アプリケーションの処理の流れ

ユーザーが二段階認証を行い、ホーム画面に遷移するまでの詳細な処理フローをコード例と共に説明します。認証リクエストの振り分けや認証処理、セキュリティコンテキストの更新についても解説します。

なお説明のためコードは実際の実装内容から抜粋して掲載します。

一段階目の認証

認証リクエストの振り分け

WebSecurityConfig

public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        // 認可の設定
        .authorizeHttpRequests(c -> c
            // ログイン画面は誰でも見られる
            .requestMatchers("/login").permitAll()
            // 二段階認証画面はログイン済のユーザーのみ見られる
            .requestMatchers("/login/step").access(new TwoStepVerificationManager())
            // エラー画面は誰でも見られる
            .requestMatchers("/error").permitAll()
            // CSSとJSは誰でも参照できる
            .requestMatchers("/css/**", "/js/**").permitAll()
            // 上記以外の画面は二段階認証が必要
            .anyRequest().authenticated()
        )
        // ログインの設定
        .formLogin(c -> c
            // ログイン認証を行うパスを設定する
            .loginPage("/login")
            // ユーザー名のパラメーター名を設定する
            .usernameParameter("loginId")
            // パスワードのパラメーター名を設定する
            .passwordParameter("userPassword")
            // パスワード認証後に2段階認証の画面を表示するように設定
            .successHandler(new TwoStepVerificationSuccessHandler("/login/step"))
        )
        .authenticationManager(cognitoAuthenticationManager())

ユーザーがフォームに「ログインID」と「パスワード」を入力し、送信ボタンを押すと、フォームのデータはPOSTリクエストとして送信されます。

このPOSTリクエストは.formLogin(c -> c.loginPage(“/login”))で設定されたパスに送信されます。Spring Securityは、送信されたログインIDとパスワードをusernameParameter(“loginId”)およびpasswordParameter(“userPassword”)というパラメーター名で受け取ります。

これらの情報を使って、AuthenticationManagerが認証を行います。今回はcognitoAuthenticationManager()メソッドを通じてカスタムのCognitoAuthenticationProviderを使用します。

    @Bean
    public ProviderManager cognitoAuthenticationManager() {
        ProviderManager providerManager = new ProviderManager(provider);
        providerManager.setAuthenticationEventPublisher(eventPublisher);
        return providerManager;
    }

認証処理

CognitoAuthenticationProvider

public class CognitoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

    // (中略)

    @Override
    protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        // パスワード認証を取得
        String password = (String) authentication.getCredentials();

        // (中略。ここでログインID/パスワードのバリデーションチェックを行っている) 

        try {
            RespondToAuthChallengeResult respondToAuthChallengeResult = cognitoService.userPasswordAuth(
                new UserPasswordAuthDTO(username, password, userPasswordAuthClientId)
            );

            // cognitoセッション情報をDBに保存
            securityLoginService.saveCognitoSession(username, respondToAuthChallengeResult.getSession());
        } catch (NotAuthorizedException e) {
            throw new UsernameNotFoundException(username, e);
        }

        return securityLoginService.loadUserByUsername(username);
    }
}

ここでは、ユーザー名とパスワードのバリデーションを行い、Cognitoを使用して認証を行い、認証が成功した場合にユーザー情報を取得して返します。

Cognitoに送る前にバリデーションチェックを行い、条件に合致しない場合はBadCredentialsExceptionとしてエラーを返却するようにしています。

cognitoService.userPasswordAuthメソッドを呼び出して、ユーザー名とパスワードを使ったCognito認証を行います。

認証が成功すると、Cognitoセッション情報をsecurityLoginService.saveCognitoSessionメソッドを通じて保存します。

@Transactional public void saveCognitoSession(String loginId, String cognitoSession) { 
    // DBにセッション情報を保存 
    SystemAccount systemAccount = systemAccountRepository.findByLoginId(loginId); 
    systemAccount.setCognitoSession(cognitoSession); 
    systemAccountRepository.save(systemAccount); 
}

認証が失敗した場合、NotAuthorizedExceptionが投げられ、UsernameNotFoundExceptionとして再スローされます。

Spring Securityの認証機構にユーザー情報を渡す

SecurityLoginService

@Service
@Transactional
public class SecurityLoginService implements UserDetailsService {
    
    // (中略)

    /**
     * ログインIDをもとにユーザー情報を取得して返す。
     */    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {
        if (StringUtils.isEmpty(loginId)) {
            throw new UsernameNotFoundException(loginId);
        }

        SystemAccount systemAccount = systemAccountRepository.findByLoginId(loginId);
        if (systemAccount == null) {
            throw new UsernameNotFoundException(loginId);
        }
        return new UserDetailsImpl(systemAccount);
    }

securityLoginService.loadUserByUsernameメソッドを呼び出して、ユーザー情報を取得し、UserDetailsオブジェクトとして返します。

ユーザー名に基づいてデータベースからユーザー情報を取得し、それをSpring Securityの認証機構に渡します。

ユーザー名(ログインID)が空でないかチェックします。空であれば、例外を投げます。

データベースから該当するユーザーを検索します。ユーザーが見つからない場合も例外を投げます。

見つかったユーザー情報を使って、認証に必要な詳細情報を含むUserDetailsImplオブジェクトを生成し、返します。

二段階認証画面へ遷移

WebSecurityConfig(再掲)

.formLogin(c -> c
    // ログイン認証を行うパスを設定する
    .loginPage("/login")
    // ユーザー名のパラメーター名を設定する
    .usernameParameter("loginId")
    // パスワードのパラメーター名を設定する
    .passwordParameter("userPassword")
    // パスワード認証後に2段階認証の画面を表示するように設定
    .successHandler(new TwoStepVerificationSuccessHandler("/login/step"))
)

認証が成功すると、TwoStepVerificationSuccessHandlerが呼び出され、ユーザーは”/login/step”へリダイレクトされます。

 

TwoStepVerificationSuccessHandler

public class TwoStepVerificationSuccessHandler implements AuthenticationSuccessHandler {
	private final AuthenticationSuccessHandler authenticationSuccessHandler;

	public TwoStepVerificationSuccessHandler(String authUrl) {
		this.authenticationSuccessHandler = new SimpleUrlAuthenticationSuccessHandler(authUrl);
	}

	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws IOException, ServletException {

		// authenticationを直接入れずにTwoFactorAuthentication(authentication)を入れる→isAuthenticatedがfalseになる
		SecurityContextHolder.getContext().setAuthentication(new TwoStepVerification(authentication));
		this.authenticationSuccessHandler.onAuthenticationSuccess(request, response, authentication);
	}
}

このクラスは、Spring Securityを使った認証処理の一部であり、認証が成功した後に二段階認証を要求するためのカスタムハンドラーです。具体的には、認証成功後に通常のauthenticationオブジェクトではなく、二段階認証用のTwoStepVerificationオブジェクトをセキュリティコンテキストに設定し、その後に指定されたURLにリダイレクトする処理を行います。

onAuthenticationSuccessメソッドは、認証が成功したときに呼び出されるメソッドです。

このメソッドでは、まずSecurityContextHolderのコンテキストに新しいTwoStepVerificationオブジェクトを設定します。このTwoStepVerificationオブジェクトには元のauthenticationオブジェクトがラップされており、isAuthenticatedfalseになるように設定されていることがコメントから読み取れます。

その後、authenticationSuccessHandleronAuthenticationSuccessメソッドを呼び出し、リダイレクトの処理を行います。

 

TwoStepVerification

public class TwoStepVerification extends AbstractAuthenticationToken {

	private final Authentication authentication;

	public TwoStepVerification(Authentication authentication) {
		super(List.of());
		this.authentication = authentication;
	}

	public Object getPrincipal() {
		return this.authentication.getPrincipal();
	}

	@Override
	public Object getCredentials() {
		return this.authentication.getCredentials();
	}

	@Override
	public void eraseCredentials() {
		if (this.authentication instanceof CredentialsContainer) {
			((CredentialsContainer) this.authentication).eraseCredentials();
		}
	}

	// 必ずisAuthenticatedをfalseにすることで、二段階認証を行っていないユーザーを弾く。
	@Override
	public boolean isAuthenticated() {
		return false;
	}

このTwoStepVerificationクラスは、Spring Securityの認証フローにおいて二段階認証を実現するためのカスタム認証トークンです。元の認証情報をラップし、isAuthenticatedメソッドを常にfalseにすることで、二段階認証がまだ完了していないことを示します。

これにより、二段階認証が完了するまでは、ユーザーが完全に認証されたと見なされないようにします。