Amazon Cognito でパスキー認証の裏側を見てみよう【後編】
こんにちは、DWS 2人目の大島です。
こちらの記事は、
AWS が公開する Amazon Cognito でパスキーを取り扱うハンズオンを用いて、
パスキー認証がどのように動いているのかを確認してみる記事の後編になります。
前編では、パスキーの基本的な知識の確認や、Cognito におけるパスキーの登録のフローを確認しました。前編の内容をもとに、後編を書いておりますので、お時間がある方はぜひ、前編も参照してみてください。
ハンズオンのリンクは↓になります。
ハンズオンで取り扱うソースコードの Github レポジトリは↓です。
今回取り扱うことと取り扱わないこと
前編を読んだ方には繰り返しとなりますが、再度記事で取り扱う内容を示しておきます。
主に Cognito でのパスキーの実装を見て、どのような仕組みで動いているのか、を確認していきます。
上記にあるそもそもパスキーとは?といった部分については、前編で取り扱ったので、後編では割愛します。
念の為以下に、前編の該当箇所へのリンクを貼っておきます。
3つのフロー
前編の繰り返しにはなりますが、
パスキーを使用する上では押さえておきたいフローとして、鍵の登録と鍵を使った認証の大きく2つのフローがあります。
さらに、今回デプロイされるリソースでは認証には2つのフローが実装されているため、合計で3つのフローがあります。
- 鍵の登録
- 鍵を使った認証
- username あり認証
- ログイン時に、事前にどのユーザーでログインをするのかをユーザーから指定する方式
- ログイン時に、事前にどのユーザーでログインをするのかをユーザーから指定する方式
- username なし認証
- ログイン時に、サイトの情報などをもとにリライングパーティーで使用するパスキーを事前に指定してログインする方法
- username あり認証
前編では、① の鍵の登録フローを確認しました。
後編となるこちらの記事では、鍵を使った2種類の認証フローを確認していきたいと思います。
鍵を使った2つの認証フロー
認証フローの再確認
どちらの認証フローもやることは基本的に同じであるため、大まかな認証フローを再度確認します。
鍵による認証の際には、「リライングパーティー」側からチャレンジ値を送信し、
「認証器」の持つ秘密鍵でチャレンジを署名、出来上がった署名をリライングパーティ側で登録されている公開鍵を使用して検証を行います。
認証フロー図
鍵の認証フロー:username あり認証
username ありの認証フローでは、Cognito の裏に存在する Lambda を使用したカスタム認証フローが利用されます。
使用されるリソースは以下の通りとなります。
前編にて、鍵の登録が完了しているので、
認証器上に秘密鍵が、DynamoDB 上に公開鍵が格納されている状態になります。
今回、Cognito と、Cognito の裏側でカスタム認証フローを実施する Lambda だけで処理が完了しますので、API Gateway は使用されません。
使用されるリソース
Cognito でパスキーの認証を導入するには、ネイティブの機能だけではできないため、「カスタム認証フロー」と呼ばれる独自の認証チャレンジを Lambda で定義する必要があります。
「カスタム認証フロー」で利用される Lambda は以下の通りです。
関数名 | 説明 |
---|---|
Define auth Challenge | 行う認証の順番などを制御する関数です。一番最初に呼び出されます。 |
Create auth Challenge | 実際に実装する認証の内容を定義する関数です。 |
Verify auth Challenge | Create auth Challenge で実装した認証に対して、 認証が正しいか検証して、レスポンスを返すための関数です。 |
username ありの認証では、
まずは、メールアドレスを入力してどのユーザーでログインするのか、を指定する必要があります。(「username あり」とは、誰でログインするのかをリライングパーティへ情報として教えてあげる、という意味です)
username ありの認証の始め方
メールアドレスを入力して、
ユーザーを指定したら、Sign in with face or touch を押下すると、Cognito へ認証を開始するリクエストが飛びます。
パスキーでの認証の開始
1. 認証チャレンジの開始
Cognito への最初に認証リクエストは、InitiateAuthAPI と呼ばれる API となります。
Cognito に認証の依頼が飛ぶと、
Cognito は裏側に設定されているDefine auth Challengeという Lambda を呼び出します。
step1 Define auth Challenge
Define auth Challengeが呼び出されると、
使用するカスタム認証フローにて実施する認証チャレンジの方法・制御の順番などの各種オプションが設定され、Cognito にレスポンスが返却されます。
これによって、Cognito はカスタム認証フローを開始します。
The Define auth Challenge Lambda trigger uses a session array of previous challenges and responses as input. It then generates the next challenge name and Booleans that indicate whether the user is authenticated and can be granted tokens. This Lambda trigger is a state machine that controls the user’s path through the challenges.
参考:https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow.html#amazon-cognito-user-pools-custom-authentication-flow
2. チャレンジ値の生成と返却
次に、一連の認証チャレンジの中で使用するチャレンジ値を生成するためにCreate auth Challengeが呼び出されます。
この関数では、ブラウザから認証器を呼ぶための Web API である、navigator.credentials.get()を利用するために必要なチャレンジ値を含めた各種情報を準備します。
step2 Create auth Challenge
Create auth Challengeでは、
チャレンジ値の生成と合わせて、「鍵の登録フロー」で登録した公開鍵が保管されている DynamoDB から、対象のユーザーが保有する鍵ペアの識別子であるcredential IDを取得します。
credential IDは、ユーザーが今回のチャレンジで使用する秘密鍵を指定するために必要になります
チャレンジ値やcredential IDの取得が完了すると、Create auth Challengeは、各種情報を、fido2optionsというオブジェクトとして、 Cognito へ返却し、Cognito からクライアントへチャレンジの情報を渡します。
この時、fido2optionsは、以下2つのデータに複製されて返却されています。
- 今後のユーザーから返ってきたチャレンジ値を検証するために、ユーザーへ渡したチャレンジをCognito 上で保管しておくデータ
- ユーザーが実際に秘密鍵を使用して認証チャレンジを実施するために Cognito からユーザーへ返却するデータ
それぞれは、publicChallengeParameters,privateChallengeParametersという属性として、Cognito へ返却されています。
publicChallengeParameters
クライアントアプリケーションでユーザーに提示されるチャレンジに使用する 1 つ以上のキー - 値ペア。このパラメータには、ユーザーにチャレンジを正確に提示するために必要なすべての情報を含める必要があります。
privateChallengeParameters
このパラメータは、認証チャレンジレスポンスの検証の Lambda トリガー以外では使用されません。このパラメータには、チャレンジに対するユーザーのレスポンスを検証するために必要な情報のすべてを含める必要があります。つまり、
参考:https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-lambda-verify-auth-challenge-response.htmlpublicChallengeParameters
には、ユーザーに示される質問が含まれ、privateChallengeParameters
には、質問に対する有効な回答が含まれます。
大まかに、チャレンジの生成と返却についての流れを理解したところで、
今回のチャレンジの肝となるfido2options格納されるオブジェクトの中身を確認してみましょう。
コードを見てみると以下のようなオブジェクトが返却されています。
return {
relyingPartyId,
challenge: await challengeGenerator(),
credentials,
timeout,
userVerification,
};
fido2options の属性名 | 説明 |
---|---|
relyingPartyId | 認証を実施するリライングパーティの識別子です。 ドメインが文字列で入ります。 |
challenge | 生成されたランダムなチャレンジ値が入ります。 |
credentials | クライアントが利用できる鍵ペアを一意に識別するcredential IDが格納されています。 credential IDと合わせて、transport(クライアントが認証器とやり取りする方法) という要素も渡されています。 |
timeout | navigator.credentials.get()の完了を待つ最大時間をミリ秒単位で指定します。 |
userVerification | user verification (ユーザーが秘密鍵にアクセスする際に認証器で行う認証)の実施するかどうかを設定します。 使用される値は、"required", "preferred", "discouraged" の3つです。 |
これらの値によって、navigator.credentials.get()を呼び出して秘密鍵での認証を制御します。
3. チャレンジ値の署名
Cognito を経由して、アプリケーションへチャレンジ値やcredential IDが返却されると、
アプリケーションから、WebAPI のnavigator.credentials.get()を呼び出して、認証器の秘密鍵を利用します。
navigator.credentials.get()が呼び出される際に、先ほどfido2optionsで説明したチャレンジ値を含めた各種情報などをpublicKeyオブジェクトとして渡しています。
step3 navigator.credentials.get()
navigator.credentials.get()を呼び出して秘密鍵を利用する際には、認証器上でのユーザー認証が必要になります。
(今回、ブラウザで生体認証を要求されています)
生体認証画面
認証器でのユーザーの認証が完了すると、navigator.credentials.get()からの返り値として、PublicKeyCredentialオブジェクトが返却されます。
前編で鍵の登録のフローの際に使用した、navigator.credential.create()でも、PublicKeyCredentialオブジェクトが戻ってきていました。
ただ、今回は中身が変わっており、PublicKeyCredentialの中には、秘密鍵を利用して実行した認証に関するデータであるAuthenticatorAssertionResponseが入っています。
AuthenticatorAssertionResponseを構成する要素は、3つあり以下の通りです。
- authenticatorData
認証器から渡される情報を持つデータです。
細かい部分は割愛しますが、ユーザーが認証器上で認証を行なったかどうか?(UserVerification)などの認証に関する情報が返却されています。
- clientDataJSON
これはnavigator.credentials.get()へ渡された値が格納されているデータです。
(navigator.credential.create()と同様ですね)
ですので、ここで受け取ったチャレンジ値などのデータが入っています。
- signature
authenticatorDataとclientDataJSONを秘密鍵で署名したものになります。
このチャレンジ値を含む各種データを秘密鍵で署名することによって、クライアントが秘密鍵を持っていることが担保されます。
これで秘密鍵で署名されたチャレンジ値が手に入りました。
4. チャレンジ値の検証
チャレンジ値の署名が完了したら、Cognito に対してRespondToAuthChallengeAPIを呼び、チャレンジ値など各種情報を返却します。
A
参考:https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_RespondToAuthChallenge.htmlRespondToAuthChallenge
API request provides the answer to that challenge, like a code or a secure remote password (SRP).
step4 Verify auth Challenge
Cognito では、チャレンジ値の検証などのためにVerify auth ChallengeResponseの Lambda が呼ばれます。
Verify auth ChallengeResponseには、先ほどチャレンジ値などをリライングパーティ側で保存するために使われたprivateChallengeParametersが渡されており、この中の値をクライアントが送信してきたデータと比較して検証を行います。
クライアントからは、clientDataJSONの中にチャレンジ値が返ってきているので、こちらが privateChallengeParametersのなかのチャレンジ値と一緒なのかを検証します。
(正確には、チャレンジ値が入っているclientDataのハッシュ値とauthenticatorDataを結合したものの署名を検証しています)
これにより、クライアントから送られてきた一連のデータが改竄されていない・ユーザーが対象の秘密鍵を持っている・認証の手続きが一連の流れのものであること、が担保されます。
最後に、検証結果が問題なければ、Verify auth ChallengeResponseは、Cognito へチャレンジ成功の結果を返します。
5. トークンの返却
Cognito へチャレンジの結果が返却されると、
Cogniot はリクエストの際に、sessionというパラメータにチャレンジの結果を格納して再度 Define auth Challengeの Lambda を呼び出します。
s
参考:https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-lambda-define-auth-challenge.htmlession
パラメータは、現在の認証プロセスでユーザーに提示されたすべてのチャレンジが含まれる配列です。リクエストには、対応する結果も含まれます。session
配列は、チャレンジの詳細 (ChallengeResult
) を時系列に保存します。チャレンジsession[0]
は、ユーザーが最初に受け取るチャレンジを表します。
sessionは複数のチャレンジの結果を格納できるので、Define auth Challengeでさらにクライアント側にチャレンジを要求することなども可能です。
ただ今回は、パスキーのチャレンジが完了した時点で、認証は完了となりますので、
Define auth Challengeから Cognito へトークンの払い出しなどを要求するパラメータが返却されます。
最後に Cognito は、アクセストークンやIDトークンをクライアントへ払出します。
step5 Define auth Challenge
実際にブラウザの Local Storage を確認してみると、以下のように、
IDトークンや、アクセストークンなどが格納されています。
トークンの結果
これにて、username あり認証のフローは完了です。
次から、username なし認証についてみていきます。
鍵の認証フロー:username なしの認証
username なしの認証では、誰でログインするのか指定せずに、パスキーでのログインを開始します。
username なし認証の始め方
登場するリソースは以下の通りです。
基本的には、username ありの認証とそれほどフローは変わりません。
username なしの認証では、チャレンジ値の生成に Cognito の裏にある Lambda ではなく、API Gateway の裏に存在する Lambda が使用されます。
登場するリソース
Sign in with passkey を選択すると、最初に API Gateway の/sing-in-challengeの Lambda へリクエストが飛びます。
ここで、チャレンジ値を生成し、DyanamoDB に格納します。
step1 /sign-in-challenge
DynamoDB へチャレンジ値を格納した後は、クラアイントへチャレンジ値を返却します。
チャレンジ値が返却されると、navigator.credential.get()を実行します
step2 navigator.credentials.get()
username なしの認証では、username あり認証で使用していた使用する鍵を特定するためのcredential IDがないため、ユーザー側で使用する鍵を選択する必要があります。
パスキー選択画面
この後の手順は username ありの認証とほとんど変わりません。
3. Cognito のInitiateAuthAPIへリクエストが飛び、Define auth Challengeが実行され、今回実施するチャレンジのカスタム認証フローの制御が行われます。
4. Create auth Challengeが実行され、ユーザーがチャレンジの実行に必要な情報が作成され、Cognito を経由してユーザーへ返却されます。
5. すでにユーザーはチャレンジを完了した情報(秘密鍵での署名など)を持っているので、 Cognito のRespondToAuthChallengeAPIへリクエストが飛び、Verify auth Challenge Responseが実行されます。
この時、チャレンジ値は DynamoDB 上に格納されているので、DynamoDB からチャレンジ値を持ってきて検証します。
(RespondToAuthChallengeで返却されるチャレンジ値は/sign-in-challengeにて生成されたものです)
6. チャレンジが成功したら、Cognito からDefine auth Challengeが実行され、Cognito からユーザーへトークンが返却されます。
step3~6
ちなみに、Create auth Challengeでも一応チャレンジ自体は生成されているのですが、
今回すでに、チャレンジは/sign-in-challengeにて生成されていますので、使用されないようです。
The
参照:https://github.com/aws-samples/amazon-cognito-passwordless-auth/blob/main/FIDO2.md#note-on-userhandle-and-usernameCreateAuthChallenge
trigger will return a WebAuthn challenge also (it always does this if you've enabled FIDO2), but that challenge won't be used by the client (it already signed the challenge it got earlier from the/sign-in-challenge
API endpoint).
終わりに
いかがだったでしょうか。
だいぶん長くなってはしまいましたが、Cognito を用いたパスキーでの認証に関しての理解の一助になれば幸いです。
ぜひ実際にハンズオンも実施してみてください。