見出し画像

Cognitoのカスタム認証チャレンジでMFAを実現する

はじめまして!SHIFT DAAE 開発グループ所属の岸田です。

Cognitoのカスタム認証チャレンジを利用して、メールによる MFA(多要素認証)を実現したので紹介します。

本記事内での実装例は JavaScript (一部 TypeScript) で記載しています。


構成

以下のような構成で、メールによるMFAを実現します。

フロントエンドでユーザーの入力を受け付けて、バックエンドがCognitoと通信します。通常のパスワード認証の後に、カスタム認証チャレンジを行うことで、MFAを実現します。

カスタム認証チャレンジについて

Cognitoでは3つのLambdaトリガーを設定することで、独自の認証フローを構築できます。それぞれのLambdaトリガーの名前と役割は以下の通りです。

  • 認証チャレンジの定義(Define auth challenge)

    • Cognitoは、このトリガーを呼び出してカスタム認証フローを開始します。カスタム認証フローの終了判定も行います。

  • 認証チャレンジの作成(Create auth challenge)

    • Cognitoは、このトリガーを"認証チャレンジの定義"の後に呼び出して、カスタム認証チャレンジを作成します。

  • 認証チャレンジレスポンスの検証(Verify auth challenge response)

    • Cognitoは、このトリガーを呼び出して、カスタムチャレンジに対するエンドユーザーからのレスポンスが有効であるかどうかを検証します。

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

アプリケーションからは、InitiateAuth と respondToAuthChallenge を呼ぶことで、Cognito が内部でLambdaトリガーを適宜実行してくれます。

認証チャレンジの定義(Define auth challenge)

では早速、認証チャレンジの定義から実装していきましょう。

実装例を以下に示します。 この例では、1つのチャレンジを定義し、そのチャレンジが正常に完了した場合にトークンを発行します。

exports.defineAuthChallenge = async (event, context, callback) => {
  if (
    event.request.session &&
    event.request.session.length === 1 &&
    event.request.session[0].challengeName === "CUSTOM_CHALLENGE"
  ) {
    if (event.request.session[0].challengeResult === true) {
      // 成功
      event.response.issueTokens = true;
      event.response.failAuthentication = false;
    } else {
      // 失敗
      event.response.issueTokens = false;
      event.response.failAuthentication = true;
    }
  } else {
    // custom auth の開始
    event.response.issueTokens = false;
    event.response.failAuthentication = false;
    event.response.challengeName = "CUSTOM_CHALLENGE";
  }

  callback(null, event);
};

リクエストパラメータ

session

現在の認証プロセスでユーザーに提示されたすべてのチャレンジが含まれる配列です。

  • challengeName

    1. チャレンジの名前です。以下のいずれかですが、今回は CUSTOM_CHALLENGE であるかを確認します。(CUSTOM_CHALLENGE、SRP_A、PASSWORD_VERIFIER、SMS_MFA, DEVICE_SRP_AUTH、DEVICE_PASSWORD_VERIFIER、または ADMIN_NO_SRP_AUTH )

  • challengeResut

    1. ユーザーが正常にチャレンジを完了した場合はtrueに、それ以外の場合はfalseに設定されます。

レスポンスパラメータ

challengeName

チャレンジの名前です。以下のいずれかですが、今回は CUSTOM_CHALLENGE を指定します。(CUSTOM_CHALLENGE、SRP_A、PASSWORD_VERIFIER、SMS_MFA, DEVICE_SRP_AUTH、DEVICE_PASSWORD_VERIFIER、または ADMIN_NO_SRP_AUTH )

issueTokens

ユーザーが認証チャレンジを十分に完了したと判断した場合、true に設定します。ユーザーがチャレンジを十分に満たしていない場合は、false に設定します。

failAuthentication

現在の認証プロセスを終了する場合は、true に設定します。現在の認証プロセスを続行するには、false に設定します。

認証チャレンジの作成(Create auth challenge)

次に、認証チャレンジの作成です。

実装例を以下に示します。 この例では、ワンタイムコードを作成し、そのコードをSESでメール送信しています。

※ ses:SendEmilを許可するIAMポリシーをLambdaの実行IAMロールに付与する必要があります。

const crypto = require("crypto");
const aws = require("aws-sdk");
exports.createAuthChallenge = async (event, context, callback) => {
  if (event.request.challengeName === "CUSTOM_CHALLENGE") {
    const onetimeCode = crypto.randomBytes(3).toString("hex");
    const domain = process.env.DOMAIN;
    const mailRequest = {
      Source: `noreply@${domain}`,
      Destination: {
        ToAddresses: [event.request.userAttributes["email"]],
      },
      Message: {
        Subject: {
          Data: "ワンタイムコードのお知らせ",
        },
        Body: {
          Text: {
            Data: `
--------  ワンタイムコードはこちら  --------
◆ワンタイムコード: 
${onetimeCode}
-------------------------------------------------
ワンタイムコードは3分間有効です。

`,
          },
        },
      },
    };

    const ses = new aws.SES();
    await ses.sendEmail(mailRequest).promise();

    const expires = new Date();
    expires.setMinutes(expires.getMinutes() + 3);
    event.response.privateChallengeParameters = {
      onetimeCode: onetimeCode,
      expires: expires,
    };
  }

  callback(null, event);
};

リクエストパラメータ

challengeName

チャレンジの名前です。以下のいずれかですが、今回は CUSTOM_CHALLENGE であるかを確認します。(CUSTOM_CHALLENGE、SRP_A、PASSWORD_VERIFIER、SMS_MFA, DEVICE_SRP_AUTH、DEVICE_PASSWORD_VERIFIER、または ADMIN_NO_SRP_AUTH )

userAttributes

ユーザー属性を表す1つ以上の "名前-値ペア" です。今回はメール送信のために、email属性を使用します。

レスポンスパラメータ

privateChallengeParameters

このパラメータは認証チャレンジレスポンスの検証のLambdaトリガーで使用されます。チャレンジに対するユーザーのレスポンスを検証するために必要な情報のすべてを含める必要があります。

今回は、生成したワンタイムコードおよびその有効期限を含めています。

認証チャレンジレスポンスの検証(Verify auth challenge response)

最後に、認証チャレンジレスポンスの検証です。

実装例を以下に示します。 この例では、まずワンタイムコードの有効期限をチェックし、その後ユーザーの回答が正しいかを検証しています。

exports.verifyAuthChallenge = async (event, context, callback) => {
  const expected = event.request.privateChallengeParameters["onetimeCode"];
  const expires = new Date(event.request.privateChallengeParameters["expires"]);

  const now = new Date();
  if (expires.getTime() < now.getTime()) {
    event.response.answerCorrect = false;
  } else {
    event.response.answerCorrect = event.request.challengeAnswer === expected;
  }

  callback(null, event);
};

リクエストパラメータ

privateChallengeParameters

認証チャレンジの作成のLambdaトリガーで設定したパラメータです。

今回は、ワンタイムコードとその有効期限が含まれています。

challengeAnswer

チャレンジに対するユーザーの回答です。

レスポンスパラメータ

answerCorrect

ユーザーがチャレンジを正常に完了した場合true、正常に完了しなかった場合falseに設定します。

今回は、有効期限内かつワンタイムコードが正しいときのみtrueとしています。

Lambdaトリガーの設定

必要なLambdaが用意できたので、Cognitoユーザープールに設定します。

ユーザープール → 全般設定 → トリガーから設定ができます。

2022年6月時点での新しいインターフェースだと、

ユーザープール → ユーザープールのプロパティ → Lambdaトリガーを追加から設定できます。

これで、Cognito 側の準備は整いました。

アプリケーション(バックエンド)からの呼び出し

アプリケーションでは、InitiateAuth と respondToAuthChallenge の呼び出し部分を実装します。

InitiateAuth の呼び出し

通常のパスワード認証をした後にInitiateAuthを呼び出すことで、カスタム認証フローを開始し、MFAを実現します。

呼び出し部分の実装例は以下です。

// パスワード認証    
const input: AdminInitiateAuthCommandInput  = {
    AuthFlow: AuthFlowType.ADMIN_USER_PASSWORD_AUTH,
    ClientId: clientId,        // 適切なクライアントID
    UserPoolId: userPoolId,    // 適切なユーザープールID
    AuthParameters: {
      USERNAME: userName,      // 認証するユーザー名
      PASSWORD: password,      // ユーザーが入力したパスワード
    },
};

try {
  const command = new AdminInitiateAuthCommand(input);
  const response: AdminInitiateAuthCommandOutput =
    await this.cognitoClient.send(command);
} catch (error) {
  /** エラー処理 */
}

// カスタム認証フローの開始
// cognitoClient: CognitoIdentityProviderClient は事前に定義されているものとする
const input: AdminInitiateAuthCommandInput = {
  AuthFlow: AuthFlowType.CUSTOM_AUTH,
  ClientId: clientId,        // 適切なクライアントID
  UserPoolId: userPoolId,    // 適切なユーザープールID
  AuthParameters: {
    USERNAME: userName,      // 認証するユーザー名
  },
};
const command = new AdminInitiateAuthCommand(input);

try {
  const response: AdminInitiateAuthCommandOutput = await cognitoClient.send(command);
  return response.Session;
} catch (error) {
  /** エラー処理 */
}

フローのタイプに、AuthFlowType.CUSTOM_AUTH を指定します。

この呼び出しがうまく行けば、ユーザーのemailにワンタイムコードが届いているはずです。

response.Session は次に呼ぶ respondToAuthChallenge で使用します。

respondToAuthChallenge の呼び出し

最後に、ユーザーからのワンタイムコードの入力をもとに、respondToAuthChallengeを呼び出します。

呼び出し部分の実装例は以下です。

// 認証チャレンジへの返答
// cognitoClient: CognitoIdentityProviderClient は事前に定義されているものとする
const input: AdminRespondToAuthChallengeCommandInput = {
  ClientId: clientId,             // 適切なクライアントID
  UserPoolId: userPoolId,         // 適切なユーザープールID
  ChallengeName: ChallengeNameType.CUSTOM_CHALLENGE,
  ChallengeResponses: {
    USERNAME: userName,           // 認証するユーザー名
    ANSWER: onetimePassword,      // ユーザーから入力されたワンタイムコード
  },
  Session: sessionId,             // InitiateAuth で受け取ったセッションID
};
const command = new AdminRespondToAuthChallengeCommand(input);

try {
  const response: AdminRespondToAuthChallengeResponse = await cognitoClient.send(command);
  return response.AuthenticationResult;
} catch (error) {
  /** エラー処理 */
}

チャレンジネームには、ChallengeNameType.CUSTOM_CHALLENGE を指定します。

この呼び出しがうまく行けば、response.AuthenticationResult に各種トークンが入っているはずです。

これにて、MFAを実現することができました!!

まとめ

  • Cognito はカスタム認証チャレンジを定義して独自の認証フローを構築できる。

  • カスタム認証チャレンジの定義には以下の3つのLambdaトリガーが必要である。

    • 認証チャレンジの定義(Define auth challenge)

    • 認証チャレンジの作成(Create auth challenge)

    • 認証チャレンジレスポンスの検証(Verify auth challenge response)

  • 通常のパスワード認証のあとにカスタム認証チャレンジを行うことでMFAが実現できる。

参考情報

\もっと身近にもっとリアルに!DAAE公式Twitter/


筆者プロフィール: 岸田 脩平
SHIFT DAAE部所属の開発エンジニア。前職ではC++で医療機器ソフトウェアの開発に携わっていた。
好きなウイスキーはバーボン。好きな日本酒は山廃。好きなビールは苦いやつ。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/