Spring Securityのログインページをカスタマイズする

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

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

前回作成したフォーム認証機能に対して、ログインページのカスタマイズを行います。

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

- Spring Boot 2.1.5
- Spring Security 5.1.5（maven依存）
- Java 11（AdoptOpenJDK）

## 実装
第1回の実装に追加する形で行います。

### 依存jarの追加
ログインページをThymeleafで作成します。
Thymeleafを使うための依存関係を追加します。

```xml:pom.xml
<!-- 中略 -->
<!-- ログインページ作成用にThymeleafを使用 -->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- 中略 -->
```

### セキュリティ設定
ログインページをカスタマイズするための設定を追加します。

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

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

        http
            // AUTHORIZE
            .authorizeRequests()
            /* */.mvcMatchers("/hello").permitAll()
            /* */.anyRequest()
            /*    */.authenticated()
            .and()
            // LOGIN
            .formLogin()
            // ▼今回追加
            /* */.loginPage("/login")
            /*    */.permitAll()
            // ▲今回追加
            /* */.defaultSuccessUrl("/success")
        // end
        ;
        // ▼今回追加
        // メッセージをカスタマイズするために、メッセージソースを設定する
        AuthenticationManager a = this.authenticationManager();
        if (a instanceof ProviderManager) {
            ProviderManager a2 = (ProviderManager)a;
            a2.getProviders().forEach(p -> {
                if (p instanceof MessageSourceAware) {
                    ((MessageSourceAware)p).setMessageSource(s);
                }
            });
        }
        // ▲今回追加
    }
}
```

- `formLogin().loginPage("/login").permitAll()`にて、カスタムログインページの設定を行います。
  - `loginPage(...)`でカスタムのログインパスを指定します。この例だとデフォルトログインページも同じパスですが、明示的に指定しないとカスタムページに届きません。
  - `permitAll()`にて未ログイン時のアクセスを許容します（これを指定しないと、ログインページ開くのにログインページを求められて無限リダイレクトします）。
- `MessageSourceAware#setMessageSource`を呼び出して、Spring Securityが提供するデフォルトメッセージをカスタマイズします。※補足参照

### メッセージ追加
カスタマイズするメッセージを設定します。

```properties:src/main/resources/messages.properties
AbstractUserDetailsAuthenticationProvider.badCredentials=IDまはたパスワードが誤っています。
demo.login.logouted=ログアウトに成功しました。
```

### コントローラ追加
ログインページ表示のコントローラを追加します。

- リクエストパスが`login?error`の場合、エラーメッセージを表示するためのデータを用意します。
- リクエストパスが`login?logout`の場合、ログアウト完了メッセージを表示するためのデータを用意します。

```DemoController.java
@Controller
class DemoController {

    @GetMapping(path = "login")
    public String login(@RequestParam(value = "error", required = false) String error,
            @RequestParam(value = "logout", required = false) String logout,
            Model model, HttpSession session) {

        model.addAttribute("showErrorMsg", false);
        model.addAttribute("showLogoutedMsg", false);
        if (error != null) {
            if (session != null) {
                AuthenticationException ex = (AuthenticationException) session
                        .getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
                if (ex != null) {
                    model.addAttribute("showErrorMsg", true);
                    model.addAttribute("errorMsg", ex.getMessage());
                }
            }
        } else if (logout != null) {
            model.addAttribute("showLogoutedMsg", true);
        }
        return "login";
    }

    @GetMapping("success")
    public String success() {
        return "success.html";
    }
}
```

### ログインページ
カスタマイズしたログインページです。
（といいつつ、Spring標準の`DefaultLoginPageGeneratingFilter`からデフォルトログインページを拝借しましたが・・・）

```html:src/main/resources/template/login.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>ログイン</title>
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
    <link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet" crossorigin="anonymous"/>
</head>
<body>
<div class="container">
   <p style="text-align: center;">オリジナルログインフォームです</p>
   <form class="form-signin" th:action="@{/login}" method="post">
        <div th:if="${showErrorMsg}" class="alert alert-danger" role="alert" th:text="${errorMsg}">
            ※エラーメッセージ※
        </div>
        <div th:if="${showLogoutedMsg}" class="alert alert-success" role="alert" th:text="#{demo.login.logouted}">
            ※ログアウトメッセージ※
        </div>

        <p>
          <input type="text" id="username" name="username" class="form-control" placeholder="ユーザID" required autofocus>
        </p>
        <p>
          <input type="password" id="password" name="password" class="form-control" placeholder="パスワード" required>
        </p>
        <button class="btn btn-lg btn-primary btn-block" type="submit">ログイン</button>
   </form>
</div>
</body>
</html>
```

- `th:action="@{/login}" method="post"`でログインの送信先を指定します。
  - デフォルト送信先が`{contextPath}/login`になります。
  - httpメソッドは`POST`である必要があります。
- `<div th:if="${iserror}" >...</div>`で、エラーが発生して画面に戻った際、この部分が表示されます


### ログイン成功ページ
ログイン成功後のページです。
ログアウトボタンを配置して、クリックするとログアウト実行されるようにしています。

```html:src/main/resources/template/success.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>マイページ</title>
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
    <link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet" crossorigin="anonymous"/>
</head>
<body>
<div class="container">
   <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>
</div>
</body>
</html>
```


## 動作確認

### ログインページ
サーバを立ち上げて`/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を入力してログイン成功させます。
デフォルトのID／PASSについては[こちら](https://qiita.com/huge-book-storage/items/56b86d2986cf04632706)を参照してください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299510/9b217e7a-6610-868f-0d6a-ae820203affa.png)

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

## おわりに
今回はログインページにフォーカスした記事を扱ってみました。
次回は認証処理をカスタマイズしてDBアクセスによる認証を実装しようと思います。


## 補足：メッセージのカスタマイズについて
少し調べたところ、メッセージプロパティに`AbstractUserDetailsAuthenticationProvider.badCredentials`を設定するとカスタマイズ可能、という情報が複数ヒットしましたが、実際にやってみてもメッセージが変わりませんでした。

そこで、Springのソースコードをデバッグ実行して調べてみた結果、該当箇所`AbstractUserDetailsAuthenticationProvider`に行き当たりました。
実装は次のようなものになっています。

```java:AbstractUserDetailsAuthenticationProvider.java

public abstract class AbstractUserDetailsAuthenticationProvider implements
        AuthenticationProvider, InitializingBean, MessageSourceAware {
    // 中略
    protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); // ←使用するメッセージソースはここで初期化されている
    // 中略

    public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {
        // 中略
            try {
                user = retrieveUser(username,
                        (UsernamePasswordAuthenticationToken) authentication);
            }
            catch (UsernameNotFoundException notFound) {
                logger.debug("User '" + username + "' not found");

                if (hideUserNotFoundExceptions) {
                    throw new BadCredentialsException(messages.getMessage(
                            "AbstractUserDetailsAuthenticationProvider.badCredentials", //←ここで該当メッセージを取得
                            "Bad credentials"));
                }
                else {
                    throw notFound;
                }
            }
        // 中略
    }
    // 中略
}
```

`SpringSecurityMessageSource#getAccessor`の実装は次のようになっています

``` SpringSecurityMessageSource.java
public class SpringSecurityMessageSource extends ResourceBundleMessageSource {

	public SpringSecurityMessageSource() {
		setBasename("org.springframework.security.messages"); // ←ここでリソースのbasenameが決まっている
	}

	public static MessageSourceAccessor getAccessor() {
		return new MessageSourceAccessor(new SpringSecurityMessageSource());
	}
}
```

つまり、パッケージ`org.springframework.security`に配置されたメッセージソースが使われることになります。

よって、メッセージをカスタマイズするには、`AbstractUserDetailsAuthenticationProvider`の保持している`messages`をハンドル可能なものに変えなければなりません（？）。

`AbstractUserDetailsAuthenticationProvider`は、`WebSecurityConfigurerAdapter#authenticationManager`で得られる`ProviderManager`型のインスタンスが保持しているため、それに対して適切なメッセージソースを設定することでカスタマイズができるようになりました。

※とても適切な方法とは思えないので、他の方法をご存知でしたら是非教えてください。

## 参考：デフォルトログイン、ログアウトページ生成フィルタ
Spring Securityのデフォルトログインページ、ログアウトページの仕様が下記フィルタのソースコードから読み取れます。

- ログインページ：`DefaultLoginPageGeneratingFilter`
- ログアウトページ：`DefaultLogoutPageGeneratingFilter`

## 参考記事
[SpringSecurity：デフォルトのログイン画面をオリジナルログイン画面に変更する](https://arakan-pgm-ai.hatenablog.com/entry/2018/03/20/060000)
[ログインエラーのメッセージをカスタマイズする](http://fukuchiharuki.me/wiki/index.php?Spring%20Boot/%E3%83%AD%E3%82%B0%E3%82%A4%E3%83%B3%E3%82%A8%E3%83%A9%E3%83%BC%E3%81%AE%E3%83%A1%E3%83%83%E3%82%BB%E3%83%BC%E3%82%B8%E3%82%92%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%9E%E3%82%A4%E3%82%BA%E3%81%99%E3%82%8B)