🔓

マネージドUIを使わないでAmazon CognitoとSpring Securityでサインアップからログインまでを実装してみた

に公開

こんにちは!Septeni Japan株式会社の大志万と言います。

Amazon Cognitoはマネージド UI(旧 Hosted UI)といって、Cognito側があらかじめ用意しているログイン画面があります。
マネージドUIを使うことによって簡単に認証・認可処理を実装することが可能です。

しかし、以下のようなことを行いたい場合はマネージドUIではなく、自前でログイン画面を実装する必要があります。

  • 登録フローを自由にしたい
    • 例: サインアップの途中で、プロフィール入力画面を先に挟む。
  • 認証機能を「部品」として使いたい
    • 例: 「Googleログインボタン」だけをヘッダーに配置する。
  • エラー文言を分かりやすくしたい
    • 例: 「User does not exist.」を「登録されていません。」に変える。

Cognitoの記事の多くはマネージドUIが前提となっている記事が多かったので、今回はマネージドUIなしでCognitoを使ってサインアップから〜ログインまでをMFA認証(TOTPのみ)込みでの実装を行いその内容を備忘録としてこのブログに残しておこうと思います。

完成したアプリ

簡単ですが、フロントエンドも作成し、サインアップ〜ログインまでをできるようなアプリを作成しました。
イメージは下記のような感じです
flow

▼実装したコード
https://github.com/septeni/zenn_cognito

前提知識

  • Spring Bootの基本的な知識
  • JWTの基本的な概念
  • Java/Kotlinの基本的なプログラミング知識
    • 本ブログのサンプルコードはKotlinを用いて実装しています。

実装

Cognitoの設定

Cognitoの画面に遷移し、右上のユーザープールの作成をクリック
create-cognito

下記のように内容を設定して、ユーザーディレクトリを作成します

  • アプリケーションに任意の名前を設定(今回はzenn_cognito)
  • サインイン識別子のオプションではメールアドレスにチェック
  • マネージドログインは使わないため、リターンURIは不要

user-pool-settings

MFAの設定
サイドバーから>サインイン>他要素認証の編集をクリック
cognito-mfa-setting-1

MFAを必須にするにチェックをつけ、今回はTOTPのみなので「Authenticatorアプリケーション」にチェック
cognito-mfa-setting-2

以上でCognitoの設定は終わりです。

Sign Up

下記にサインアップの主な流れをシーケンス図で表しています。

以下バックエンドに絞ってコードの解説をしていきます。

SignUpUseCase

SecretHashUtilはユーザー名とクライアントIDをクライアントシークレットで署名したハッシュ値を計算するためのクラスです。

userRepositoryにUserが存在しなければ削除するという処理を組んでいます。これは「確認コード入力画面を誤って閉じてしまった際、もう一度ユーザー名、パスワードを入力してサインアップしようとするとUsernameExistsExceptionが発生してしまうため、確認コードが入力できず詰んでしまう」という状況を避けるためです。

class SignUpUseCase(
    private val cognitoConfig: CognitoConfig,
    private val cognitoIdentityProviderClient: CognitoIdentityProviderClient,
    private val secretHashUtil: SecretHashUtil,
    private val userRepository: UserRepository,
) {
    fun signUp(
        email: Email,
        name: UserName,
        password: String,
    ) {
        val user = userRepository.find(email)
        if(user != null) throw Exception("User already exists")

        try {
            cognitoSignUp(email, name, password)
        } catch (e: UsernameExistsException) {
            cognitoIdentityProviderClient.adminDeleteUser { it.userPoolId(cognitoConfig.userPoolId).username(email.value) }
            cognitoSignUp(email, name, password)
        }
    }

    private fun cognitoSignUp(
        email: Email,
        name: UserName,
        password: String,
    ) {
        val attributes =
            AttributeType
                .builder()
                .name("name")
                .value(name.value)
                .build()
        cognitoIdentityProviderClient.signUp {
            it.clientId(cognitoConfig.clientId)
            it.secretHash(secretHashUtil.calcSecretHash(email))
            it.userAttributes(attributes)
            it.username(email.value)
            it.password(password)
        }
    }
}

ConfirmSignUpUseCase

confirmSignUpをすることで、Cognito上でユーザーのステータスがCONFIRMED状態になります。ユーザーステータスはCONFIRMED・UNCONFIRMEDの他にも様々なステータスがあります。詳しくはこちらの公式ドキュメントをご覧ください。

ユーザーの確認が成功した後はデータをuserRepositoryに保存し、サインアップの処理は完了です。

@Service
class ConfirmSignUpUseCase(
    private val cognitoConfig: CognitoConfig,
    private val cognitoIdentityProviderClient: CognitoIdentityProviderClient,
    private val secretHashUtil: SecretHashUtil,
    private val userRepository: UserRepository,
) {

    fun confirmSignUp(
        email: Email,
        code: String,
    ) {
        cognitoIdentityProviderClient.confirmSignUp {
            it.clientId(cognitoConfig.clientId)
            it.secretHash(secretHashUtil.calcSecretHash(email))
            it.username(email.value)
            it.confirmationCode(code)
        }

        val cognitoUser = cognitoIdentityProviderClient.adminGetUser {
            it.userPoolId(cognitoConfig.userPoolId).username(email.value)
        }
        val name = cognitoUser.userAttributes().find { it.name() == "name" }?.value() ?: "unnamed"

        val user = User.create(email, UserName(name))
        userRepository.create(user)
    }
}

Sign In

下記にサインインの主な流れをシーケンス図で表しています。
サインインはサインアップよりも処理が複雑です。

以下バックエンドに絞ってコードの解説をしていきます。

SignInUseCase

Cognitoはサーバサイドから管理者として呼び出す前提のAdminAPIと、ユーザとしてサーバサイドでもクライアンサイドでも呼び出せるAPIとで分かれています。AdminAPIにはAPI名にAdminとついていることが多いです。

サインインの場合はまずInitiateAuthのAPIを呼び出す必要があります。
InitiateAuthではAdminAPIと非AdminAPIで使える認証フローが変わってきます。下記は主な認証フローの種類とAdmin/非Admin APIの認証可否をまとめた表になります[1]。(AWS Blackbeltの資料から抜粋)
auth_flow

今回は認証の手軽さとセキュリティの観点からAdmin APIを使ってADMIN_USER_PASSWORD_AUTHを使用することにします。

CognitoでMFA設定を必須にしている場合、AdminInitiateAuthのレスポンスで、チャレンジではまだMFAを設定していない場合はMFA_SETUPすでに設定している場合はSOFTWARE_TOKEN_MFAというチャレンジが返ってきます。

MFA_SETUPの場合はTOTP用のURLを発行して、フロントエンドに返します。

@Service
class SignInUseCase(
    private val cognitoConfig: CognitoConfig,
    private val cognitoIdentityProviderClient: CognitoIdentityProviderClient,
    private val secretHashUtil: SecretHashUtil,
) {
    fun signIn(
        email: Email,
        password: String,
    ): SignInDto {
        val secretHash = secretHashUtil.calcSecretHash(email)
        val authResult =
            cognitoIdentityProviderClient.adminInitiateAuth {
                it.clientId(cognitoConfig.clientId)
                it.userPoolId(cognitoConfig.userPoolId)
                it.authFlow(AuthFlowType.ADMIN_USER_PASSWORD_AUTH)
                it.authParameters(mapOf("USERNAME" to email.value, "PASSWORD" to password, "SECRET_HASH" to secretHash))
            }

        if (authResult.challengeName() == ChallengeNameType.MFA_SETUP) {
            val res = cognitoIdentityProviderClient.associateSoftwareToken { it.session(authResult.session()) }
            val qrCodeUri = generateQRCodeUri(res.secretCode(), email)
            return SignInDto(res.session(), authResult.challengeName(), qrCodeUri)
        }

        return SignInDto(authResult.session(), authResult.challengeName())
    }

    private fun generateQRCodeUri(
        secretCode: String,
        userEmail: Email,
    ): String {
        val accountName = URLEncoder.encode(userEmail.value, StandardCharsets.UTF_8)

        return "otpauth://totp/$accountName?secret=$secretCode&issuer=zenn-cognito"
    }
}

data class SignInDto(
    val session: String,
    val challenge: ChallengeNameType,
    val qrCodeUri: String? = null,
)

VerifyMfaUseCase

すでにユーザー側でMFAを設定している場合のMFAコードの認証用のUseCaseです。

AdminInitiateAuth後AdminRespondToAuthChallengeでコードを認証することで認証ができます。

認証に成功すると、CognitoはIDトークン・アクセストークン・リフレッシュトークンを返却してきます。

各トークンの違いを下記でまとめました。

項目 IDトークン アクセストークン リフレッシュトークン
主な用途 ユーザー識別・認証情報の提供 ユーザープールのユーザー属性の操作 新しいトークンセットの取得
含まれる情報 ユーザー属性(名前、メール、カスタム属性など) スコープ、グループ、クライアント ID 最小限の情報のみ
有効期限 デフォルト1時間(5分〜1日で設定可) デフォルト1時間(5分〜1日で設定可) デフォルト30日(1日〜10年で設定可)
形式 JWT(署名付き) JWT(署名付き) 不透明な文字列(JWT形式ではない)

詳しくは下記のブログに記載されています。
https://dev.classmethod.jp/articles/study-tokens-of-cognito-user-pools/

今回はCognitoで認証部分を行い、認可処理はアプリケーション側で実施するのでIDトークンを使うようにします。

@Service
class VerifyMfaUseCase(
    private val secretHashUtil: SecretHashUtil,
    private val cognitoConfig: CognitoConfig,
    private val cognitoIdentityProviderClient: CognitoIdentityProviderClient,
) {
    fun verify(
        session: String,
        email: Email,
        code: String,
    ): VerifyMfaDto {
        val secretHash = secretHashUtil.calcSecretHash(email)
        val challengeResponses =
            mapOf(
                "USERNAME" to email.value,
                "SOFTWARE_TOKEN_MFA_CODE" to code,
                "SECRET_HASH" to secretHash,
            )

        val response =
            cognitoIdentityProviderClient.adminRespondToAuthChallenge {
                it.clientId(cognitoConfig.clientId)
                it.challengeResponses(challengeResponses)
                it.session(session)
                it.challengeName(ChallengeNameType.SOFTWARE_TOKEN_MFA)
                it.userPoolId(cognitoConfig.userPoolId)
            }

        return VerifyMfaDto(
            token = response.authenticationResult().idToken(),
            refreshToken = response.authenticationResult().refreshToken(),
        )
    }
}

data class VerifyMfaDto(
    val token: String,
    val refreshToken: String,
)

SetupMfaUseCase

初回ログイン後にMFAを設定するUseCaseです。
AdminInitiateAuthから帰ってくるsessionとユーザーが入力したcodeを使い、AdminRespondToAuthChallengeを行うことでMFAを設定しています。

@Service
class SetupMfaUseCase(
    private val cognitoIdentityProviderClient: CognitoIdentityProviderClient,
    private val cognitoConfig: CognitoConfig,
    private val secretHashUtil: SecretHashUtil,
) {
    fun setup(
        session: String,
        code: String,
        email: Email,
    ): SetupMfaDto {
        val secretHash = secretHashUtil.calcSecretHash(email)
        val verifiedSession =
            cognitoIdentityProviderClient.verifySoftwareToken {
                it.session(session)
                it.userCode(code)
            }
        // 2. MFA_SETUPチャレンジに応答してアクセストークンを取得
        val challengeResponse =
            cognitoIdentityProviderClient.adminRespondToAuthChallenge {
                it.clientId(cognitoConfig.clientId)
                it.challengeName(ChallengeNameType.MFA_SETUP)
                it.session(verifiedSession.session())
                it.userPoolId(cognitoConfig.userPoolId)
                it.challengeResponses(mapOf("USERNAME" to email.value, "SECRET_HASH" to secretHash))
            }

        cognitoIdentityProviderClient.setUserMFAPreference { mfa ->
            mfa.accessToken(challengeResponse.authenticationResult().accessToken())
            mfa.softwareTokenMfaSettings {
                it.enabled(true)
                it.preferredMfa(true)
            }
        }

        return SetupMfaDto(
            token = challengeResponse.authenticationResult().idToken(),
            refreshToken = challengeResponse.authenticationResult().refreshToken(),
        )
    }
}

data class SetupMfaDto(
    val token: String,
    val refreshToken: String,
)

トークンの認証

トークンの認証の流れになります。

SecurityConfig

まず、application.ymlに以下の設定を追加します。

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: トークンキー署名URL

issuer-uriはCognitoの概要からトークン署名URLをコピーした値を記載します。
issuer_uri

次に、SecurityConfigクラスでSpring Securityの設定を行います。

@Configuration
@EnableWebSecurity
class SecurityConfig(
    private val customJwtAuthenticationConverter: CustomJwtAuthenticationConverter
) {
    @Bean
    fun securityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain {
        httpSecurity
            .cors { it.configurationSource(corsConfiguration()) }
            .csrf { it.disable() }
            .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
            .authorizeHttpRequests {
                it.requestMatchers("/auth/**").permitAll()
                it.anyRequest().authenticated()
            }.oauth2ResourceServer {
                it.jwt{ jwtConfigurer ->
                    jwtConfigurer.jwtAuthenticationConverter(customJwtAuthenticationConverter)
                }
            }
        return httpSecurity.build()
    }

    @Bean
    fun corsConfiguration(): UrlBasedCorsConfigurationSource {
        val source = UrlBasedCorsConfigurationSource()
        val config = CorsConfiguration()
        config.allowedOrigins = listOf("http://localhost:5173")
        config.allowedMethods = listOf("OPTIONS", "GET", "POST", "PUT", "DELETE")
        config.allowedHeaders = listOf("*")
        config.allowCredentials = true
        source.registerCorsConfiguration("/**", config)
        return source
    }
}

主な設定内容は以下の通りです

/auth/**パス(サインアップ・ログイン系)は認証不要でその他のリクエストはすべて認証が必要というように設定しています。

JWTの検証ですが、application.ymlでissuer-uriを登録し、Resource Serverとしての設定を行うことで、Spring Securityが自動的にCognitoのJWKSエンドポイントから公開鍵を取得し、受信したJWTトークンの署名を検証します。また、Custom Converter関数作成し、検証後はアクセスしてきたuser情報を簡単に受け取れるようにします。

CustomJwtAuthenticationConverter

CustomJwtAuthenticationConverterは、CognitoのJWTトークンをSpring Securityの認証オブジェクトに変換する役割を担います。

AbstractAuthenticationTokenを継承したクラスを返すことで、認証済み判定になります。
また、getPrincipleメソッドの戻り値を設定することで、下記のように@AuthenticationPrincipalアノテーションを使用してアクセスしてきたユーザーの情報を取得することができます。

@Component
class CustomJwtAuthenticationConverter(
    private val userRepository: UserRepository
) : Converter<Jwt, AbstractAuthenticationToken> {

    override fun convert(jwt: Jwt): AbstractAuthenticationToken {
        val email = jwt.getClaimAsString("email") // Cognitoのemail claim
        val user = userRepository.find(Email(email))
            ?: throw UsernameNotFoundException("User not found: $email")
        val authorities = emptyList<GrantedAuthority>()


        return CustomAuthenticationToken(jwt,user,authorities)
    }
}

class CustomAuthenticationToken(
    private val jwt: Jwt,
    private val user: User,
    authorities: List<GrantedAuthority>,
) : AbstractAuthenticationToken(authorities) {
    init { super.setAuthenticated(true) }
    override fun getCredentials() = jwt
    override fun getPrincipal() = user
}

UserController

@RestController
@RequestMapping("/users")
class UserController {
    @GetMapping("/me")
    fun getMe(
        @AuthenticationPrincipal user: User,
    ): ResponseEntity<String> {

        return ResponseEntity.ok(user.name.value)
    }
}

まとめ

今回、CognitoのマネージドUIを使わずに、サインアップからMFA認証付きログインまでを実装しました。

実装を通してCognitoの機能について知見を深められましたが、AdminAPIの使い分けやチャレンジの処理など、思ったよりも大変な部分が多かったです。今回はサインアップとサインインのみ実装しましたが、パスワードリセット機能やTOTP以外のMFA認証なども実装する場合は、さらに複雑になります。

特にこだわりがない場合は、マネージドUIを使った方がかなり楽に実装できると思いました。
とはいえ、カスタムUIで使いたいケースもなくはないと思うので、この記事が誰かの参考になれば幸いです。

最後までお読みいただき、ありがとうございました!
質問やフィードバックがありましたら、ぜひコメント欄で教えてください。

参考文献

脚注
  1. AWS BlackBeltの資料p32より抜粋 閲覧日: 2025年12月1日 ↩︎

Discussion