Spring Securityの基本的な使い方

ログイン機能では、基本的に認証/認可が必要になります。これをいちいち実装すると、何らかのミスで脆弱性につながってしまいます。Spring Bootでは、Spring Securityという認証/認可のためのフレームワークが用意されていますので、こちらを用いてカスタムした方が得策です。本記事では、Spring Securityの基本的な使い方を紹介します。ただし、Spring Bootでのアプリケーションの作り方に関しては説明しません。

## 前提
### 環境

<table>
    <tr>
        <th>言語</th>
        <td>Java 17.x</td>
    </tr>
    <tr>
        <th>フレームワーク</th>
        <td>spring boot 3.x</td>
    </tr>
    <tr>
        <th>テンプレートエンジン</th>
        <td>thymeleaf</td>
    </tr>
    <tr>
        <th>ビルドツール</th>
        <td>gradle</td>
    </tr>
    <tr>
        <th>DB</th>
        <td>postgreSQL 11.x</td>
    </tr>
    <tr>
        <th>IDE</th>
        <td>IntellJ</td>
    </tr>
</table>

### クラスファイル
本記事では、` Test~.java `というクラスファイルが出てきた場合は、自作のクラスです。以下では、上記のクラスが断りなく出てきますので、ご了承下さい。

## gradleの追加
`build.gradle`の`dependependencies`にspring securityを追加します（テストやthymeleafも含めています）。
```yaml
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
testImplementation 'org.springframework.security:spring-security-test'
```
その後、`build.gradle`を再ロードします。

## セキュリティ設定
Spring Securityに必要な設定をします。まず、その設定ファイルを作成します。どこに作っても良いですが、ここでは、`com.example.common`に`SecurityConfig.java`を作成しました。

```SpringConfig.java
package com.example.common;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
public class SecurityConfig {

    @Bean
    protected SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        http.authorizeHttpRequests(authz -> authz
                .requestMatchers("/css/**", "/js/**", "/img/**").permitAll()
                .requestMatchers("/").permitAll()
                .requestMatchers("/toInsert").permitAll()
                .requestMatchers("/insert").permitAll()
                .anyRequest().authenticated()
        ).formLogin(login -> login
                .loginPage("/")
                .loginProcessingUrl("/login")
                .failureUrl("/?error=true")
                .defaultSuccessUrl("/topPage", true)
                .usernameParameter("mailAddress")
                .passwordParameter("password")
        ).logout(logout -> logout
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout**"))
                .logoutSuccessUrl("/")
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID")
        );

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}
```
### ルーティング

| URL | 説明 |
| ---- | ---- |
| `/` | ログイン画面へのgetリクエスト |
| ` /toInsert ` | ユーザの新規登録画面へのgetリクエスト |
| ` /insert ` | ユーザの新規登録のpostリクエスト |


### アノテーション
* `@Configuration`:このクラスがSpringの設定クラスであることを示します。

### HttpSecurityの設定
securityFilterChainメソッドでは、認証、ログイン、ログアウトの動作を定義しています。
* `.authorizeHttpRequests(authz -> authz`
    * `requestMatchers(~).permitAll()`
        * この設定により、指定されたURLパターンに対するリクエストは全て認証なしでアクセスできるようになります。
    * `anyRequest().authenticated()`
        * 上記で許可されていない他の全てのリクエスト認証を要求します。
* `.formLogin(login -> login`
    * `loginPage("/")`
        * 自作のログインページのURLを指定します。認証が必要なページにアクセスした場合、このページにリダイレクトされます。ログインページへのURLをpermitAllにしておかないと、403エラーが返ってくるので、注意です。
    * `loginProcessingUrl("/login")`
        * ログインフォームが送信されるURLを指定します。このURLへのPOSTリクエストをSpring Securityが処理します。
    * `failureUrl("/?error=true")`
        * ログイン失敗時にリダイレクトされるURLを指定します。
        * ` error `というクエリパラメータを設定しておくことで、コントローラ側でそのパラメータでログインミスか判断できます。
    * `defaultSuccessUrl("/employee/showList", true)`
        * ログイン成功後のリダイレクト先URLを指定します。
        * trueは常にこのURLにリダイレクトすることを意味します。
        * falseにすると、認可が必要なページにアクセスする時に認証後、元々遷移しようとしたページに移動します。
    * `usernameParameter("mailAddress")`
        * ログインフォームのユーザー名フィールドのnameを指定します。
    * `passwordParameter("password")`
        * ログインフォームのパスワードフィールドのnameを指定します。
* `.logout(logout -> logout`
    * `logoutRequestMatcher(new AntPathRequestMatcher("/logout**"))`
        * ログアウトリクエストのURLパターンを指定します。
    * `logoutSuccessUrl("/")`
        * ログアウト成功後のリダイレクト先URLを指定します。
    * `invalidateHttpSession(true)`
        * ログアウト時にHTTPセッションを無効にします。
    * `deleteCookies("JSESSIONID")`
        * ログアウト時に特定のクッキー（ここではJSESSIONID）を削除します。

### PasswordEncoderの設定
* パスワードをハッシュ化するための`PasswordEncoder`を定義しています。ここでは`BCryptPasswordEncoder`を使用しています。Spring Securityでは、認証時にハッシュ化されたパスワードで比較をします。そのため、このメソッドをユーザ登録の際に用いることにより、登録するパスワードをハッシュ化する必要があります。

## ログイン処理用の管理者情報
Spring SecurityのUserクラスを継承し、ログイン処理用の管理者情報を保持するドメインを作成します。

```LoginAdministrator.java
package com.example.domain;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

import java.util.Collection;

public class LoginAdministrator extends User {
    
    private final Administrator administrator;

    public LoginAdministrator(Administrator administrator, Collection<GrantedAuthority> authorities){
        super(administrator.getMailAddress(), administrator.getPassword(), authorities);
        this.administrator = administrator;
    }

    public Administrator getAdministrator() {
        return administrator;
    }
}
```


このクラスは、自身で作成したドメインである`Administrator`オブジェクトを内部に保持し、必要に応じてアクセスできるようにすることで、Spring Securityの認証および認可プロセス中にカスタムのユーザーデータを利用できるようにしています。

## ログイン処理用のサービス
Spring Securityの認証プロセスで使用される`UserDetailsService`インターフェースを実装したサービスクラスを作成します。

```AdministratorDetailService.java
package com.example.service;

import com.example.domain.Administrator;
import com.example.domain.LoginAdministrator;
import com.example.repository.AdministratorRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.Collection;

@Service
public class AdministratorDetailsService implements UserDetailsService {

    @Autowired
    private TestRepository testRepository;

    @Override
    public UserDetails loadUserByUsername(String mailAddress) throws UsernameNotFoundException {
        Administrator administrator = testRepository.findByMailAddress(mailAddress);

        if(administrator == null){
            throw new UsernameNotFoundException("Not found mail address:" + mailAddress);
        }

        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
        return new LoginAdministrator(administrator, authorities);
    }
}
```

### `loadUserByUsername`メソッドのオーバーライド
このメソッドは、ユーザー名（ここではメールアドレス）を使用してユーザー情報をロードするためのメソッドです。
* `Administrator`オブジェクトをリポジトリを使ってメールアドレスで検索します。
* `administrator`が見つからない場合、`UsernameNotFoundException`をスローします。
* ユーザーが見つかった場合、権限リスト(`Collection<GrantedAuthority>`)を作成し、デフォルトで ROLE_USER 権限を追加します。
* 最後に、`LoginAdministrator`オブジェクトを作成して返します。このオブジェクトにはユーザーの詳細情報と権限リストが含まれます。

## 処理の流れ
最後に全体の処理の流れをまとめます。

1. ユーザーのリクエスト:
ユーザーが保護されたリソース（認証が必要なページやAPIエンドポイント）にアクセスしようとします。
    
2. フィルターチェーンによるリクエストのキャッチ:
Spring Securityのフィルターチェーンがリクエストをキャッチします。ここでは、主要なフィルターとして `UsernamePasswordAuthenticationFilter`が使用されますが、今回は`SecurityConfig`で作成した`securityFilterChain`メソッドが一部使われます。

3. ユーザー認証情報の取得:
ユーザーがログインフォームを使用して認証情報（メールアドレスとパスワード）を送信します。
`UsernamePasswordAuthenticationFilter`がその認証情報をキャッチします。

4. `AuthenticationManager`による認証処理:
`UsernamePasswordAuthenticationFilter`は、送信された認証情報を`AuthenticationManager`に渡します。
`AuthenticationManager`は、`AdministratorDetailsService`を使用してユーザー情報をロードします。

5. `AdministratorDetailsService`の呼び出し:
`TestRepository`を使用してデータベースからユーザー情報を取得します。

6. ユーザー情報の検証:
`AdministratorDetailsService`がユーザー情報を見つけられなかった場合、`UsernameNotFoundException`がスローされます。
ユーザー情報が見つかった場合、そのユーザーの詳細情報（ユーザー名、パスワード、権限）が`LoginAdministrator`オブジェクトとして返されます。

7. 認証情報の確認:
`AuthenticationManager`は`UserDetails`オブジェクトを使用して、送信されたパスワードが正しいかどうかを確認します。
正しい場合、ユーザーは認証され、認証情報がセッションに保存されます。

8. アクセス許可の判断:
認証が成功した後、Spring Securityはユーザーがアクセスしようとしているリソースに対する適切な権限を持っているかどうかを確認します。
権限があれば、ユーザーはリソースにアクセスできます。権限がなければ、アクセス拒否（403 Forbidden）が発生します。

## 補足
### ログイン成功/失敗時の共通処理
ログイン成功した時や失敗した時に行いたい処理がある場合の作成の仕方を紹介します。また、成功時も失敗時も同じような処理ですので、ここでは、成功時の処理のみ説明します。

#### AuthenticationSuccessHandlerの作成
`AuthenticationSuccessHandler`をimplementsした`CustomAuthenticationSuccessHandler`クラスを作成します。そのクラスでは、`onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,Authentication authentication)`メソッドをオーバーライドします。

具体的には以下の通りです。

```CustomAuthenticationSuccessHandler.java
package com.example.security;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.stereotype.Component;

import java.io.IOException;

/**
 * 認証後の処理用のクラス.
 */
@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private final RequestCache requestCache = new HttpSessionRequestCache();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException{

        //　ここに処理を書きます

        SavedRequest savedRequest = requestCache.getRequest(request, response);
        String contextPath = request.getContextPath();

        if(savedRequest != null) {
            String targetUrl = savedRequest.getRedirectUrl();
            response.sendRedirect(targetUrl);
        }else {
            response.sendRedirect(contextPath + "/");
        }
    }
}
```


#### SecurityConfigファイルに追加
`CustomAuthenticationSuccessHandler`クラスを` SecurityConfig `クラスに追加します。

```java
@Autowired
private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
```
フィールド変数に`customAuthenticationSuccessHandler`変数を用意しておきます。

```java
.formLogin(login -> login
                .loginPage("/")
                .loginProcessingUrl("/login")
                .failureUrl("/?error=true")
                .successHandler(customAuthenticationSuccessHandler)
                .usernameParameter("mailAddress")
                .passwordParameter("password")
)
```
ここでは、`.successHandler(customAuthenticationSuccessHandler)`を追加します。そのクラス内で、ログイン後のページ遷移の設定をしているので、`.defaultSuccessUrl("/employee/showList", true)`は削除しておきます。