フォトシンス エンジニアブログ

株式会社Photosynth のテックブログです

Amazon Cognitoで独自認証フローを構築する方法

この記事は Akerun - Qiita Advent Calendar 2024 - Qiita の23日目の記事です。

こんにちは。@ps-shimizuです。バックエンドシステムの開発やプロジェクトマネージャーを担当しています。

先日業務でAmazon Cognitoのカスタム認証チャレンジに触れる機会があったのですが、カスタム認証チャレンジに関する情報が少なく開発に手こずることがありました。その経験を踏まえ、本記事ではカスタム認証チャレンジの実装手順から、実際にカスタム認証チャレンジでOTP(ワンタイムパスワード)を発行・検証する流れについてお話しします。

注意事項

カスタム認証チャレンジとは

カスタム認証チャレンジは、Amazon Cognito が提供する認証フローを拡張し、独自の認証ステップを組み込む仕組みです。以下のようなユースケースに適しています

  • SMSやメールを利用したOTP認証 (本記事ではこちらのユースケースを利用します)
  • セキュリティ質問を用いた認証
  • 外部サービスと連携した認証

カスタム認証を設定するには、以下の2つのコンポーネントを使用します。

  1. カスタムLambdaトリガー: Cognitoが特定のイベントで実行するLambda関数。
  2. カスタム認証フロー: Cognitoで設定する認証モード。

カスタム認証フロー

以下がカスタム認証チャレンジのフローです。 各Lambda関数の役割に関しては後述のLambda関数の作成項目で説明します。

カスタム認証フロー

※出典: カスタム認証チャレンジの Lambda トリガー

ユーザープールの作成手順

では早速 Amazon Cognitoのユーザープールの作成から進めていきます。

カスタム認証チャレンジを実装するためには、まずCognitoユーザープールを作成する必要があります。以下は基本的な設定手順です。

  1. ユーザープールの新規作成:

    • AWS Management Consoleにアクセスし、「Cognito」を選択します。
    • 「ユーザープール」から「ユーザープールを作成」をクリックします。
  2. サインインオプションの設定:

    • アプリケーションタイプは「従来のウェブアプリケーション」を選択します。
    • サインイン方法として、「メールアドレス」を選択します。
    • オプション項目については入力せず、アプリケーションを作成します。
  3. ユーザープールの保存:

    • 保存に成功すると以下の画面が表示されます。
  4. カスタム認証フローの有効化:

    • 続いて作成したユーザープールの「アプリケーションクライアント」の編集を行います。
    • 「認証フロー」セクションで「Lambda トリガー(ALLOW_CUSTOM_AUTH)」を有効に更新します。
      • 任意で「ユーザ名とパスワード (ALLOW_USER_PASSWORD_AUTH)」も有効にします。
  5. Lambdaトリガーの設定:

    • ユーザープール設定の「認証 > 拡張機能」を選択し、Lambdaトリガーの追加を行います。
    • Lambdaトリガーの「カスタム認証」を選択し、以下のLambda関数を割り当てます。
      • 認証チャレンジを定義: DefineAuthChallenge
      • 認証チャレンジを作成: CreateAuthChallenge
      • 認証チャレンジレスポンスを確認: VerifyAuthChallengeResponse
    • Lambda関数作成の流れは以下です。
      • 割り当てるLambda関数を作成していない場合は「Lambda関数の作成」からLambdaの作成を行います。
      • LambdaのランタイムはRuby3.3を選択してください。

この手順を完了すると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