--- title: Spring Securityで2要素認証 (2FA) を行う方法 tags: ["Java", "Spring Boot", "Spring Security", "2FA", "MFA"] categories: ["Programming", "Java", "org", "springframework", "security", "web", "authentication"] date: 2023-09-08T08:01:09Z updated: 2023-09-08T08:03:26Z --- Spring Securityで2要素認証 (2FA) を行う方法をメモします。 今回の実装は2要素に限定しているので、ここでは多要素認証 (MFA)というより2FAと明示しておきます。 "Spring Security 2FA" でGoogle検索すると、次の2例が見つかります。 * https://www.baeldung.com/spring-security-two-factor-authentication-with-soft-token * https://www.javadevjournal.com/spring-security/two-factor-authentication-with-spring-security/ どちらも、TOTPを使った2FAを実装しています。しかし、ログインフォームの中で認証コードを入力する仕様になっています。 実装したいのは * ログインフォームではユーザー名とパスワードのみ入力 * ユーザー名とパスワードでログインが成功した後、2FAが有効になっていれば認証コード(TOTP)入力フォームを表示 というフローです。上記の実装方法ではこのフローを実現できません。 このフローを実装しているサンプルがSpring Securityチームがメンテナンスしている公式サンプルの中にあります。
https://github.com/spring-projects/spring-security-samples/blob/main/servlet/spring-boot/java/authentication/username-password/mfa
こちらのサンプルはMFAのサンプルになっています。 このサンプルを参考に、上記のフローを実装したサンプルが
https://github.com/making/demo-two-factor-authentication/tree/main
です。 ### サンプルアプリのウォークスルー まずはサンプルアプリをウォークスルーします。 http://localhost:8080/signup にアクセスし、アカウントを登録します。 image 2FAはデフォルトで無効になっています。 image ログアウトします。 image もう一度ログインします。 image 2FAが無効になっているので、ユーザー名とパスワードのみでログインが成功します。 image 2FAを有効化します。 image Google Authenticatorを使ってQRコードを読み込みます。 ![](https://github.com/making/blog.ik.am/assets/106908/a1a51abd-6b93-4210-a004-75a801d04040) コードを確認します。 ![](https://github.com/making/blog.ik.am/assets/106908/542691b9-f8d3-4a5a-bd0b-51a711f59fae) コードを入力して、verifyボタンを押します。 image 2FAが有効になりました。 image ログアウトします。 image もう一度ログインします。 image 今回は2FAが有効になっているので、コードの入力を求められます。 image Google Authenticatorでコードを確認します。 ![](https://github.com/making/blog.ik.am/assets/106908/3b1d7927-5c50-4732-a0dd-c4274934f4be) コードを入力して、verifyボタンを押します。 image ログインが成功しました。 image ### 実装の説明 `SecurityFilterChain`の定義は次のようになります。 ```java @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationSuccessHandler primarySuccessHandler) throws Exception { return http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/signup", "/error").permitAll() .requestMatchers("/challenge/totp").access(new TwoFactorAuthorizationManager()) .anyRequest().authenticated()) .formLogin(form -> form .successHandler(new TwoFactorAuthenticationSuccessHandler("/challenge/totp", primarySuccessHandler))) .securityContext(securityContext -> securityContext.requireExplicitSave(false)) .build(); } ``` ポイントは`formLogin`の`successHandler`に設定した`TwoFactorAuthenticationSuccessHandler`です。 このクラスが "ユーザー名とパスワードでログインが成功した後、2FAが有効になっていれば認証コード(TOTP)入力フォームを表示" を担います。 次のような実装になっています。 ```java public class TwoFactorAuthenticationSuccessHandler implements AuthenticationSuccessHandler { private final AuthenticationSuccessHandler primarySuccessHandler; private final AuthenticationSuccessHandler secondarySuccessHandler; public TwoFactorAuthenticationSuccessHandler(String secondAuthUrl, AuthenticationSuccessHandler primarySuccessHandler) { this.primarySuccessHandler = primarySuccessHandler; this.secondarySuccessHandler = new SimpleUrlAuthenticationSuccessHandler(secondAuthUrl); } @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { AccountUserDetails accountUserDetails = (AccountUserDetails) authentication.getPrincipal(); Account account = accountUserDetails.getAccount(); if (account.twoFactorEnabled()) { SecurityContextHolder.getContext().setAuthentication(new TwoFactorAuthentication(authentication)); this.secondarySuccessHandler.onAuthenticationSuccess(request, response, authentication); } else { this.primarySuccessHandler.onAuthenticationSuccess(request, response, authentication); } } } ``` `primarySuccessHandler`はデフォルトで使われる`AuthenticationSuccessHandler`と同じもの(`SavedRequestAwareAuthenticationSuccessHandler`)です。`secondarySuccessHandler`はログインが成功すると指定したURL(ここでは`/challenge/totp`)に遷移する`AuthenticationSuccessHandler`です ログインフォームからユーザ名とパスワードを入力して、認証が成功すると`TwoFactorAuthenticationSuccessHandler`の`onAuthenticationSuccess`メソッドが呼ばれます。 このメソッドを見ればわかるように、認証されたアカウントの2FAが無効であれば、`primarySuccessHandler`に処理が移譲されます。すなわち、これ以降は2FAを使わない場合と同じです。 2FAが有効であれば、`SecurityContext`に`TwoFactorAuthentication`を設定し、`secondarySuccessHandler`に処理が移譲されます。その結果、`/challenge/totp`にリダイレクトされます。 `TwoFactorAuthentication`は次のような実装になっています。 ```java public class TwoFactorAuthentication extends AbstractAuthenticationToken { private final Authentication primary; public TwoFactorAuthentication(Authentication primary) { super(List.of()); this.primary = primary; } // 省略 @Override public boolean isAuthenticated() { return false; } public Authentication getPrimary() { return this.primary; } } ``` 実際に認証処理を経て作成された`Authentication`オブジェクト(実装は`UsernamePasswordAuthenticationToken`)をラップしていますが、`isAuthenticated`が`false`を返します。つまり認証されていない状態にします。 2FAが有効の場合は、ユーザー名とパスワードによるログインが成功しても"authenticated"な状態にならないため、`anyRequest().authenticated()`に対して認可されません。 一方で、次の遷移先である`/challenge/totp`は認可される必要があるため、`.requestMatchers("/challenge/totp").access(new TwoFactorAuthorizationManager())`という設定をしています。 `TwoFactorAuthorizationManager`の実装は次のようになっています。 ```java public class TwoFactorAuthorizationManager implements AuthorizationManager { @Override public AuthorizationDecision check(Supplier authentication, RequestAuthorizationContext object) { return new AuthorizationDecision(authentication.get() instanceof TwoFactorAuthentication); } } ``` 対象の`Authentication`オブジェクトが`TwoFactorAuthentication`かどうかだけを見ています。したがって、`TwoFactorAuthenticationSuccessHandler`によって`/challenge/totp`に遷移した場合に認可されます。 `/challenge/totp`に対するのControllerは次のようになっています。 ```java @Controller public class TwoFactorAuthController { private final TwoFactorAuthenticationCodeVerifier codeVerifier; private final AuthenticationSuccessHandler successHandler; private final AuthenticationFailureHandler failureHandler; // 省略 @GetMapping(path = "/challenge/totp") public String requestTotp() { return "totp"; } @PostMapping(path = "/challenge/totp") public void processTotp(@RequestParam String code, TwoFactorAuthentication authentication, HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { Authentication primaryAuthentication = authentication.getPrimary(); AccountUserDetails accountUserDetails = (AccountUserDetails) primaryAuthentication.getPrincipal(); Account account = accountUserDetails.getAccount(); if (this.codeVerifier.verify(account, code)) { SecurityContextHolder.getContext().setAuthentication(primaryAuthentication); this.successHandler.onAuthenticationSuccess(request, response, primaryAuthentication); } else { this.failureHandler.onAuthenticationFailure(request, response, new BadCredentialsException("Invalid code")); } } } ``` `GET /challenge/totp`はコードを入力するフォームを表示するだけです。そのコードが送信される`POST /challenge/totp`に対しては、 `TwoFactorAuthenticationCodeVerifier`でTOTPコードの確認を行います。 コードがValidだったら`SecurityContext`に元の認証された`Authentication`を設定し、デフォルトの`AuthenticationSuccessHandler`で成功処理を行います。 コードがValidでなければ、デフォルトの`AuthenticationFailureHandler`でログイン失敗にします。 TOTPのSecret生成やverification、QRコード生成などは本記事では割愛します。Github上のソースコードを確認してください。 > [補足] Spring Security 6から、デフォルトで `SecurityContextHolder.getContext().setAuthentication(...)` だけではSession状態の保存が行われなくなり、Contextの保存を明示的に行う必要があります。
> デメリットはありますが、今回は明示的なContextの保存をしなくても良いように `securityContext.requireExplicitSave(false)` を設定しました。
> https://docs.spring.io/spring-security/reference/migration/servlet/session-management.html#_require_explicit_saving_of_securitycontextrepository