Spring Security 〜カスタムログインページの実装とDB連携〜

# 概要
前回作成したSpringBootのアプリケーションに実装したSpringSecurityのログイン画面を独自のログイン画面が使えるようにする
また、メモリで管理していたユーザー情報をDBで管理するように変更する。

SpringSecurityについてあまり理解はできていないが、ひとまず実装したい方向けに記事を書いています。

[Spring Securityの実装(前回)](https://qiita.com/yuki_2392/items/e78a6f0fb5cc1559bdd6)

イメージ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3459413/8493c912-61d4-b953-b69e-eb35c41d2818.png)

# 目次
1. [カスタムログインページの実装](#カスタムログインページの実装)
1. [DBに登録したユーザーでのログイン](#dbに登録したユーザーでのログイン)
1. [実際の認証の流れ](#実際の認証の流れ)

# カスタムログインページの実装
### ログインページ(login.html)の作成
まず、ログインページを実装するためにlogin.htmlを作成します
今回のログインページのURLは「/toLogin」としています。

```html:login.html
<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>ログインページ</title>
        <!-- Bootstrap CSSの読み込み -->
        <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
    </head>
    <body>
        <div class="login-container">
            <h2>ログイン</h2>
            <form method="post" th:action="@{/toLogin}">
                <div class="form-group">
                    <label for="username">ユーザー名</label>
                    <input type="text" id="username" name="username" required>
                </div>
                <div class="form-group">
                    <label for="password">パスワード</label>
                    <input type="password" id="password" name="password" required>
                </div>
                <button type="submit" class="login-button">ログイン</button>
            </form>
        </div>
    </body>
</html>
```
入力値チェックを実装するには、formタグ内に以下を追加します
```HTML
<div th:if="${param.error}">
    <div class="alert alert-danger">
        <span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION?.message ?: 'ユーザー情報が存在しません'}"></span>
    </div>
</div>
```
解説：
ログイン失敗（「param.error」が存在）時に、エラーメッセージを出力するようにします。

認証失敗時はSpringSecurityにより「SPRING_SECURITY_LAST_EXCEPTION.message」にエラーメッセージが格納されているため、そのメッセージを出力します。

 もし、「SPRING_SECURITY_LAST_EXCEPTION」が存在しない場合は「ユーザー情報が存在しません」と返します。

[参考：リファレンス](https://spring.pleiades.io/spring-security/reference/servlet/authentication/passwords/form.html)
<details><summary>実際に作成したlogin.html</summary>

```html:login.html
<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ログインページ</title>
    <!-- Bootstrap CSSの読み込み -->
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
    <style>
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
            background-color: #f5f5f5;
            font-family: Arial, sans-serif;
        }

        .login-container {
            background-color: white;
            padding: 2rem;
            border-radius: 8px;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
            width: 100%;
            max-width: 600px;
        }

        .login-container h2 {
            margin-bottom: 1.5rem;
            color: #333;
        }

        .form-group {
            margin-bottom: 1rem;
        }

        .form-group label {
            display: block;
            margin-bottom: 0.5rem;
            color: #555;
        }

        .form-group input {
            width: 100%;
            padding: 0.5rem;
            border: 1px solid #ccc;
            border-radius: 4px;
        }

        .form-group input:focus {
            border-color: #007bff;
            outline: none;
        }

        .login-button {
            width: 100%;
            padding: 0.75rem;
            border: none;
            border-radius: 4px;
            background-color: #007bff;
            color: white;
            font-size: 1rem;
            cursor: pointer;
        }

        .login-button:hover {
            background-color: #0056b3;
        }

        .login-container p {
            margin-top: 1rem;
            text-align: center;
        }

        .login-container p a {
            color: #007bff;
            text-decoration: none;
        }

        .login-container p a:hover {
            text-decoration: underline;
        }
    </style>
</head>

<body>
    <div class="login-container">
        <h2>ログイン</h2>
        <form method="post" th:action="@{/toLogin}">
            <div th:if="${param.error}">
                <div class="alert alert-danger">
                    <span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION.message ?: 'ユーザー情報が存在しません'}"></span>
                </div>
            </div>
            <div class="form-group">
                <label for="username">ユーザー名</label>
                <input type="text" id="username" name="username" required>
            </div>
            <div class="form-group">
                <label for="password">パスワード</label>
                <input type="password" id="password" name="password" required>
            </div>
            <button type="submit" class="login-button">ログイン</button>
        </form>
    </div>
</body>

</html>
```
</details>



### Controllerの修正
/login を表示させるための記述を追加します
URLは「/toLogin」とします
```java:LoginController.java
@GetMapping("/toLogin")
public String toLogin(){
    return "/login";
}
```

### SecurityConfigの修正
ログインページが「/toLogin」になるように設定します。
```java:SecurityConfig
  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

    http
        .formLogin(login -> login
            .loginPage("/toLogin") // ログインページの設定　　〜〜追加箇所〜〜
            .defaultSuccessUrl("/user")
            .permitAll())
```

### ログアウト処理
独自のログインページを実装すると、HTMLを以下のように設定していた場合にログアウトが機能しなくなります。
```html
<a href="./logout.html" th:href="@{/logout}">ログアウト</a>
```
<img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3459413/3afd51c8-5a01-3edb-bd7a-0789f45ab8d0.png">

これは、カスタムページを使用することでCSRF保護が有効になったため発生します。

SecurityConfigに「.csrf().disable()」を追加するとCSRF保護を無効になり、aタグのログアウトでも機能しますが、非推奨

CSRF保護を有効にしたログアウトを行うためにControllerのログアウトボタンを以下の通り変更します。
```html
<form th:action="@{/logout}" method="post">
    <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
    <button type="submit">ログアウト</button>
</form>
```

解説：
トークンを送信する必要があるため、フォームの送信にはPOSTメソッドを利用します。

th:name="\\${_csrf.parameterName}" : CSRFトークンのパラメーター名を取得する
th:value="${_csrf.token}" : CSRFトークンの実際の値

# DBに登録したユーザーでのログイン
1. InMemoryUserDetailsManagerで管理をしていたユーザー情報削除
SecurityConfig.java の InMemoryUserDetailsManager userDetailsService() はコメントアウトもしくは削除します
    <br>
1. 依存関係の追加
DBと連携するために、以下の依存関係を追加します。
    ```xml:pom.xml
    <dependency>
	    <groupId>org.springframework.boot</groupId>
	    <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
	    <groupId>org.postgresql</groupId>
	    <artifactId>postgresql</artifactId>
	    <scope>runtime</scope>
    </dependency>
    ```
    <br>
1. ymlにDBとの接続情報を設定
    ```yaml:application.yml
    datasource:
        driver-class-name: org.postgresql.Driver
        url: jdbc:postgresql://localhost:5432/spring-security
        username: postgres
        password: postgres
    ```
    <br>
1. Domain の実装
id, userName, password, role 属性が定義されたドメインクラスを作成
(Java 14 以降で導入されたrecordクラスを利用しています)

    ```java:User.java
    package com.example.demo.domain;
    
    public record User(int id, String username, String password, String role) {
    }
    ```
    <br>
1. Repositoryの実装
入力されたusernameをデータベースで検索するリポジトリクラスを作成
    ```java:LoginRepository.java
    @Repository
    public class LoginRepository {
    
      @Autowired
      private NamedParameterJdbcTemplate template;
    
      private RowMapper<User> USER_ROW_MAPPER = (rs, row) -> new User(
          rs.getInt("id"),
          rs.getString("username"),
          rs.getString("password"),
          rs.getString("role"));
    
      public Optional<User> findByUsername(String username) {
        SqlParameterSource param = new MapSqlParameterSource().addValue("username", username);
        String sql = "SELECT * FROM users WHERE username = :username";
    
        return Optional.ofNullable(template.queryForObject(sql, param, USER_ROW_MAPPER));
      }
    }
    ```
    <br>
1. Service の実装
UserDetailsServiceインターフェースを実装したCustomUserDetailsService.javaを作成します。
    ```java:CustomUserDetailsService.java
    @Service
    public class CustomUserDetailsService implements UserDetailsService {
    
      @Autowired
      private LoginRepository repository;
      
      @Override
      public UserDetails loadUserByUsername(String username) throws　UsernameNotFoundException {
          User user = repository.findByUsername(username)
              .orElseThrow(() -> new UsernameNotFoundException("User not found for username : " + username));
          return new LoginUserDetails(user);
        }
    }
    ```
    <br>
1. UserDetails　を実装したクラスを作成
UserDetailsクラスでは、SpringSecurityでの認証認可に必要な
ユーザー情報(ユーザー名、パスワード、権限など）が格納されます。
```java:LoginUserDetails.java
package com.example.demo.common;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import com.example.demo.domain.User;
import java.util.Collection;
import java.util.Collections;

public class CustomUserDetails implements UserDetails {
  private final User user;
  private final Collection<GrantedAuthority> authorities;

  public CustomUserDetails(User user) {
    this.user = user;
    this.authorities = Collections.singletonList(new SimpleGrantedAuthority(user.role()));
  }

  // ユーザーに付与された権限を返す
  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return authorities;
  }

  // ユーザー名を返す
  @Override
  public String getUsername() {
    return user.username();
  }

  // ユーザーパスワードを返す
  @Override
  public String getPassword() {
    return user.password();
  }

  // アカウントが期限切れでないかを示す
  @Override
  public boolean isAccountNonExpired() {
    return true;
  }

  // アカウントがロックされていないかを示す
  @Override
  public boolean isAccountNonLocked() {
    return true;
  }

  // 資格情報が期限切れでないかを示す
  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  // アカウントが有効かを示す
  @Override
  public boolean isEnabled() {
    return true;
  }
}

```
今回、DBのカラムにはユーザー名, パスワード, 権限しかないため、他の項目についてはtrueで返しています。

# 実際の認証の流れ
SpringSecurity で実際にどのような流れで認証が行われるかをざっくり解説します

全体像
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3459413/b5dd8b97-37c6-a2d3-9dd4-455a24f99681.png)


図の参照１：[Figure 4. SecurityFilterChain](https://docs.spring.io/spring-security/reference/servlet/architecture.html#servlet-securityfilterchain)
図の参照2：[DaoAuthenticationProvider（リファレンス）](https://spring.pleiades.io/spring-security/reference/servlet/authentication/passwords/dao-authentication-provider.html)

大まかな流れ
1. リクエストが FilterChain に渡され処理が開始

2. DelegatingFilterProxy によって 認証と認可を処理するSecurityFilterChain を呼び出す

3. UsernamePasswordAuthenticationFilter でリクエスト情報からユーザー名とパスワードを抽出しUsernamePasswordAuthenticationToken（トークン） を作成

4. トークン　をAuthenticationManagerに渡し認証処理を委譲

5. DaoAuthenticationProvider から UserDetailsService を呼び出しデータベース上のユーザー名とパスワードをUserDetailsオブジェクトに格納

6. DaoAuthenticationProvider でユーザー名とパスワードの認証に成功した場合、トークンが[SecurityContextHolder](https://spring.pleiades.io/spring-security/reference/servlet/authentication/architecture.html#servlet-authentication-securitycontextholder)に格納され認証処理終了
(PasswordEncoderでリクエストから取得したパスワードをハッシュ化し比較検証)
<br>
<br>

[今回作成したアプリケーション](https://github.com/yukiya-rp/SpringSecurity_advance)

# 参考記事
[Spring SecurityのSecurity Filterについて](https://iikanji.hatenablog.jp/entry/2022/12/17/152830)
[Spring Security 使い方メモ　認証・認可](https://qiita.com/opengl-8080/items/032ed0fa27a239bdc1cc)