AWS

Amazon Cognito でパスキー認証の裏側を見てみよう【後編】

tak

こんにちは、DWS 2人目の大島です。

こちらの記事は、
AWS が公開する Amazon Cognito でパスキーを取り扱うハンズオンを用いて、
パスキー認証がどのように動いているのかを確認してみる記事の後編になります。


前編では、パスキーの基本的な知識の確認や、Cognito におけるパスキーの登録のフローを確認しました。前編の内容をもとに、後編を書いておりますので、お時間がある方はぜひ、前編も参照してみてください。

参考
Amazon Cognito でパスキー認証の裏側を見てみよう【前編】
Amazon Cognito でパスキー認証の裏側を見てみよう【前編】

ハンズオンのリンクは↓になります。

参考
Implement Passwordless authentication with Amazon Cognito and WebAuthn
Implement Passwordless authentication with Amazon Cognito and WebAuthn

ハンズオンで取り扱うソースコードの Github レポジトリは↓です。

参考
amazon-cognito-passwordless-auth
amazon-cognito-passwordless-auth

今回取り扱うことと取り扱わないこと

前編を読んだ方には繰り返しとなりますが、再度記事で取り扱う内容を示しておきます。

主に Cognito でのパスキーの実装を見て、どのような仕組みで動いているのか、を確認していきます。

この記事で取り扱うこと
  • パスキーについての基本的な概要
  • Cognito でパスキーを使用するフローの仕組み
  • パスキーの実装の裏側
この記事で取り扱わないこと
  • ハンズオンのデプロイの方法
  • 実案件での Cognito へのパスキーの組み込み方法
  • マジックリンク・ワンタイムパスワード認証

上記にあるそもそもパスキーとは?といった部分については、前編で取り扱ったので、後編では割愛します。
念の為以下に、前編の該当箇所へのリンクを貼っておきます。

あわせて読みたい
前編:Cognito でパスキーを使うには
前編:Cognito でパスキーを使うには

3つのフロー

前編の繰り返しにはなりますが、
パスキーを使用する上では押さえておきたいフローとして、鍵の登録と鍵を使った認証の大きく2つのフローがあります。

さらに、今回デプロイされるリソースでは認証には2つのフローが実装されているため、合計で3つのフローがあります。

  1. 鍵の登録
  2. 鍵を使った認証
    • 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 ChallengeCreate auth Challenge で実装した認証に対して、
認証が正しいか検証して、レスポンスを返すための関数です。

username ありの認証では、
まずは、メールアドレスを入力してどのユーザーでログインするのか、を指定する必要があります。(「username あり」とは、誰でログインするのかをリライングパーティへ情報として教えてあげる、という意味です)

username ありの認証の始め方

メールアドレスを入力して、
ユーザーを指定したら、Sign in with face or touch を押下すると、Cognito へ認証を開始するリクエストが飛びます。


パスキーでの認証の開始

1. 認証チャレンジの開始

Cognito への最初に認証リクエストは、InitiateAuthAPI と呼ばれる API となります。

参考
InitiateAuth
InitiateAuth

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 トリガー以外では使用されません。このパラメータには、チャレンジに対するユーザーのレスポンスを検証するために必要な情報のすべてを含める必要があります。つまり、publicChallengeParameters には、ユーザーに示される質問が含まれ、privateChallengeParameters には、質問に対する有効な回答が含まれます。

参考:https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-lambda-verify-auth-challenge-response.html

大まかに、チャレンジの生成と返却についての流れを理解したところで、
今回のチャレンジの肝となるfido2options格納されるオブジェクトの中身を確認してみましょう。


コードを見てみると以下のようなオブジェクトが返却されています。

  return {
    relyingPartyId,
    challenge: await challengeGenerator(),
    credentials,
    timeout,
    userVerification,
  };
参考
amazon-cognito-passwordless-auth/cdk/custom-auth/fido2.ts#L112
amazon-cognito-passwordless-auth/cdk/custom-auth/fido2.ts#L112


fido2options の属性名説明
relyingPartyId認証を実施するリライングパーティの識別子です。
ドメインが文字列で入ります。
challenge生成されたランダムなチャレンジ値が入ります。
credentialsクライアントが利用できる鍵ペアを一意に識別するcredential IDが格納されています。
credential IDと合わせて、transport(クライアントが認証器とやり取りする方法) という要素も渡されています。
timeoutnavigator.credentials.get()の完了を待つ最大時間をミリ秒単位で指定します。
userVerificationuser 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が入っています。

参考
前編:6. 認証器からのレスポンス
前編:6. 認証器からのレスポンス


AuthenticatorAssertionResponseを構成する要素は、3つあり以下の通りです。

  • authenticatorData

認証器から渡される情報を持つデータです。
細かい部分は割愛しますが、ユーザーが認証器上で認証を行なったかどうか?(UserVerification)などの認証に関する情報が返却されています。

  • clientDataJSON

これはnavigator.credentials.get()へ渡された値が格納されているデータです。
navigator.credential.create()と同様ですね)
ですので、ここで受け取ったチャレンジ値などのデータが入っています。

  • signature

authenticatorDataclientDataJSONを秘密鍵で署名したものになります。
このチャレンジ値を含む各種データを秘密鍵で署名することによって、クライアントが秘密鍵を持っていることが担保されます

これで秘密鍵で署名されたチャレンジ値が手に入りました。

4. チャレンジ値の検証

チャレンジ値の署名が完了したら、Cognito に対してRespondToAuthChallengeAPIを呼び、チャレンジ値など各種情報を返却します。

RespondToAuthChallenge API request provides the answer to that challenge, like a code or a secure remote password (SRP).

参考:https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_RespondToAuthChallenge.html
step4 Verify auth Challenge

Cognito では、チャレンジ値の検証などのためにVerify auth ChallengeResponseの Lambda が呼ばれます。


Verify auth ChallengeResponseには、先ほどチャレンジ値などをリライングパーティ側で保存するために使われたprivateChallengeParametersが渡されており、この中の値をクライアントが送信してきたデータと比較して検証を行います。


クライアントからは、clientDataJSONの中にチャレンジ値が返ってきているので、こちらが privateChallengeParametersのなかのチャレンジ値と一緒なのかを検証します。
(正確には、チャレンジ値が入っているclientDataのハッシュ値とauthenticatorDataを結合したものの署名を検証しています)

これにより、クライアントから送られてきた一連のデータが改竄されていない・ユーザーが対象の秘密鍵を持っている・認証の手続きが一連の流れのものであること、が担保されます。

なぜチャレンジ値が必要なのか?

全体のデータを秘密鍵で署名して検証することで、
秘密鍵の所有を証明することができるので、チャレンジ値は不要ではないのか?という疑問が生まれるかもしれません。

チャレンジ値がないと、一度使用された認証データを盗まれた場合、
秘密鍵を持っていなくとも、認証データを再使用して認証に成功できてしまいます。(これをリプレイアタックといいます)
そこで、一度使用された認証データを再度使用できないように、それぞれの認証のやり取りのデータが一意となるようにランダムなIDを付与したくなります。
そこで、一連のやり取りの認証データにランダムなIDとしてチャレンジ値が必要になるわけですね。

最後に、検証結果が問題なければ、Verify auth ChallengeResponseは、Cognito へチャレンジ成功の結果を返します。

5. トークンの返却

Cognito へチャレンジの結果が返却されると、
Cogniot はリクエストの際に、sessionというパラメータにチャレンジの結果を格納して再度 Define auth Challengeの Lambda を呼び出します。

session パラメータは、現在の認証プロセスでユーザーに提示されたすべてのチャレンジが含まれる配列です。リクエストには、対応する結果も含まれます。session 配列は、チャレンジの詳細 (ChallengeResult) を時系列に保存します。チャレンジ session[0] は、ユーザーが最初に受け取るチャレンジを表します。

参考:https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-lambda-define-auth-challenge.html

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 CreateAuthChallenge 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).

参照:https://github.com/aws-samples/amazon-cognito-passwordless-auth/blob/main/FIDO2.md#note-on-userhandle-and-username

終わりに

いかがだったでしょうか。
だいぶん長くなってはしまいましたが、Cognito を用いたパスキーでの認証に関しての理解の一助になれば幸いです。

ぜひ実際にハンズオンも実施してみてください。

AUTHOR
tak
tak
記事URLをコピーしました