【Spring Boot】ログイン機能とログインユーザーの取得

Spring Bootを使ってログイン機能を作成しようとしたら思ったより複雑だったので記録(2022時点で動作確認済み)

## 前提
version
```
java 11
maven 3.6.3
Spring Boot 2.7.0
```

必要なdependencyの追加
```pom.xml
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>
```
次のようなアカウントのEntityクラスを使用想定
```Account.java
@Entity
@Table(name = "accounts")
@Getter
@Setter
public class Account {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column
    private Long id;
    @Column(nullable = false)
    private String name;
    @Column(nullable = false)
    private String password;
}
```

## 1. 認証の設定
`WebSecurityConfig`クラスの作成
認証の適用する範囲やどのように認証を行うか設定する
```WebSecurityConfig.java
package com.example.todo;

import com.example.todo.action.auth.AccountDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/register").permitAll() // /regisdterにアクセス許可
                .anyRequest().authenticated() // 全てのリクエストに認証設定
                .and()
                .formLogin() // フォームログインで認証する
                .loginPage("/login") // ログインページのURL
                .loginProcessingUrl("/login") // ログイン処理を行うURL
                .usernameParameter("name") // 認証にnameとpasswordを使用
                .passwordParameter("password")
                .defaultSuccessUrl("/index") // ログイン後に飛ぶURL
                .failureUrl("/login?error") // ログイン失敗時に飛ぶURL
                .permitAll() // /loginページにアクセス許可
                .and()
                .logout()
                .logoutUrl("/logout") // ログアウト処理を行うURL
                .logoutSuccessUrl("/login") // ログアウト後に飛ぶURL
                .permitAll();
    }
}
```
* `@EnableWebSecurity`：Spring Securityを有効化
* `WebSecurityConfigurerAdapter`を継承

## 2. 認証サービスの実装
### ユーザー情報クラス
ユーザー情報を保持するクラス
```AccountDetail.java
package com.example.todo.action.auth;

import com.example.todo.entity.Account;
import org.springframework.security.core.userdetails.User;
import java.util.ArrayList;
import lombok.Getter;

public class AccountDetail extends User{
    @Getter
    private Account account;
    public AccountDetail(Account account){
        super(
                account.getName(),
                account.getPassword(),
                true,
                true,
                true,
                true,
                new ArrayList<>()
        );
        this.account = account;
    }
}
```
* `User`クラスを継承
* `User`クラスは`UserDetails`インターフェイスを実装
* name, passwordをユーザー情報として保持

### ユーザー情報サービスクラス
ログインフォームで入力されたusernameからユーザーが存在するか検索
```AccountDetailsService.java
package com.example.todo.action.auth;

import com.example.todo.entity.Account;
import com.example.todo.repository.AccountRepository;
import org.springframework.beans.factory.annotation.Autowired;
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;

@Service
public class AccountDetailsService implements UserDetailsService {
    
    @Autowired
    private AccountRepository repository;

    /**
     * 認証処理
     * @param name (Account.name)
     * @return AccountDetail
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {
        Account account = repository.findByName(name);
        if (account == null) {
            throw new UsernameNotFoundException("User " + name + " not found");
        }
        return new AccountDetail(account);
    }
}
```
* `UserDetailsService`を継承
* `loadUserByUsername`の引数に入力されたnameが渡される
* nameに一致するユーザー情報が存在するかをチェック

### リポジトリ
```AccountRepository.java
@Repository
public interface AccountRepository extends JpaRepository<Account,Long> {
    @Query("SELECT a FROM Account a WHERE a.name = :name")
    Account findByName(String name);
}
```
* 存在した場合はユーザー情報のオブジェクトを生成

ここではnameだけで認証を行なっているので、`WebSecurityConfig`で`AuthenticationManagerBuilder`を使用。
```WebSecurityConfig.java
package com.example.todo;

import com.example.todo.action.auth.AccountDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
    
    @Autowired
    private AccountDetailsService userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/register").permitAll() // /regisdterにアクセス許可
                .anyRequest().authenticated() // 全てのリクエストに認証設定
                .and()
                .formLogin() // フォームログインで認証する
                .loginPage("/login") // ログインページのURL
                .loginProcessingUrl("/login") // ログイン処理を行うURL
                .usernameParameter("name") // 認証にnameとpasswordを使用
                .passwordParameter("password")
                .defaultSuccessUrl("/index") // ログイン後に飛ぶURL
                .failureUrl("/login?error") // ログイン失敗時に飛ぶURL
                .permitAll() // /loginページにアクセス許可
                .and()
                .logout()
                .logoutUrl("/logout") // ログアウト処理を行うURL
                .logoutSuccessUrl("/login") // ログアウト後に飛ぶURL
                .permitAll();
    }

    /**
     * Passwordのハッシュ化メソッド
     * 認証時に使用
     * @return BCryptPasswordEncoder
     */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 認証処理時に呼び出されるメソッド
     * 入力されたpasswordをハッシュ化してチェック
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }
}
```
* `passwordEncoder`：入力されたパスワードをハッシュ化
* DBのパスワードと一致するか確認
-> これによってnameとpasswordで認証が可能に

## 3. ログインフォームの作成

自作ログインフォームの作成

```login.html
<label th:if="${error}" th:text="${error}" class="alert alert-danger text-center"></label>
<form class="mt-sm-4" method="post" th:action="@{/login}" aria-label="Login">
  <div class="mb-3 input-group-lg">
    <label>Name</label>
    <input class="form-control" type="text" name="name" required>
  </div>
  <div class="mb-3 input-group-lg">
    <label>Password</label>
    <input class="form-control" type="password" name="password" required>
  </div>
  <div class="d-grid">
    <button type="submit" class="btn btn-lg btn-primary">Login</button>
  </div>
</form>
```
フォームの表示
```AccountController.java
@RequestMapping(value = "/login")
public String showLoginForm(@RequestParam(name = "error",required = false) String error,Model model){
    if(error!=null){
        model.addAttribute("error","ログイン名またはパスワードが間違っています");
    }
    return "login";
}
```
* `/login?error`：ログイン失敗時にエラーメッセージを表示

## 4. ログアウト
```html
<form class="d-flex" method="post" th:action="@{/logout}">
  <button class="btn btn-outline-secondary" type="submit">Logout</button>
</form>
```
* `POST`メソッドで`/logout`へ
* Controllerの設定は不要