【Cognito】メールで多要素認証(MFA)する方法
はじめまして!11月からマーベリックスのメンバーとなりました兼岡です。
Amazon Cognitoのユーザー認証で、カスタム認証フローを利用したメールでの多要素認証(MFA)を実装する機会がありましたので、その方法についてご紹介します。
カスタム認証フローについて
Cognitoユーザープールには、Lambdaへのトリガー機能があります。
認証フローの途中で、Lambdaにフックすることで、独自の処理を呼び出して認証アクションをカスタマイズすることができます。
今回のカスタム認証フローで利用するLambdaトリガーは以下の3つです。
- 認証チャレンジを定義(Define auth challenge)
- Cognitoは、このトリガーを呼び出して、カスタム認証フローの開始から終了までを管理します。
- 認証チャレンジを作成(Create auth challenge)
- Cognitoは、このトリガーを認証チャレンジの定義後に呼び出して、カスタムチャレンジ(質問)を作成します。
- 認証チャレンジレスポンスを確認(Verify auth challenge response)
- Cognitoは、このトリガーが呼び出して、カスタムチャレンジ(質問)に対するエンドユーザーからのレスポンス(回答)が有効であるかどうかを検証します。
Lambda関数の実装
認証チャレンジの定義(Define auth challenge)
まずは、認証チャレンジの定義を実装します。
以下の実装例は、通常のパスワード認証の後にカスタム認証チャレンジを行うことを想定しています。関数名は「test-cognito-define-auth-challenge」とします。
exports.handler = async (event, context, callback) => {
const len = event.request.session.length - 1;
if (
event.request.session[len].challengeName === "SRP_A" &&
event.request.session[len].challengeResult === true
) {
event.response.issueTokens = false;
event.response.failAuthentication = false;
event.response.challengeName = "PASSWORD_VERIFIER";
} else if (
event.request.session[len].challengeName === "PASSWORD_VERIFIER" &&
event.request.session[len].challengeResult === true
) {
event.response.issueTokens = false;
event.response.failAuthentication = false;
event.response.challengeName = "CUSTOM_CHALLENGE";
} else if (
event.request.session[len].challengeName === "CUSTOM_CHALLENGE" &&
event.request.session[len].challengeResult === true
) {
event.response.issueTokens = true;
event.response.failAuthentication = false;
} else if (
event.request.session[len].challengeName === "CUSTOM_CHALLENGE" &&
event.request.session[len].challengeResult === false
) {
event.response.issueTokens = false;
event.response.failAuthentication = false;
event.response.challengeName = "CUSTOM_CHALLENGE";
} else {
event.response.issueTokens = false;
event.response.failAuthentication = true;
}
// Return to Amazon Cognito
callback(null, event);
};
認証チャレンジ/レスポンスのやり取りのたびに呼び出されます。
event.request.sessionは、現在の認証プロセスでユーザーに提示された全てのチャレンジが含まれる配列です。ここにチャレンジの詳細が時系列で蓄積されていきます。
アプリケーションからサインインを開始すると、challengeName=SRP_Aを含む初期セッションで、認証チャレンジの定義が呼び出されます。
また、challengeNameを指定することで、後続のチャレンジを提示することができます。ユーザーパスワードの検証を行う場合はchallengeName=PASSWORD_VERIFIERを、認証チャレンジの作成を呼び出す場合はchallengeName=CUSTOM_CHALLENGEを指定します。
認証チャレンジ/レスポンスを繰り返し、最後のchallengeResult=trueになったら、認証が成功したと判断しています。event.response.issueTokens=trueを指定することで、トークンを生成してクライアント側に返却することができます。
認証チャレンジの作成(Create auth challenge)
次に、認証チャレンジの作成を実装します。
以下の実装例では、認証コードを生成してユーザーにメールで送信しています。関数名は「test-cognito-create-auth-challenge」とします。
const crypto = require("crypto");
const aws = require("aws-sdk");
const ses = new aws.SES({
region: process.env.REGION,
});
exports.handler = async (event, context, callback) => {
const len = event.request.session.length - 1;
if (event.request.challengeName === "CUSTOM_CHALLENGE") {
let verificationCode;
if (event.request.session[len].challengeName === "CUSTOM_CHALLENGE") {
verificationCode = event.request.session[len].challengeMetadata;
} else {
verificationCode = crypto.randomBytes(3).toString("hex");
await sendEmail(event.request.userAttributes["email"], verificationCode);
}
event.response.privateChallengeParameters = {
answer: verificationCode,
};
event.response.challengeMetadata = verificationCode;
}
// Return to Amazon Cognito.
callback(null, event);
};
async function sendEmail(email, code) {
const params = {
Destination: {
ToAddresses: [email],
},
Message: {
Body: {
Text: {
Data: `Your verification code is ${code}.`,
},
},
Subject: {
Data: "Your verification code",
},
},
Source: process.env.FROM_MAIL_ADDRESS,
};
await ses.sendEmail(params).promise();
}
多要素認証で使用する認証コードを生成し、ユーザーにAmazon SESでメール送信しています。
verificationCode = crypto.randomBytes(3).toString("hex");
await sendEmail(event.request.userAttributes["email"], verificationCode);
以下の処理で、生成した認証コードを「認証チャレンジレスポンスの検証」トリガーで呼び出されるLambda関数に渡しています。これが質問に対する期待される回答になります。
event.response.privateChallengeParameters = {
answer: verificationCode,
};
以下は、認証コードをセッションに追加しておくことで、次回の「認証チャレンジの作成」トリガーで利用できるようにしています。
event.response.challengeMetadata = verificationCode;
新しく認証コードを生成せず、既存セッションに保持している認証コードを再利用しています。
if (event.request.session[len].challengeName === "CUSTOM_CHALLENGE") {
verificationCode = event.request.session[len].challengeMetadata;
また、Amazon SESでメール送信するため、Lambdaの実行ロールにses:SendEmailを許可するポリシーを付与する必要があります。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ses:SendEmail",
"ses:SendRawEmail"
],
"Resource": "*"
}
]
}
認証チャレンジレスポンスの検証(Verify auth challenge response)
最後に、認証チャレンジレスポンスの検証を実装します。
クライアント側から渡された回答が正しいかを判定する処理です。関数名は「test-cognito-verify-auth-challenge-response」とします。
exports.handler = async (event, context, callback) => {
const answer = event.request.privateChallengeParameters.answer;
if (event.request.challengeAnswer === answer) {
event.response.answerCorrect = true;
} else {
event.response.answerCorrect = false;
}
// Return to Amazon Cognito
callback(null, event);
};
参考:認証チャレンジレスポンスの検証の Lambda トリガー
Lambdaトリガーの設定
実装したLambdaをCognitoユーザープールに設定します。
アプリケーション側の実装
アプリケーション側は、AWS AmplifyのAuthライブラリを使用します。
カスタマイズした認証フローを使用するため、authenticationFlowType=CUSTOM_AUTHを指定します。
import { Auth } from 'aws-amplify'
Auth.configure({
authenticationFlowType: 'CUSTOM_AUTH',
})
まずは、通常のユーザーパスワード認証処理です。
パスワード認証を通過するとカスタム認証チャレンジが行われ、認証コードがメールで通知されます。この時点で返却されるCognitoユーザー情報にアクセストークンは含まれません。
async submitSignIn() {
const username = this.formValue.email
const password = this.formValue.password
await Auth.signIn({ username, password })
.then((user) => {
this.user = user
})
.catch((err) => {
console.log(err)
})
},
最後に、認証コードの送信処理です。
以下のように、パスワード認証で返却されたCognitoユーザー情報と認証コードを引数として、Auth.sendCustomChallengeAnswerを呼び出します。「認証チャレンジレスポンスの検証」トリガーが実行され、無事に認証コードの検証を通過すると、トークンが生成されて返却されます。
async submitVerificationCode() {
const verificationCode = this.formValue.verificationCode
await Auth.sendCustomChallengeAnswer(this.user, verificationCode)
.then((user) => {
console.log(user)
})
.catch((err) => {
console.log(err)
})
},
さいごに
カスタム認証フローを利用したメールでの多要素認証(MFA)についてご紹介しました。
Cognito標準のセキュリティ機能では、SMSテキストメッセージによる多要素認証(MFA)が提供されていますが、カスタム認証チャレンジを定義することで、独自の認証フローを構築できることがわかりました。
この記事が少しでも、どなたかの参考になれば嬉しいです!