二段階目の認証

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

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())

“/login/step”へのアクセスは TwoStepVerificationManager によって管理されます。ここでは、ユーザーが二段階認証のコードを入力し、再度認証が行われます。

 

TwoStepVerificationManager

public class TwoStepVerificationManager implements AuthorizationManager {

	// ここではauthentication.get()がTwoFactorAuthentication型であること→一段階目の認証を突破していることのみcheckする。
	@Override
	public AuthorizationDecision check(Supplier authentication, RequestAuthorizationContext object) {
		return new AuthorizationDecision(authentication.get() instanceof TwoStepVerification);
	}
}

TwoStepVerificationManagerは、ユーザーが一段階目の認証を成功させ、二段階認証に進んでいることを確認するための認可ロジックを提供します。

Spring Securityの認可設定の一部として使用され、特定のエンドポイントやリソースに対するアクセスを制御します。

この設定により、”/login/step”へのアクセスは、認証情報がTwoStepVerification型である場合にのみ許可されます。

認証処理

SecurityLoginController

@PostMapping(path = "step")
public String executeTwoStepVerification(
        @Validated CognitoAuthenticationForm form,
        TwoStepVerification twoStepVerification,
        HttpServletRequest request,
        HttpServletResponse response,
        SecurityContextLogoutHandler logoutHandler,
        RedirectAttributes redirectAttributes) {

    Authentication authentication = twoStepVerification.getAuthentication();
    UserDetailsImpl accountUserDetails = (UserDetailsImpl) authentication.getPrincipal();

    // DBからセッション情報を取得する
    SystemAccount account = systemAccountRepository.findByLoginId(accountUserDetails.getUsername());

    // カスタム認証チャレンジに対する応答を行う
    RespondToAuthChallengeResult authChallengeResult;
    try {
        authChallengeResult = securityLoginService.respondToAuthCustomChallenge(
                account.getUserId(),
                form.getAuthCode(),
                account.getCognitoSession());
    } catch (NotAuthorizedException e) {
        // 二段階認証コードの有効期限が切れた場合、一度ログアウトしてログイン画面に返す。
        logoutHandler.logout(request, response, authentication);

        // (中略。エラーメッセージをログイン画面に設定する)
        return "redirect:/login";
    }

    // DBにセッション情報を保存
    securityLoginService.saveCognitoSession(account.getLoginId(), authChallengeResult.getSession());

    if (authChallengeResult.getAuthenticationResult() != null &&
            authChallengeResult.getAuthenticationResult().getAccessToken() != null) {
        // SecurityContextの内容を書き換えることで、isAuthenticated()をtrueにする。
        SecurityContext securityContext = SecurityContextHolder.getContext();
        securityContext.setAuthentication(authentication);
        SecurityContextHolder.setContext(securityContext);

        // ログイン成功時には、最終ログイン日時を更新する
        securityLoginService.updateSystemAccount(account.getLoginId());
        return "redirect:/portal";
    } else {
        redirectAttributes.addFlashAttribute("authError",
                messageSource.getMessage("E00012", null, LocaleContextHolder.getLocale()));
        return "redirect:/login/step";
    }
}

二段階認証プロセスは、まず認証情報の取得から始まります。二段階認証オブジェクトから認証情報を取得し、そこからユーザー詳細を抽出します。次に、抽出されたユーザー名を使用してデータベースからシステムアカウント情報を取得します。

その後、カスタム認証チャレンジの処理に移ります。ここでは、securityLoginService.respondToAuthCustomChallengeメソッドを呼び出し、ユーザーID、認証コード、Cognitoセッションを使用して認証チャレンジに応答します。

public RespondToAuthChallengeResult respondToAuthCustomChallenge(String userSub, String verificationCode, String session) {
    return cognitoService.respondToAuthCustomChallenge(new RespondCustomAuthDTO(userSub, verificationCode, session));
}

この過程で認証が失敗した場合(NotAuthorizedException)、ユーザーをログアウトさせてログイン画面にリダイレクトします。認証チャレンジが成功すると、新しいCognitoセッション情報をデータベースに保存します。

最後に、認証結果の処理を行います。認証が成功した場合(アクセストークンが存在する場合)、SecurityContextを更新して認証状態を反映させ、最終ログイン日時を更新した上でポータルページにリダイレクトします。

一方、認証が失敗した場合は、エラーメッセージをフラッシュ属性に追加し、ログインステップページにリダイレクトします。

実装を行う際に比較検討したポイント

本実装を採用するにあたり、方式を検討する中で比較検討したポイントを参考までに記します。方式検討される際の参考となれば幸いです。

認証方式

本実装ではメールによる二段階認証を採用していますが、より高いセキュリティを求める場合、二要素認証(2FA)の採用も考慮する必要があります。
例えば、Amazon Simple Notification Service (SNS)を用いたSMS認証や、Authenticatorアプリを使用したTOTP(Time-based One-Time Password)認証などが候補となると思います。

SMS認証やTOTPはより安全な認証手段でありますが、今回構築するシステムでは以下のような前提がありました。

  • 社内システムの機能であり、利用ユーザーは社内の管理者からメールで直接招待されたユーザーのみであること
    • 限られているユーザーの利用に限定されているため、一般的なWebシステムほどセキュリティを考慮する必要がない
  • システムを継続して利用していく中で、運用を含めたコストを抑えることが重要であること
    • SMS送信には費用がかかり、大量のユーザーに対して認証を行う場合コストが上昇する可能性がある
    • TOTPアプリの初期設定やトラブルシューティングに関して、サポートが必要となる可能性がある

これらの前提条件を踏まえ、本実装ではメールを用いた二段階認証を採用しました。

セッション情報の保存方式

本実装ではCognitoのセッション情報を都度データベースに保存する方針を採用していますが、Springのセキュリティコンテキスト上に保存する方法や、Redisのような分散セッションストアの利用も検討する必要があります。
この選択にあたっては、以下の前提条件を考慮しました。

  • 速度よりも可用性の重視であること
    • Springのセキュリティコンテキストは、アプリケーションのローカルメモリに情報を保持するため、サーバーがダウンするとセッション情報も失われる可能性がある。(クラスタリングやセッションのレプリケーションを行うことで可用性を高めることは可能だが、設定が複雑)
  • システムの複雑化を防ぐこと
    • 本システムは既にDBを用いて他ユーザー情報を格納していたため、分散セッションストアの導入は新たなインフラストラクチャの追加が必要となり、運用や開発の複雑性が増す

これらの前提条件を踏まえ、本実装ではCognitoのセッション情報を都度データベースに保存する方針を採用しました。

まとめ

本記事では、Spring SecurityとAmazon Cognito/AWS Lambdaを用いたカスタム二段階認証の実装について、その処理の流れや実装のポイントを解説しました。

本記事が、読者の皆様のシステム開発の一助となれば幸いです。

付録

以下に、カスタム認証を実現するためのLambda関数のPythonソースコードと、それに関連する処理内容について簡潔に説明します。
一般的なAmazon Cognitoを利用したカスタム認証においては、こちらに記載されたコードをそのまま実装に適用することが可能です。必要に応じて参考にしてください。

define_auth_challenge

def lambda_handler(event, context):
    session = event['request']['session']
    latest_session = session[-1] if session else None
    def set_response(issue_tokens, fail_authentication, challenge_name=None):
        event['response']['issueTokens'] = issue_tokens
        event['response']['failAuthentication'] = fail_authentication
        if challenge_name:
            event['response']['challengeName'] = challenge_name
    if latest_session:
        challenge_name = latest_session['challengeName']
        challenge_result = latest_session['challengeResult']

        if challenge_name == "SRP_A" and challenge_result:
            set_response(False, False, "PASSWORD_VERIFIER")
        elif challenge_name == "PASSWORD_VERIFIER" and challenge_result:
            set_response(False, False, "CUSTOM_CHALLENGE")
        elif challenge_name == "CUSTOM_CHALLENGE" and challenge_result:
            set_response(True, False)
        elif challenge_name == "CUSTOM_CHALLENGE" and not challenge_result:
            set_response(False, False, "CUSTOM_CHALLENGE")
        else:
            set_response(False, True)
    else:
        set_response(False, True)
    return event
  1. イベント情報の取得: Lambda関数に渡されたイベントから、セッション情報を取得します。セッション情報には、これまでのチャレンジ履歴が含まれています。
  2. 最新セッションの確認: 最新のセッションの情報に基づいて、どのチャレンジが実行されたか、その結果はどうだったかを確認します。
  3. チャレンジ結果に応じた処理を行います。
    • SRP_Aチャレンジ: 成功した場合、次のパスワード検証チャレンジに進みます。
    • パスワード検証チャレンジ: 成功した場合、カスタムチャレンジに進みます。
    • カスタムチャレンジ: 成功した場合、認証に成功し、トークンを発行します。失敗した場合、カスタムチャレンジを繰り返します。
    • その他: 不明なチャレンジまたは失敗した場合、認証に失敗します。
  4. レスポンスの設定: 認証結果や次のチャレンジに基づいて、イベントのレスポンスを設定します。

create_auth_challenge

import random
import string
import boto3
import os
def lambda_handler(event, context):
    if event['request']['challengeName'] == "CUSTOM_CHALLENGE":
        # セッションが存在し、CUSTOM_CHALLENGEの場合
        if event['request']['session'] and event['request']['session'][-1]['challengeName'] == "CUSTOM_CHALLENGE":
            verification_code = event['request']['session'][-1]['challengeMetadata']
        else:
            ses = boto3.client('ses')
            # ランダムな数字6桁を生成
            verification_code = ''.join(random.choice(string.digits) for _ in range(6))
            to_email_address = event['request']['userAttributes']['email']
            send_email(ses, to_email_address, verification_code)
        event['response']['privateChallengeParameters'] = {'answer': verification_code}
        event['response']['challengeMetadata'] = verification_code
    return event
def send_email(ses, email, code):
    params = {
        'Destination': {
            'ToAddresses': [email],
        },
        'Message': {
            'Body': {
                'Text': {
                    'Data': f'Your verification code is {code}.',
                },
            },
            'Subject': {
                'Data': 'Your verification code',
            },
        },
        'Source': os.getenv('FROM_MAIL_ADDRESS'),
    }
    ses.send_email(**params)
  1. イベントデータからチャレンジ名を取得し、カスタムチャレンジであるかを確認します。
  2. セッションが存在し、前回のチャレンジがカスタムチャレンジだった場合は、セッションから検証コードを取得します。そうでない場合は、新しい検証コードを生成し、SESの send_email メソッドを使ってメールを送信します。関数を使ってユーザーにメール送信します。
  3. 生成または取得した検証コードを、イベントのレスポンスに設定します。

auth_challenge_response

def lambda_handler(event, context):
    # ユーザーが入力した値(challengeAnswer)が正しいか
    if event['request']['privateChallengeParameters']['answer'] == event['request']['challengeAnswer']:
        event['response']['answerCorrect'] = True
    else:
        event['response']['answerCorrect'] = False
    return event
    1. システムが生成した正しい回答(event[‘request’][‘privateChallengeParameters’][‘answer’])とユーザーが入力した回答(event[‘request’][‘challengeAnswer’])を比較し、一致していれば answerCorrectTrueに、一致していなければFalseに設定します。
    2. 検証結果をイベントのレスポンスに設定します。