はじめに
Amazon Cognitoは、ユーザー登録やログイン、認証・認可、トークン発行といった処理をマネージドに提供するサービスです。
前回の記事「Amazon CognitoのLambdaトリガー完全ガイド(基礎編)」では、Cognitoの処理に合わせて独自処理を実行できるLambdaトリガーのうち、基本的なものを紹介しました。
今回はその応用編として、カスタム認証で利用する以下の3つのLambdaトリガーを整理します。
- Define Auth Challenge(認証の管理)
- Create Auth Challenge(認証の準備)
- Verify Auth Challenge Response(認証結果の検証)
本記事では、便宜上この3つを「カスタム認証3兄弟」と呼びます。
後半では実装例として、IDとパスワードによる認証の後に、Amazon Rekognitionで顔認証を行う仕組みを紹介します。
Lambdaトリガーの基本的な仕組みや設定方法、利用時の注意点については以下リンクで紹介しているため、本記事ではカスタム認証に関する内容を中心に解説します。
1.カスタム認証とは
カスタム認証は、Cognito標準の認証フローに独自の認証チャレンジを組み込む仕組みです。
例えば、以下のような認証を実装できます。
- CAPTCHA
- 秘密の質問
- 外部認証サービスとの連携
- ハードウェアセキュリティキー
- 生体認証
カスタム認証を利用する場合でも、パスワードの検証までLambdaで実装する必要はありません。
CUSTOM_AUTHフローでは、CognitoがSRPでパスワードを検証した後に、独自のチャレンジ(CUSTOM_CHALLENGE)を実行できます。
Amplifyを利用する場合は、signInのauthFlowTypeにCUSTOM_WITH_SRPを指定します。Cognito側ではCUSTOM_AUTHフローとして扱われます。
await signIn({
username,
password,
options: { authFlowType: "CUSTOM_WITH_SRP" }
});
2.カスタム認証3兄弟
カスタム認証では、3つのLambdaトリガーが次のように連携して認証を進めます。
以降は、それぞれをDefine、Create、Verifyと略して記載します。
Define:次に実行するチャレンジを決定
↓
Create:チャレンジを準備
↓
ユーザー:チャレンジに回答
↓
Verify:回答を検証
↓
Define:次のチャレンジ・トークン発行・認証失敗のいずれかを決定
Defineがトークンの発行または認証失敗を決定するまで、この流れを繰り返します。
今回の実装では、3つのLambdaトリガーに同じLambda関数を設定し、triggerSourceの値で処理を分岐しています。
2.1.Define Auth Challenge(認証の管理)
認証フロー全体を管理するLambdaトリガーです。
これまでのチャレンジの結果をもとに、以下を決定します。
- 次に実行するチャレンジ(
challengeName) - トークンを発行するか(
issueTokens) - 認証を失敗として終了するか(
failAuthentication)
これまでのチャレンジの名前と成否は、event["request"]["session"]に時系列で格納されています。なお、このsessionはRekognitionのFace Livenessのセッションとは別物です。
今回の実装では、SRP_A、PASSWORD_VERIFIER、CUSTOM_CHALLENGEの順にすべて成功した場合のみトークンを発行します。SRP_AとPASSWORD_VERIFIERは、SRPによるパスワード認証の段階です。
def define_auth_challenge(event):
session = event["request"].get("session", [])
# チャレンジの履歴を(チャレンジ名, 成否)のリストに変換
steps = [(item["challengeName"], item["challengeResult"]) for item in session]
response = event["response"]
response.update(issueTokens=False, failAuthentication=False)
if steps == [("SRP_A", True)]:
response["challengeName"] = "PASSWORD_VERIFIER" # パスワード検証へ
elif steps == [("SRP_A", True), ("PASSWORD_VERIFIER", True)]:
response["challengeName"] = "CUSTOM_CHALLENGE" # 顔認証へ
elif steps == [
("SRP_A", True),
("PASSWORD_VERIFIER", True),
("CUSTOM_CHALLENGE", True),
]:
response["issueTokens"] = True # すべて成功したのでトークンを発行
else:
response["failAuthentication"] = True # 失敗や想定外の順序は認証失敗
return event
2.2.Create Auth Challenge(認証の準備)
DefineでCUSTOM_CHALLENGEが指定されたときに実行され、ユーザーに実施させるチャレンジを準備するLambdaトリガーです。
チャレンジの準備では、以下の2つの値を設定します。
| パラメータ | 用途 |
|---|---|
publicChallengeParameters |
クライアントへ渡す値 |
privateChallengeParameters |
Verifyでの検証に使う値(Cognito内で保持され、クライアントには渡らない) |
今回の実装では、Face Livenessのセッションを作成し、発行されたsessionIdを両方に設定しています。
def create_auth_challenge(event):
session_id = create_liveness_session() # RekognitionのCreateFaceLivenessSession
event["response"]["publicChallengeParameters"] = {"sessionId": session_id}
event["response"]["privateChallengeParameters"] = {"sessionId": session_id}
return event
sessionIdはクライアントにも渡るため、秘密情報としては扱いません。認証の成否は、VerifyでFace Livenessの実際の判定結果を確認して判断します。
2.3.Verify Auth Challenge Response(認証結果の検証)
ユーザーの回答を検証し、その結果をanswerCorrectとしてCognitoへ返すLambdaトリガーです。
返した結果はチャレンジの成否としてsessionに記録され、Defineでの判断に使われます。
今回の実装では、回答されたsessionIdがCreateで保持した値と一致する場合のみ、Rekognitionで顔認証の結果を確認します。
def verify_auth_challenge(event):
expected = event["request"]["privateChallengeParameters"].get("sessionId")
answer = event["request"].get("challengeAnswer")
correct = False
if answer and answer == expected:
try:
# Face Livenessの結果確認と登録顔との比較(4.3で解説)
correct = compare_registered_face(event["userName"], answer)
except (ClientError, ValueError):
pass # Face Livenessの失敗・登録顔なし・APIエラーは認証失敗にする
event["response"]["answerCorrect"] = correct
return event
Face LivenessのsessionIdは1回の撮影専用で、ログインのたびにCreateで新しく発行されます。そのため、過去の撮影結果を使い回すことはできません。
3.ID+パスワード+カスタム認証の流れ
IDとパスワードによる認証の後に顔認証を行う場合、次のような流れになります。
React:CUSTOM_WITH_SRPでsignIn
↓
Define:PASSWORD_VERIFIERを指定
↓
Cognito:SRPでパスワードを検証
↓
Define:CUSTOM_CHALLENGEを指定
↓
Create:Face Livenessのセッションを作成し、sessionIdを返す
↓
React:顔を撮影し、confirmSignInでsessionIdを回答
↓
Verify:Face LivenessとCompareFacesの結果を検証
↓
Define:issueTokensをtrueにする
↓
Cognito:トークンを発行
4.Rekognitionで顔認証を実装してみる
カスタム認証の実装例として、Amazon Rekognitionの以下の2つの機能を組み合わせて顔認証を実装しました。
| 機能 | 役割 |
|---|---|
| Face Liveness | 写真や動画ではなく、カメラの前に生身の人物がいるかを判定 |
| CompareFaces | 登録済みの顔画像と、ログイン時に撮影した顔画像を比較 |
Face Livenessだけでは、登録した本人かどうかまでは判定できません。そのため、Face Livenessで取得した顔画像(ReferenceImage)と登録済みの顔画像を、CompareFacesで比較します。
4.1.顔登録
ユーザー登録時はカスタム認証を利用せず、Cognitoへのサインアップ後に顔を登録します。
React:ID+パスワードでサインアップ
↓
Lambda:CreateFaceLivenessSessionでsessionIdを発行
↓
React:FaceLivenessDetectorで撮影(StartFaceLivenessSession)
↓
Lambda:GetFaceLivenessSessionResultsで判定結果を確認
↓
Lambda:ReferenceImageを登録顔としてS3へ保存
4.2.Cognito Identity Poolとの連携
FaceLivenessDetectorは、ブラウザから直接RekognitionのStartFaceLivenessSessionを呼び出します。そのため、ブラウザ側でもAWSの一時認証情報が必要です。
しかし、顔認証を行う時点はカスタム認証の途中のため、User Poolのトークンはまだ発行されていません。顔登録時も同様です。
そこで、Cognito Identity Poolのゲストアクセスを有効にし、ログイン前でも一時認証情報を取得できるようにしています。
Identity Poolのロールには、以下の2つの権限のみを付与しています。
-
rekognition:StartFaceLivenessSession(カメラ映像をRekognitionへ送信) - バックエンドLambdaの
lambda:InvokeFunction(顔登録時のセッション作成と顔画像の保存を依頼)
Amplifyでは、identityPoolIdを指定したうえでallowGuestAccess: trueを設定します。
4.3.ログイン時の顔認証
以下は、Verifyから呼び出すcompare_registered_faceの例です。Face Livenessの判定結果を確認したうえで、登録済みの顔と比較します。
def compare_registered_face(username, session_id):
# Face Livenessの判定結果と、撮影した顔画像を取得する
result = rekognition.get_face_liveness_session_results(SessionId=session_id)
confidence = float(result.get("Confidence", 0))
image = result.get("ReferenceImage", {}).get("Bytes")
if result.get("Status") != "SUCCEEDED" or confidence < LIVENESS_THRESHOLD or not image:
raise ValueError("Face Livenessの判定に失敗しました")
# 登録時の顔(S3)と今回撮影した顔を比較する
compared = rekognition.compare_faces(
SourceImage={"S3Object": {"Bucket": FACE_BUCKET, "Name": face_key(username)}},
TargetImage={"Bytes": image},
SimilarityThreshold=SIMILARITY_THRESHOLD,
)
return bool(compared.get("FaceMatches"))
閾値(LIVENESS_THRESHOLD、SIMILARITY_THRESHOLD)はLambdaの環境変数で指定し、今回はどちらも90に設定しています。
5.実装時の注意点
5.1.標準の認証フローによる顔認証の迂回を防ぐ
App ClientでALLOW_USER_SRP_AUTHやALLOW_USER_PASSWORD_AUTHを許可すると、AWS CLIなどから標準の認証フローを直接呼び出し、顔認証を行わずにトークンを取得できてしまいます。
そのため、今回の構成では以下の2つのみを許可しています。
ExplicitAuthFlows:
- ALLOW_CUSTOM_AUTH # SRP+CUSTOM_CHALLENGE
- ALLOW_REFRESH_TOKEN_AUTH
5.2.Cognito Identity Poolのゲストアクセスが必要
今回の構成では、Cognito Identity Poolを省略できません。
User Poolのトークンが発行される前に、ブラウザから以下のAWS APIを呼び出すためです。
-
FaceLivenessDetectorからのStartFaceLivenessSession(ログイン時・顔登録時) - 顔登録処理からのバックエンドLambdaの
InvokeFunction(顔登録時)
User PoolとIdentity Poolは、次のように役割が異なります。
Cognito User Pool
→ ユーザー認証、トークン発行
Cognito Identity Pool
→ AWS APIを呼び出すための一時認証情報を発行
Identity Poolはログインの成否を判定するものではなく、ブラウザへAWSの一時認証情報を発行する仕組みです。Identity Poolを削除すると、FaceLivenessDetectorからStartFaceLivenessSessionを呼び出せなくなります。
また、ゲストアクセスを有効にすると、ログインしていないユーザーでもIdentity Poolのロールに付与した権限を利用できます。そのため、今回の構成ではロールの権限をStartFaceLivenessSessionと、対象をバックエンドLambdaに限定したlambda:InvokeFunctionのみにしています。
今回の検証では、顔登録処理でクライアントから渡されたusernameとsessionIdをそのまま使用しており、所有者確認やレート制限は省略しています。本番環境では、Face Livenessのセッションと登録対象のユーザーをバックエンド側で確実に結び付ける必要があります。
まとめ
カスタム認証は、標準機能だけでは実現できない独自の認証を、Cognitoの認証フローに組み込める便利な仕組みです。
本記事で紹介したカスタム認証3兄弟と役割は、次のとおりです。
Define Auth Challenge
→ 認証の管理(次のチャレンジ・トークン発行・認証失敗を決定)
Create Auth Challenge
→ 認証の準備(ユーザーに実施させるチャレンジを作成)
Verify Auth Challenge Response
→ 認証結果の検証(ユーザーの回答を検証し、結果をCognitoへ返す)
今回の実装では、IDとパスワードの検証をCognitoに任せたうえで、Face Livenessで生身の人物か、CompareFacesで登録済みの本人かを確認する顔認証を、CUSTOM_CHALLENGEとして追加しました。
一方で、顔認証を通らずにトークンを取得できる認証フローが残っていないか、Identity Poolのゲストアクセスで付与する権限が必要最小限になっているかなど、認証の抜け道や権限の範囲を考慮して設計する必要があります。
まずは3兄弟がどの順番で呼び出され、それぞれが何を判断しているのかを理解したうえで、実現したい認証に合わせてチャレンジを設計することが重要です。
本記事が、Amazon Cognitoのカスタム認証を理解するきっかけや、実際のシステムで活用する際の参考になれば幸いです。
今後は、ユーザー移行など、ほかの応用的なLambdaトリガーについても整理していきたいと思います。
最後までお読みいただき、ありがとうございました!