この記事は Akerun - Qiita Advent Calendar 2024 - Qiita の23日目の記事です。
こんにちは。@ps-shimizuです。バックエンドシステムの開発やプロジェクトマネージャーを担当しています。
先日業務でAmazon Cognitoのカスタム認証チャレンジに触れる機会があったのですが、カスタム認証チャレンジに関する情報が少なく開発に手こずることがありました。その経験を踏まえ、本記事ではカスタム認証チャレンジの実装手順から、実際にカスタム認証チャレンジでOTP(ワンタイムパスワード)を発行・検証する流れについてお話しします。
注意事項
- コード例は全てRubyで記載しています。
- AWSの画面は2024/12時点のものです。
- 2024/09に発表された「多要素認証 (MFA) オプションとしてメールの提供を開始」に関しては今回は取り上げておりません。
カスタム認証チャレンジとは
カスタム認証チャレンジは、Amazon Cognito が提供する認証フローを拡張し、独自の認証ステップを組み込む仕組みです。以下のようなユースケースに適しています
- SMSやメールを利用したOTP認証 (本記事ではこちらのユースケースを利用します)
- セキュリティ質問を用いた認証
- 外部サービスと連携した認証
カスタム認証を設定するには、以下の2つのコンポーネントを使用します。
- カスタムLambdaトリガー: Cognitoが特定のイベントで実行するLambda関数。
- カスタム認証フロー: Cognitoで設定する認証モード。
カスタム認証フロー
以下がカスタム認証チャレンジのフローです。 各Lambda関数の役割に関しては後述のLambda関数の作成項目で説明します。
ユーザープールの作成手順
では早速 Amazon Cognitoのユーザープールの作成から進めていきます。
カスタム認証チャレンジを実装するためには、まずCognitoユーザープールを作成する必要があります。以下は基本的な設定手順です。
ユーザープールの新規作成:
- AWS Management Consoleにアクセスし、「Cognito」を選択します。
- 「ユーザープール」から「ユーザープールを作成」をクリックします。
サインインオプションの設定:
- アプリケーションタイプは「従来のウェブアプリケーション」を選択します。
- サインイン方法として、「メールアドレス」を選択します。
- オプション項目については入力せず、アプリケーションを作成します。
ユーザープールの保存:
- 保存に成功すると以下の画面が表示されます。
カスタム認証フローの有効化:
- 続いて作成したユーザープールの「アプリケーションクライアント」の編集を行います。
- 「認証フロー」セクションで「Lambda トリガー(ALLOW_CUSTOM_AUTH)」を有効に更新します。
- 任意で「ユーザ名とパスワード (ALLOW_USER_PASSWORD_AUTH)」も有効にします。
Lambdaトリガーの設定:
- ユーザープール設定の「認証 > 拡張機能」を選択し、Lambdaトリガーの追加を行います。
- Lambdaトリガーの「カスタム認証」を選択し、以下のLambda関数を割り当てます。
- 認証チャレンジを定義:
DefineAuthChallenge
- 認証チャレンジを作成:
CreateAuthChallenge
- 認証チャレンジレスポンスを確認:
VerifyAuthChallengeResponse
- 認証チャレンジを定義:
- Lambda関数作成の流れは以下です。
- 割り当てるLambda関数を作成していない場合は「Lambda関数の作成」からLambdaの作成を行います。
- LambdaのランタイムはRuby3.3を選択してください。
- 割り当てるLambda関数を作成していない場合は「Lambda関数の作成」からLambdaの作成を行います。
この手順を完了するとLambdaトリガーが添付画像のようになっていればLambdaの紐付けは完了です。
実装ステップ
1. Lambda関数の作成
カスタム認証に利用するLambda関数を作成します。以下に、各トリガーの役割とコード例を示します。
DefineAuthChallenge
認証に成功した際、CognitoはこのLambdaトリガーを呼び出してカスタム認証フローを開始します。
require 'json' class DefineAuthChallenge def self.handler(event:, context:) new.handler(event: event, context: context) end def handler(event:, context:) if event['request']['session'].nil? || event['request']['session'].empty? event['response']['challengeName'] = 'CUSTOM_CHALLENGE' else if event['request']['session'].last['challengeResult'] event['response']['issueTokens'] = true event['response']['failAuthentication'] = false else event['response']['issueTokens'] = false event['response']['failAuthentication'] = true end end event end end def lambda_handler(event:, context:) DefineAuthChallenge.handler(event: event, context: context) end
事前に作成したLambdaにはまだ何も実装されていないので、 上記コードをLambdaのコードソースエリアに貼り付け「Deploy」ボタンを押下します。
CreateAuthChallenge
チャレンジを生成する関数です。こちらの関数でOTPを発行し、ユーザへOTPを記載したメール送信を行います。
require 'json' require 'securerandom' require 'aws-sdk-ses' class CreateAuthChallenge def self.handler(event:, context:) new.handler(event: event, context: context) end def handler(event:, context:) if event['request']['challengeName'] == 'CUSTOM_CHALLENGE' otp = SecureRandom.random_number(1000000).to_s.rjust(6, '0') # Amazon SESを利用してOTPをメール送信。 send_otp_via_ses(event['request']['userAttributes']['email'], otp) event['response']['publicChallengeParameters'] = { 'email' => event['request']['userAttributes']['email'] } event['response']['privateChallengeParameters'] = { 'otp' => otp } event['response']['challengeMetadata'] = 'CUSTOM_CHALLENGE' end event end private def send_otp_via_ses(email, otp) ses = Aws::SES::Client.new(region: 'ap-northeast-1') ses.send_email( destination: { to_addresses: [email] }, message: { body: { text: { charset: 'UTF-8', data: "Your OTP is #{otp}" } }, subject: { charset: 'UTF-8', data: 'Your Authentication Code' } }, source: 'noreply@example.com' ) end end def lambda_handler(event:, context:) CreateAuthChallenge.handler(event: event, context: context) end
同じ要領で上記ソースコードをLambdaのコードソースエリアに貼り付け「Deploy」ボタンを押下します。
VerifyAuthChallengeResponse
クライアントから受け取った回答と、CreateAuthChallenge
で発行したOTPが一致しているか検証する関数です。
require 'json' class VerifyAuthChallengeResponse def self.handler(event:, context:) new.handler(event: event, context: context) end def handler(event:, context:) expected_otp = event['request']['privateChallengeParameters']['otp'] user_provided_otp = event['request']['challengeAnswer'] event['response']['answerCorrect'] = (user_provided_otp == expected_otp) event end end def lambda_handler(event:, context:) VerifyAuthChallengeResponse.handler(event: event, context: context) end
こちらも同じ要領でLambdaのコードソースエリアに貼り付け「Deploy」ボタンを押下します。
2. バックエンドでの連携
RubyでCognitoを利用した認証フローを実装します。以下 app.rb
(CognitoClient)のコード全文と.env
に設定する環境変数です。
# ファイル名: app.rb require 'aws-sdk-cognitoidentityprovider' class CognitoClient def initialize @client = Aws::CognitoIdentityProvider::Client.new( region: ENV['AWS_REGION'] ) @user_pool_id = ENV['AWS_COGNITO_USER_POOL_ID'] @client_id = ENV['AWS_COGNITO_APPLICATION_CLIENT_ID'] end # カスタム認証を使用しないパスワードサインイン def sign_in(username, password) resp = @client.initiate_auth( client_id: @client_id, auth_flow: 'USER_PASSWORD_AUTH', auth_parameters: { 'USERNAME' => username, 'PASSWORD' => password, 'SECRET_HASH' => calculate_secret_hash(username) } ) puts "Authentication successful: #{resp}" resp rescue Aws::CognitoIdentityProvider::Errors::ServiceError => e puts "Error signing in: #{e.message}" end # カスタム認証チャレンジを利用した認証開始 def custom_auth(username, password) resp = @client.initiate_auth( client_id: @client_id, auth_flow: 'CUSTOM_AUTH', auth_parameters: { "USERNAME" => username, "PASSWORD" => password, 'SECRET_HASH' => calculate_secret_hash(username) } ) puts "Custom Authentication successful: #{resp}" resp rescue Aws::CognitoIdentityProvider::Errors::ServiceError => e puts "Error signing in: #{e.message}" end # カスタム認証チャレンジ検証 def custom_challenge(username, session, code) resp = @client.respond_to_auth_challenge( client_id: @client_id, challenge_name: 'CUSTOM_CHALLENGE', session: session, # custom_authで取得したresp.sessionを設定する challenge_responses: { "USERNAME" => username, "ANSWER" => code, 'SECRET_HASH' => calculate_secret_hash(username) } ) puts "Custom Challenge successful: #{resp}" resp rescue Aws::CognitoIdentityProvider::Errors::ServiceError => e puts "Error signing in: #{e.message}" end private def calculate_secret_hash(username) data = "#{username}#{@client_id}" digest = OpenSSL::Digest.new('SHA256') hmac = OpenSSL::HMAC.digest(digest, ENV.fetch('AWS_COGNITO_APPLICATION_CLIENT_SECRET'), data) Base64.encode64(hmac).strip end end
.env
の設定です。
AWS_REGION=ap-northeast-1 AWS_PROFILE='your-aws-profile' AWS_SDK_LOAD_CONFIG=true AWS_COGNITO_APPLICATION_CLIENT_ID='your-cognito-application-client-id' AWS_COGNITO_APPLICATION_CLIENT_SECRET='your-cognito-application-client-secret' AWS_COGNITO_USER_POOL_ID='your-cognito-user-pool-id'
カスタム認証を実践
1. カスタム認証 (CUSTOM_AUTH)
app.rb
, .env
の準備が整ったらようやくカスタム認証チャレンジの開始です。
ここではカスタム認証を実行しメールアドレス・パスワードのペアの検証と、OTP発行処理を行います。カスタム認証フロー図でいうところの赤枠部分が処理内容です。
require_relative 'app' client = CognitoClient.new custom_auth_response = client.custom_auth( 'your-user-name', 'your-password' ) # challenge_parametersの値を取得できていれば認証フロー開始処理に成功している。 puts custom_auth_response.challenge_parameters
カスタム認証が成功していれば、以下のようなメールを受信しています。
2. チャレンジ応答の送信 (CUSTOM_CHALLENGE)
カスタム認証が成功した際に送信されたメールに記載されている、OTPを利用してチャレンジ応答を行います。カスタム認証フロー図でいうところの赤枠部分が処理内容です。
custom_challenge_response = client.custom_challenge( 'your-user-name', custom_auth_response.session, 'your-otp' ) # access_tokenの値を取得できていればカスタム認証チャレンジの認証成功。 puts custom_challenge_response.authentication_result.access_token
チャレンジの検証に成功していれば、response.authentication_result に各種トークンが格納されています。
これでカスタム認証は完了です。
まとめ
Amazon Cognito のユーザープール作成からカスタム認証チャレンジを利用して独自の認証フローを実践する方法について紹介しました。
今回はシンプルにOTP作成からOTPの検証処理までの構築としましたが、OTPに有効期限を設定することや一度認証に利用したOTPを無効化するなどの柔軟な対応なども可能です。 要件にマッチする機能がなければカスタム認証チャレンジの採用も視野に入るかと思います。
また、私がカスタム認証チャレンジに触れていた際にはリリースされていませんでしたが、Amazon Cognitoの多要素認証にメールが追加されましたので前提条件が揃っていればそちらの利用も視野に入るかと思います。
参考情報
株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 photosynth.co.jp Akerunにご興味のある方はこちらから akerun.com