IK.AM

@making's tech note


Spring Securityで2要素認証 (2FA) を行う方法

🗃 {Programming/Java/org/springframework/security/web/authentication}
🏷 Java 🏷 Spring Boot 🏷 Spring Security 🏷 2FA 🏷 MFA 
🗓 Updated at 2023-09-08T08:03:26Z  🗓 Created at 2023-09-08T08:01:09Z   🌎 English Page

Spring Securityで2要素認証 (2FA) を行う方法をメモします。

今回の実装は2要素に限定しているので、ここでは多要素認証 (MFA)というより2FAと明示しておきます。

"Spring Security 2FA" でGoogle検索すると、次の2例が見つかります。

どちらも、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コードを読み込みます。

コードを確認します。

コードを入力して、verifyボタンを押します。

image

2FAが有効になりました。

image

ログアウトします。

image

もう一度ログインします。

image

今回は2FAが有効になっているので、コードの入力を求められます。

image

Google Authenticatorでコードを確認します。

コードを入力して、verifyボタンを押します。

image

ログインが成功しました。

image

実装の説明

SecurityFilterChainの定義は次のようになります。

@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();
}

ポイントはformLoginsuccessHandlerに設定したTwoFactorAuthenticationSuccessHandlerです。 このクラスが "ユーザー名とパスワードでログインが成功した後、2FAが有効になっていれば認証コード(TOTP)入力フォームを表示" を担います。

次のような実装になっています。

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です

ログインフォームからユーザ名とパスワードを入力して、認証が成功するとTwoFactorAuthenticationSuccessHandleronAuthenticationSuccessメソッドが呼ばれます。 このメソッドを見ればわかるように、認証されたアカウントの2FAが無効であれば、primarySuccessHandlerに処理が移譲されます。すなわち、これ以降は2FAを使わない場合と同じです。 2FAが有効であれば、SecurityContextTwoFactorAuthenticationを設定し、secondarySuccessHandlerに処理が移譲されます。その結果、/challenge/totpにリダイレクトされます。

TwoFactorAuthenticationは次のような実装になっています。

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)をラップしていますが、isAuthenticatedfalseを返します。つまり認証されていない状態にします。 2FAが有効の場合は、ユーザー名とパスワードによるログインが成功しても"authenticated"な状態にならないため、anyRequest().authenticated()に対して認可されません。 一方で、次の遷移先である/challenge/totpは認可される必要があるため、.requestMatchers("/challenge/totp").access(new TwoFactorAuthorizationManager())という設定をしています。

TwoFactorAuthorizationManagerの実装は次のようになっています。

public class TwoFactorAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {

    @Override
    public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
        return new AuthorizationDecision(authentication.get() instanceof TwoFactorAuthentication);
    }

}

対象のAuthenticationオブジェクトがTwoFactorAuthenticationかどうかだけを見ています。したがって、TwoFactorAuthenticationSuccessHandlerによって/challenge/totpに遷移した場合に認可されます。

/challenge/totpに対するのControllerは次のようになっています。

@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


✒️️ Edit  ⏰ History  🗑 Delete