Spring Security ログイン認証のDBアクセス処理を実装

## はじめに
Spring Security シリーズ第3回目です。

- 第1回：[最初のSpring Security - フォーム認証＆画面遷移](https://qiita.com/huge-book-storage/items/56b86d2986cf04632706)
- 第2回：[Spring Securityのログインページをカスタマイズする](https://qiita.com/huge-book-storage/items/3dbf92dac5ba7c812893)
- 第3回：ログイン認証のDBアクセス処理を実装（本記事）
 

今までのフォーム認証のID／PASSはSpring Securityデフォルトのものでしたが、実際にデータベースにユーザを登録・取得した形での認証を行うよう処理を追加します。

### 目標

- DBからユーザを取得する形の認証を、Spring Securityの流れに沿って実装する
- 取得したユーザ名をログイン成功画面に表示する

### FW、ミドルウェア
本記事では下記バージョンを使用します。

- Spring Boot 2.1.7
- Spring Security 5.1.5（maven依存）
- Java 11
- H2DB 1.4.199
- DBFlute 1.2

#### ※注意

- 本記事で扱うO/Rマッパは筆者の好みでDBFluteを選びましたが、O/Rマッパはお好きなものを選択してください。
- DBに関して本記事ではH2DBを使いますが、お好きなものを選択してください。
- H2DB + DBFluteの環境構築については[こちら](https://qiita.com/huge-book-storage/items/a15678dffd3a836316ff)を参照してください。

## 実装
[第1回](https://qiita.com/huge-book-storage/items/56b86d2986cf04632706)、[第2回](https://qiita.com/huge-book-storage/items/3dbf92dac5ba7c812893)の実装に追加する形で行います。

### 依存jarの追加
DBFluteによりDBアクセスを行うための依存関係を追加します。

```xml:pom.xml
<!-- 中略 -->
<dependency>
    <groupId>org.dbflute</groupId>
    <artifactId>dbflute-runtime</artifactId>
    <version>1.2.0-patch20190620-RC1</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
<!-- 中略 -->
```

### 接続設定追加
DB接続設定を追加します。

```yml:application.yml
spring:
  datasource:
    url: jdbc:h2:tcp://localhost/./h2data
    username: sa
    driverClassName: org.h2.Driver
```

## DDL作成
ログイン判定用のDDLを作成します。
作成したDDLを元に、DBFluteのマイグレーション機能にてスキーマ作成を行います。

```sql:replace-schema.sql
create table M_USER (
    USER_ID varchar(32) not null primary key,
    USER_NAME varchar(64) not null,
    PASSWORD varchar(128) not null
);
```

## データ投入
ログイン可能なユーザを定義するデータを作成します。
作成したデータは、DBFluteのマイグレーション機能にて投入を行います。

```playsql/data/ut/tsv/utf-8/1-m_user.tsv
USER_ID	USER_NAME	PASSWORD
user1	ユーザ1	{bcrypt}$2a$10$xeYLBfOQILT1XKYhofosg.a3I1Vg8vF6Kd4NXjfigyy/.N.7AwYU.
user2	ユーザ2	{bcrypt}$2a$10$142YrOgdho1EvrXhstuYMuD.6l5XrJt4yyJ6t6kcJLi7bHvDzpF3O
user3	ユーザ3	{bcrypt}$2a$10$WlijXJStltiGmakfhoRBQuMy2Xlw6EOtnbrMQRg65tlF0aU5y2.7i
```

※投入データ「PASSWORD」はハッシュ化されたものを入れておきます。ハッシュ化方式については「補足：パスワードのハッシュ化について」参照

## 汎用エラーメッセージ追加
ログイン認証中に何かしらのエラーが起きたときのために、次のような汎用エラーメッセージを追加しておきます。

```properties:src/main/resources/messages_ja.properties
demo.unexpectedError=エラーが発生しました。システム管理者に連絡してください
```

## ユーザ情報取得用インターフェース追加
認証成功後、アプリケーション側で使用することを目的としたユーザ情報取得用インターフェースです。
こちらは、基本的に一度きり書き込むだけなので、setterは加えません。

```DemoUserInfo.java
public interface DemoUserInfo {
    String getUserId();
    String getUserName();
}
```

## Spring Security用のユーザ情報取得クラス追加
Spring Security用に`UserDetails`インターフェースを実装したクラスを用意します。
`UserDetails`の実装以外に、上記で定義した`DemoUserInfo`を取得するメソッドを追加しており、アプリケーション側で使用するための架け橋としての役割も果たしています。（`username`の表す対象が、Spring Securityとアプリケーション側で異なるので、混同を避けるためにそこを区別しています）

```DemoUserDetails.java
class DemoUserDetails extends User {

    private final DemoAuthorizedUser demoUserInfo;

    public DemoUserDetails(DemoAuthorizedUser demoUserInfo, String password) {
        super(demoUserInfo.getUserId(), password, Collections.emptyList());
        this.demoUserInfo = demoUserInfo;
    }

    public DemoAuthorizedUser getDemoUser() {
        return demoUserInfo;
    }
}
```

#### 注意
`UserDetails`には多数の（使用しない可能性もある）メソッドが定義されており、実装の煩雑さを避けるためSpring側で`org.springframework.security.core.userdetails.User`という実装クラスを用意しています。今回はそれを利用してこの`User`クラスを継承しています。

## 認証サービスクラス追加
ログイン処理用のサービスを追加します。

```DemoUserDetailsService.java
@Service
class DemoUserDetailsService implements UserDetailsService {

    @Autowired
    private MUserBhv userBhv;

    @Autowired
    private MessageSource messageSource;

    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {

        // 入力値が空の場合もあるため、予めチェックしておく。（DBFluteの場合、事前にチェック必須。）
        if (userId == null || userId.isEmpty()) {
            // UsernameNotFoundExceptionにメッセージを渡しても画面に反映されないので、
            // ここでは適当に空文字を入れておく
            throw new UsernameNotFoundException("");
        }

        try {
            // 渡されたIDをキーにユーザ情報取得
            return userBhv.selectEntity(cb -> {
                cb.acceptPK(userId);
            }).map(user -> {
                // ユーザが見つかった場合、認証情報を詰め替えておく
                DemoAuthorizedUserImpl demoUseInfo = new DemoAuthorizedUserImpl();
                demoUseInfo.setUserId(user.getUserId());
                demoUseInfo.setUserName(user.getUserName());
                // さらに、Spring Security用の認証情報も生成
                return new DemoUserDetails(demoUseInfo, user.getPassword());
            })
            // ユーザが見つからなかった場合、規定の例外を投げる。
            // なお、UsernameNotFoundExceptionにメッセージを渡しても画面に反映されない（上記と同じ）
            .orElseThrow(() -> new UsernameNotFoundException(""));
        } catch (Exception e) {
            // DBアクセス等でエラー（接続エラー等）が起きた場合に備えて、
            // 例外を捕まえ、適正なメッセージを持った別の例外に変えて投げ直す。
            // こうしておかないと、発生した元のExceptionの持つエラーメッセージがログイン画面に表示されてしまう
            throw new DbAccessException(
                    messageSource.getMessage("demo.unexpectedError", null, LocaleContextHolder.getLocale()), e);
        }
    }

    private static class DbAccessException extends RuntimeException {

        DbAccessException(String msg, Exception cause) {
            super(msg, cause);
        }
    }

    private static class DemoAuthorizedUserImpl implements DemoAuthorizedUser {

        private String userId;
        private String userName;
        // getter, setter
    }
}
```

- ユーザが見つからないエラーの場合`UsernameNotFoundException("")`を投げておきます。こうしておくと、フレームワーク側で元の入力画面に戻し、適正なエラーメッセージを表示可能な状態にしてくれます。なお、`UsernameNotFoundException`の引数が空文字なのは、ここでメッセージを指定しても意味がないからです。（[前回の記事](https://qiita.com/huge-book-storage/items/3dbf92dac5ba7c812893)参照）
- DBアクセス等でエラー（接続エラー等）が起きた場合に備えて、`try...catch`で例外を捕まえ、適正なメッセージを持った別の例外に変えて投げ直します。こうしておかないと、**発生したExceptionの持つエラーメッセージがログイン画面に表示されてしまうので注意してください**。なお、必ずしも例外クラスは新しく作成する必要はありませんが、エラーログ等出力された際にどのような例外が発生したか、一目でわかるような例外クラスを定義しています。

## 認証済のユーザ情報取得用ユーティリティ関数用意
認証済の場合、認証情報がThreadLocalに保持されますが、実際にアプリケーション側で使いたい情報にアクセスするには階層が深くなってしまっているため、ユーティリティを用意して容易に取得できるようにしておきます。

```DemoAuthUtil.java
public class DemoAuthUtil {

    public static DemoAuthorizedUser getDemoUserInfo() {

        // 基本的にWebアクセスの場合、getContext()はnullにならないが、
        // それ以外（コマンドライン等、想定外の使い方）のアクセス等を考慮して念のためチェックを入れる
        if (SecurityContextHolder.getContext() == null) {
            throw new AuthenticatedUserNotFoundException();
        }
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null) {
            throw new AuthenticatedUserNotFoundException();
        }
        // 未認証状態の場合、getPrincipal()は「anonymousUser」文字列（String型）が返る
        // そのため、認証済であるか判定するには、これがユーザ情報クラスのインスタンスかどうかを調べれば良い
        if (!(auth.getPrincipal() instanceof DemoUserDetails)) {
            throw new AuthenticatedUserNotFoundException();
        }
        return ((DemoUserDetails) auth.getPrincipal()).getDemoUser();
    }

    private static class AuthenticatedUserNotFoundException extends RuntimeException {
    }
}
```

## ログイン成功後の処理追加
ログイン成功後のページにログインユーザ名を表示するため、先ほど作成した認証済ユーザ情報取得ユーティリティを利用して、コントローラ→ビューにデータを渡します。

```DemoController.java
@Controller
class DemoController {
    // 中略
    @GetMapping("success")
    public String success(Model model) {
        model.addAttribute("username", DemoAuthUtil.getDemoUserInfo().getUserName());
        return "success.html";
    }
    // 中略
}
```

```success.html
<!-- 中略 -->
    <!--                   ▼今回追加▼                   -->
    <p style="text-align: center;">ようこそ！[[${username}]]さん</p>
    <!--                   ▲今回追加▲                   -->
    <p style="text-align: center;">ログイン成功後のページです。</p>
    <form class="form-signin" th:action="@{/logout}" method="post">
        <button class="btn btn-lg btn-primary btn-block" type="submit">ログアウト</button>
    </form>
<!-- 中略 -->
```

## 動作確認

### ログインページ
サーバを立ち上げて`/login`を開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299510/f8215671-2ba7-bd3a-1ddb-7c91fb4f2529.png)

### ID、パスワード入力誤り
ID、パスワードを誤るとログインページに戻ってきてエラー表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299510/44e32640-cd14-2799-e106-e1acf947bdd6.png)

### ログイン成功
正しいID／PASSを入力してログイン成功させます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299510/d32054b0-55ec-9f41-8145-2d70739d2b55.png)

### ログアウト成功
ログアウトボタンをクリックするとログインページに戻ります。
URLが`/login?logout`となるため、専用のメッセージが表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299510/9e4ad84e-c55d-4cf9-d280-ac968bb92d31.png)

### ログインエラー
H2DBサーバを落として、接続エラーが起きるようにした場合、ログイン実行時に設定した汎用エラーメッセージが表示されることを確認します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299510/11450aa1-f1af-8ac0-fc12-df21242ab999.png)

## おわりに
いかがだったでしょうか。DBアクセスの際、例外処理など若干注意する点があったり、認証後の情報を取得する方法が特殊だったりしますが、その辺を一度把握しておけば基本的な認証フローの実装が容易に行えるようになると思います。

## 補足：ちょっとしたカスタマイズ
### ログイン実行URLの変更
デフォルトでは、ログイン実行パスが`/login`（`POST`送信）となっていますが、
これを次のようにして変更することができます。

```DemoWebSecurityConfig.java
@Configuration
class DemoWebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.formLogin().loginProcessingUrl("/mylogin");  // ←ログイン実行パスを変更
        // 中略
    }
}
```

これを変更した際、Thymeleaf側でも送信先を適切に変更する必要があります。

```src/main/resources/templates/login.html
<!-- 中略 -->
<form th:action="@{/mylogin}" method="POST">
<!-- 中略 -->
```

### ID／パスワードのパラメータ名変更
デフォルトでは`username`、`password`というパラメータでID／パスワードを受け取りますが、これを次の方法でカスタマイズできます。

```DemoWebSecurityConfig.java
@Configuration
class DemoWebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.formLogin().usernameParameter("user").passwordParameter("pass");
        // 中略
    }
}
```

これを変更した際、Thymeleaf側でも送信先を適切に変更する必要があります。

```src/main/resources/templates/login.html
<!-- 中略 -->
<input type="text" name="user">
<input type="password" name="pass">
<!-- 中略 -->
```

## 補足：パスワードのハッシュ化について
Spring SecurityではデフォルトでDelegatingPasswordEncoderを使用してハッシュ化を行っています。（参考：[SPRING SECURITY 5: NEW PASSWORD STORAGE FORMAT](https://info.michael-simons.eu/2018/01/13/spring-security-5-new-password-storage-format/)）

ハッシュ化されたパスワードをDBに格納する際、次のような方法でハッシュ化したものを取得して格納します。

```Tmp.java
public class _Tmp {

    public static void main(String[] args) {
        PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
        System.out.println(encoder.encode("password1"));
        System.out.println(encoder.encode("password2"));
        System.out.println(encoder.encode("password3"));
    }
}
```