⚠️

Cognito で 本登録後に即認証状態にする際の実装と考慮事項

に公開

イメージ(メールリンククリックして本登録するやつ)

  1. Cognito 設定とセキュリティ事項について
  2. FE: 情報入力、APIに投げる
  3. BE:SDK 経由で Cognito に仮登録
  4. Cognito から本登録用のcode付きメール配信
  5. ユーザ:メールにあるリンクをクリック
  6. FE:「リダイレクト中です」(本登録のためのAPIを叩く)
  7. BE:SDK 経由で Cognito に本登録
  8. BE: initiateAuth(トークン取得)
  9. BE:Set-Cookie に token を付与
  10. FE:以降、リクエスト毎に自動的にブラウザに保存される
    ※最小権限で行いたい(AdminXXは使わない)
    ※FE: Next.js, BE: Ruby on Rails

結果

cognito側の仕様理解が甘く、若干ハマった
正攻法では自動認証ができなかったが一応要件は満たせた

Cognitoに任せる部分

  1. トークン発行の制御(正しい認証フローを通過した場合のみ発行)
  2. セッション管理と有効期限の処理
  3. 認証チャレンジの一貫性確認(チャレンジメタデータの検証)

0. Cognito 設定とセキュリティ事項について

※通常のユーザpool作成やメール送信設定などは割愛

  1. メール内容のカスタマイズ(メールリンククリックでページ遷移+POSTさせるため)
  2. 空のカスタム認証の設定

AWS Cognito カスタム認証のセキュリティリスク対策状況

リスクパターン 危険性 実装での対応状況
SECRET_HASH を使わない クライアント偽装による不正アクセス ✅ CLIENT_SECRET による HMAC 検証(secret_hash)を実装済み
session を無視して respond_to_auth_challenge を呼ぶ セッション乗っ取りやリプレイ攻撃のリスク ✅ initiate_auth のレスポンスから得た session を正しく引き継いで使用(後続の実装で確認)
Lambda で常に成功を返す実装 チャレンジ検証がバイパスされるリスク ✅ Cognito の仕様要件(publicChallengeParameters等)を満たした構造で実装済み
本登録 を通さずにサインイン許可 メール未確認ユーザーでもログイン可能になるリスク ✅ 本登録 成功後にのみ 自動認証(後続の実装で確認) を実行する設計

重要なポイントは、Lambda の検証処理に到達する前の段階で、Cognito が「正規のクライアントからの一貫性のある認証フロー」であることを確認済みであるということ

参考資料
Custom authentication challenge Lambda triggers
InitiateAuth
RespondToAuthChallenge

0 - 1. メール内容のカスタマイズ

拡張機能よりメッセージングトリガーを選択しLambdaをあてがう

Lambdaはこんな感じ

  • CustomMessage_SignUpyというトリガーで入ってくるのでそちらを判定してメールを生成する
const userPoolId = process.env.COGNITO_USER_POOL_ID;
const redirectURI = process.env.SIGN_UP_REDIRECT_URL;

const getEmailMessage = (userName, confirmationCode, email) => (`
    リンクをクリックして本登録を完了させてください
    <a href=${redirectURI}?code=${confirmationCode}&userRef=${userName}>本登録する</a>
`);

export const handler = async (event, context) => {
    if(event.userPoolId === userPoolId && event.triggerSource === "CustomMessage_SignUp") {
        const email = event.request.userAttributes.email;
        event.response.emailSubject = "本登録を完了させてください";
        event.response.emailMessage = getEmailMessage(event.request.userAttributes.sub, event.request.codeParameter, email);
    }
    return event;
};

0 - 2. 空のカスタム認証の設定

拡張機能から3つのトリガーとLambdaを紐づける

  1. 認証チャレンジを定義
  2. 認証チャレンジを作成
  3. 認証チャレンジレスポンスを確認
    ※3つ全て紐付けると下図のようにDisableになる

0 - 2 - 1. 認証チャレンジ

export const handler = async (event) => {
    const session = event.request.session || [];
  
    // NOTE:
    // - session にはすべてのチャレンジタイプの履歴が入る
    // - 現在は CUSTOM_CHALLENGE のみを想定して判定している
    // - 将来的に SMS_MFA など他チャレンジを追加する場合は
    //   `challengeName` を確認して CUSTOM_CHALLENGE のみ判定対象にする必要あり
    if (session.length === 0) {
      // 初回チャレンジ(まだ履歴がない状態)
      event.response.issueTokens = false;
      event.response.failAuthentication = false;
      event.response.challengeName = "CUSTOM_CHALLENGE";
    } else if (session[session.length - 1].challengeResult === true) {
      // 最新のチャレンジが成功していればトークンを発行
      event.response.issueTokens = true;
      event.response.failAuthentication = false;
    } else {
      // チャレンジ失敗 → 再チャレンジ
      event.response.issueTokens = false;
      event.response.failAuthentication = false;
      event.response.challengeName = "CUSTOM_CHALLENGE";
    }
  
    return event;
  };
  

0 - 2 - 2. 認証チャレンジを作成

export const handler = async (event) => {
    // NOTE:
    // - 今回はチャレンジ(出題)をユーザーに提示せず、常に成功させる設計
    // - 仕様上 public/privateChallengeParameters に値が必要なため形式的に "dummy" を設定
  
    // (本来)クライアントに出す出題内容
    event.response.publicChallengeParameters = {
      // ex: question: "What is 2 + 3?"
      challenge: "dummy",
    };
    // (本来)クライアントから期待している値(VerifyAuthChallengeHandler 側で比較される)
    event.response.privateChallengeParameters = {
      // ex: 5
      challenge: "dummy",
    };
  
    // Cognito が保持するセッション情報に入れて、DefineAuthChallenge など後続のトリガーで参照できる追加メモ的な文字列
    // (本来)使い方例: CUSTOM_CHALLENGE が複数ある場合の判定用(例: step1: "captcha" → step2: "code")
    event.response.challengeMetadata = "dummy";
    return event;
  };

0 - 2 - 3. 認証チャレンジレスポンスを確認

export const handler = async (event) => {
    // NOTE:
    // - 今回はユーザーに実際のチャレンジを出題しないため、常に正解とみなす
    // - privateChallengeParameters の値と比較せず、形式的に answerCorrect を true にしてトークン発行を許可
    // - 本登録完了直後の自動ログインフローなど、セキュリティリスクがない場面で利用
    // - 実際にチャレンジ検証が必要な場合は以下のように実装する:
    //
    // const expectedAnswer = event.request.privateChallengeParameters.challenge;
    // const userAnswer = event.request.challengeAnswer;
    // event.response.answerCorrect = expectedAnswer === userAnswer;
    event.response.answerCorrect = true;
    return event;
  };

バックエンド (2, 6, 7, 8)

  1. BE:SDK 経由で Cognito に仮登録
  2. BE:SDK 経由で Cognito に本登録
  3. BE: initiateAuth(トークン取得)
  4. BE:Set-Cookie に token を付与

その前に

secret_hash 付与のためのメソッド

# SECRET_HASH生成メソッド
  def self.generate_secret_hash(username)
    message = username + CLIENT_ID
    digest = OpenSSL::Digest.new("sha256")
    hmac   = OpenSSL::HMAC.digest(digest, CLIENT_SECRET, message)
    Base64.strict_encode64(hmac)
  end

2. BE:SDK 経由で Cognito に仮登録

Cognito のメール設定がしてあれば自動でメールが送られる

def self.sign_up(email, name, password)
    secret_hash = generate_secret_hash(email)
    response = COGNITO_CLIENT.sign_up(
      client_id: CLIENT_ID,
      secret_hash: secret_hash,
      username: email,
      password: password,
      user_attributes: [
        { name: "email", value: email },
        { name: "name", value: name },
      ]
    )
    response
  rescue Aws::CognitoIdentityProvider::Errors::ServiceError => e
    Rails.logger.error("Cognito sign_up failed: #{e.message}")
    nil
  end

6. BE:SDK 経由で Cognito に本登録

def self.confirm_sign_up(user_name:, code:) # 本来user_name は keyValueストアなどからBE自身で取得すべき
    if user_name.blank? || code.blank?
      raise ConfirmSignUpError, I18n.t("admin_page.auth.sign_up_verify.errors.missing_params")
    end

    secret_hash = generate_secret_hash(user_name)
    # CONFIRMED 状態にする
    response = COGNITO_CLIENT.confirm_sign_up(
      client_id: CLIENT_ID,
      secret_hash: secret_hash,
      username: user_name,
      confirmation_code: code
    )

    # confirm_sign_upが成功するとsessionが返される
    # sessionが返されない場合は認証処理が正常に完了していない
    raise ConfirmSignUpError, I18n.t("admin_page.auth.sign_up_verify.errors.confirm_failed") unless response.session.present?

    # 成功したら認証状態にする
    auto_sign_in(user_name: user_name) # 後続で書く

  rescue Aws::CognitoIdentityProvider::Errors::ServiceError => e
    handle_confirm_sign_up_error(e)
  end

7. BE: initiateAuth(トークン取得)

  # 本登録後に認証状態にするための自動認証
  def self.auto_sign_in(user_name:)
    secret_hash = generate_secret_hash(user_name)

    auth_response = COGNITO_CLIENT.initiate_auth(
      client_id: CLIENT_ID,
      auth_flow: "CUSTOM_AUTH",
      auth_parameters: {
        "USERNAME" => user_name,
        "SECRET_HASH" => secret_hash,
      },
    )

    # CUSTOM_CHALLENGEは通常、SMS認証や秘密の質問など追加の認証要素を実装するために使用される
    # 本来はFEからANSWERをもらうべき
    # 今回はLambda側で常にtrueを返すように設定しているため、任意のANSWERをBEから直接送信
    auth_result = COGNITO_CLIENT.respond_to_auth_challenge(
      client_id: CLIENT_ID,
      challenge_name: "CUSTOM_CHALLENGE",
      session: auth_response.session,
      challenge_responses: {
        "USERNAME" => user_name,
        "ANSWER" => "dummy",
        "SECRET_HASH" => secret_hash,
      }
    )

    result = auth_result.authentication_result
    raise AutoSignInError, I18n.t("admin_page.auth.sign_up_verify.errors.auto_sign_in_failed") unless result&.id_token

    {
      success: true,
      tokens: {
        id_token: result.id_token,
        access_token: result.access_token,
        refresh_token: result.refresh_token,
        expires_in: result.expires_in,
      },
    }
  rescue Aws::CognitoIdentityProvider::Errors::ServiceError, AutoSignInError => e
    handle_auto_sign_in_error(e)
  end

cognito 関係ないので割愛

TODO

refresh tokenを使用したtokenの再発行mm

思ったこと

Clerkとかだと登録後に即時認証状態にすることは可能っぽいのでもう少し調べておけばよかた

Discussion