AWS Cognitoのカスタム認証フローを使って合言葉認証を作る

前回、前々回に引き続き、もうちょっと、AWS Cognitoを操ってみます。

(ちなみに、前回、前々回は以下の通り)
[AWS CognitoにGoogleとYahooとLINEアカウントを連携させる](https://qiita.com/poruruba/items/189945dc64edfe1f2464)
[AWS Cognitoにサインインしないと見れないLambdaを作る](https://qiita.com/poruruba/items/86668c9c63844e1afd32)


今回は、AWS Cognitoのカスタム認証フローをつかって、合言葉ログインを作ってみます。
サインアップ時に指定したパスワードで認証するのもありですが、それに加えて、独自の認証を自作してみたいと思います。カスタマイズ次第で、本人しかもっていないハードウェアトークンなどと連携するもの良いかと思います。

以下、参考となる情報源です。

+ カスタム認証チャレンジLambdaトリガー
  * https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-lambda-challenge.html
  * これが基本的なサーバ側の流れになります。

+ Amazon Cognito Identity API Reference
  * https://docs.aws.amazon.com/ja_jp/cognito-user-identity-pools/latest/APIReference/Welcome.html
  * クライアント側が呼び出すAPI（合言葉をこたえる側）


# Cognitoのトリガーの作成

AWS Cognitoには、Lambdaへのトリガー機能があります。認証フローの途中途中で、Lambdaにフックすることで、独自の処理を挿入することができるようになっています。
今回は、独自の処理として合言葉を実装します。

独自の認証を実装するために、Cognitoではカスタム認証フローが定義されていますので、それに従って、合言葉を実装していきます。
基本的な考え方は、チャレンジアンドレスポンスの考え方です。サーバ側が乱数のようなチャレンジをクライアント側に渡して、クライアント側が自身しか知らない情報でチャレンジからレスポンスを生成し、サーバ側はレスポンスが期待していた値と同じであれば、クライアント側が本人であることを確認します。

今回の合言葉では、サーバ側が発する質問「yama」に対して、秘密の回答「kawa」と答えれば、本人と認めます。（ちなみに、合言葉の質問は漢字で「山」、回答は「川」です。あまりにも有名すぎて、秘密でもなんでもないんですが。。。）

CognitoからフックされるLambda関数は10個程度あるのですが、今回のカスタム認証フローで用意が必要なLambda関数へのトリガは以下の3つです。

+ 認証チャレンジの定義
 * カスタム認証フローの開始から終了までのステートを管理します。
 * これが出発点であり、到着点でもあります。
+ 認証チャレンジの作成
 * 認証のチャレンジ(質問)を生成します。
+ 認証チャレンジレスポンスの確認
 * クライアントから返ってきたレスポンス(回答)が正しいかどうかを確認します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/1da66983-a25e-41a7-47a5-4e387762c6a5.png)

# Lambda関数：認証チャレンジの定義

それでは、一つずつ、Lambda関数を実装していきます。
Lambda関数を作成します。例えば、「test-cognito-defineauth-lambda」という名前の関数を作成します。

実装例は以下の通りです。

```javascript

exports.handler = async (event, context, callback) => {
    console.log('Lambda called OK!!');
    console.log(context);
    console.log(event);

    for( var i = 0 ; i < event.request.session.length ; i++ )
        console.log(event.request.session[i]);

    if( event.request.session.length > 0 && event.request.session[event.request.session.length - 1].challengeResult ){
        event.response.issueTokens = true;
        event.response.failAuthentication = false;
    }else{
        event.response.issueTokens = false;
        event.response.failAuthentication = false;
        event.response.challengeName = 'CUSTOM_CHALLENGE';
    }

    callback(null, event);
};
```

認証チャレンジの定義は、カスタム認証フローのステートを管理しており、チャレンジアンドレスポンスのやり取りのたびに呼ばれます。

やり取りの途中経過は、event.request.session の配列に積まれていきます。
最初は、配列のサイズが0なので、それを認証フローの開始と考えます。
challengeName=CUSTOM_CHALLENGEとしているのは、後続の処理としてチャレンジの作成を依頼するためであり、「認証チャレンジの作成」のLambda関数が続けて呼び出されます。

チャレンジレスポンスのやり取りを重ね、最終のsessionのchallengeResultがtrueになったら、認証が成功したと判断し、event.response.issueTokens = trueとすることで、Cognitoはトークンを生成しクライアント側に返してくれます。
上記の例では、event.request.sessionの配列が0より大きいことが終了条件の1つにしているのでチャレンジレスポンスのやり取りが1回だけで認証が完了しますが、より大きい値とすることで、認証完了するまでのチャレンジレスポンスのやり取りの回数を変えることもできます。

このように、event.response配下の変数群を操作することで、Cognitoがそれに基づいて次の処理を続けてくれます。

# Lambda関数：認証チャレンジの作成

次に、認証チャレンジの作成のLambda関数を作成します。例として、関数名を「test-cognito-createauth-lambda」とします。
以下のようなコードとしました。

```javascript
exports.handler = (event, context, callback) => {
    console.log('Lambda called OK!!');
    console.log(event);
    
    if (event.request.challengeName == 'CUSTOM_CHALLENGE') {
        event.response.publicChallengeParameters = {};
        event.response.publicChallengeParameters.challenge = 'yama';
        event.response.privateChallengeParameters = {};
        event.response.privateChallengeParameters.answer = 'kawa';
        event.response.challengeMetadata = 'CHALLENGE_AND_RESPONSE' + event.request.session.length;
    }
    
    callback(null, event);
}
```

このLambda関数では、乱数に相当する合言葉の質問を指定しています。
以下の部分です。これがクライアント側にそのまま渡されます。

> event.response.publicChallengeParameters.challenge = 'yama’;

以下は、質問に対する期待する回答になります。
クライアントに渡されはせず、「認証チャレンジレスポンスの確認」のLambda関数に渡されます。

> event.response.privateChallengeParameters.answer = 'kawa';

以下は必須ではないのですが、event.request.sessionに記録されていくやり取りの要素を区別しやすくしています。タグのようなものです。

> event.response.challengeMetadata = 'CHALLENGE_AND_RESPONSE' + event.request.session.length;

このLambda関数が終わると、合言葉の質問とともに、クライアント側に処理が移ります。


# Lambda関数：認証チャレンジレスポンスの確認

クライアントから合言葉の回答を受け取り、それが正しいかを判断するLambda関数です。例えば、「test-cognito-verifyauth-lambda」とします。

以下のような感じです。

```javascript
exports.handler = (event, context, callback) => {
    console.log('Lambda called OK!!');
    console.log(event);
    
    if (event.request.privateChallengeParameters.answer == event.request.challengeAnswer) {
        console.log('answer OK');
        event.response.answerCorrect = true;
    } else {
        console.log('answer NG');
        event.response.answerCorrect = false;
    }
    
    callback(null, event);
}
```

認証チャレンジの作成のLambda関数から聞いていた期待する回答（event.request.challengeAnswer）と、クライアントから返ってきた回答（event.request.privateChallengeParameters.answer）が等しいかどうかを確認します。

回答が正しかった場合、event.response.answerCorrect = true とすることで、その確認結果を次の処理を行うLambda関数に伝えます。
次の関数には、event.request.session[0].challengeResult でその確認結果が分かります。次の関数とは、「認証チャレンジの定義」のLambda関数ことです。

実は、認証チャレンジレスポンスの確認のLambda関数で回答が正しかったと伝えても、処理を終了させるのか、再度チャレンジレスポンスをやり取りするのかは、次のLambda関数が決めます。その話は、「認証チャレンジの定義」のLambda関数の説明ですでにしていました。

以上で、Lambda側の関数の実装が終わりました。


# クライアントの実装

今度はクライアント側の実装を進めます。
クライアントからの呼び出しには、AWS CognitoのAPIを利用します。

使うAPIは、以下の2つです。

+ InitiateAuth
 * 認証の開始を宣言します。レスポンスに合言葉の質問(チャレンジ)が返ってきます。
+ RespondToAuthChallenge
 * 合言葉の回答(レスポンス)を渡します。認証が成功すると、トークンが返ってきます。
 * サーバ側の実装によっては、再度チャレンジが来るようにもできます。

早速、実装してみます。
今回も、前回、前々回に利用したnode.jsの環境を再利用します。
2つのファイルを追加します。（auth_custom.html、start_custom.js）

```
.
├── cert
│   ├── server.crt
│   ├── server.csr
│   └── server.key
├── index.js
├── node_modules
├── package.json
└── public
    ├── auth.html
    ├── auth_custom.html
    └── js
        ├── start.js
        └── start_custom.js
```

まずは、auth_custom.htmlです。

```html
<HTML lang="ja">
<HEAD>
	<META http-equiv="Content-Type" content="text/html; charset=utf-8">	 
	<META http-equiv="Content-Security-Policy" content="default-src * 'unsafe-inline'; style-src * 'unsafe-inline'; media-src * blob:; img-src *; script-src * 'unsafe-inline';">
	<TITLE>テスト</TITLE>	 
</HEAD>
<BODY>
	<H1><a href="">カスタム認証テスト</a></H1>

	<input type="text" id="username" value="test" size="30">
	<BUTTON id="start_call" onclick="start_call()">start呼び出し</BUTTON>
	<DIV id="challenge"></DIV>
	<input type="text" id="answer" value="kawa" size="30">
	<BUTTON id="respond_call" onclick="respond_call()">respond呼び出し</BUTTON>
	<DIV id="content"></DIV>
	<BUTTON id="redirect_call" onclick="redirect_call()">redirect呼び出し</BUTTON>

	<SCRIPT src="js/start_custom.js"></SCRIPT>
</BODY>
</HTML>
```

簡単に、3つの関数と3つのHTMLの要素を説明しておきます。

+ Javascript関数
 * start_call()：認証を開始して、合言葉の質問を取得します。
 * respond_call()：合言葉の回答を返します。成功すると、トークンを受領します。
 * redirect_call()：受領したトークンといっしょに、前回の投稿で作成した、「サインインしていないと見れないLambda」を呼び出すページにリダイレクトします。

+ HTMLの要素
 * id="username"：どのユーザを認証するかを指定する場所です。
 * id="challenge"：合言葉の質問を表示させる場所です。
 * id="answer"：合言葉の回答を入力する場所です。

見た目は以下の感じです。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/11017163-0d22-2092-a347-28a0916ad7b7.png)

今度は、start_custom.jsです。リージョンは「ap-northeast-1」を前提としています。
UserPoolClientIdは、これから認証しようとしているCognitoユーザープールの「アプリクライアント ID」を指定します。

```javascript
const UserPoolClientId = 'XXXXXXXXXXXXXXXXXXX';
const CognitoUrl = 'https://cognito-idp.ap-northeast-1.amazonaws.com/';

var challenge;
var session;
var tokens;

function do_post_aws(url, body, target) {
	const headers = new Headers( { "Content-type" : "application/x-amz-json-1.1", 'X-Amz-Target': target } );
	return fetch(url, {
		method : 'POST',
		body : JSON.stringify(body),
		headers: headers
	})
	.then((response) => {
		return response.json();
	});
}

function start_call(){

	var content = document.getElementById("username");
	var params = {
		AuthFlow: 'CUSTOM_AUTH',
		ClientId: UserPoolClientId,
		AuthParameters: {
			'USERNAME' : content.value
		}
	};
	
	do_post_aws(CognitoUrl, params, 'AWSCognitoIdentityProviderService.InitiateAuth' )
	.then(data =>{
		console.log(data);
		challenge = data;
		session = data.Session;

		var content = document.getElementById("challenge");
		content.innerText = 'challenge=' + data.ChallengeParameters.challenge;
		content = document.getElementById("content");
		content.innerText = JSON.stringify(data);
	});
}

function respond_call(){
	var content = document.getElementById("answer");
	var params = {
		ChallengeName: 'CUSTOM_CHALLENGE',
		ClientId: UserPoolClientId,
		ChallengeResponses: {
			'USERNAME' : challenge.ChallengeParameters.USERNAME,
			'ANSWER' : content.value
		},
		Session: session,
	};

	do_post_aws(CognitoUrl, params, 'AWSCognitoIdentityProviderService.RespondToAuthChallenge' )
	.then(data =>{
		console.log(data);
		tokens = data;
		session = data.Session;

		var content = document.getElementById("content");
		content.innerText = JSON.stringify(data);
	});
}

function redirect_call(){
	location.href = 'https://localhost:3001/auth.html' + '#access_token=' + tokens.AuthenticationResult.AccessToken + '&id_token=' + tokens.AuthenticationResult.IdToken + '&token_type=' + tokens.AuthenticationResult.TokenType;
}
```

まずは、認証を開始するstart_call()から見ていきます。
注目すべきところは、AWSCognitoIdentityProviderService.InitiateAuthに渡している引数です。

param.AuthFlow = 'CUSTOM_AUTH' としています。こうしないと、せっかく作ったLambda関数：認証チャレンジの定義が呼ばれません。

params.ClientId.UserPoolClientIdでCognitoユーザープールのアプリクライアント IDを指定しており、これで、どのユーザープールのユーザを認証しようとしているのか、どのアプリが認証しようとしているのかが特定されます。

params. ChallengeResponses.USERNAME = content.valueで、どのユーザーを認証しようとしているのかを指定しています。

このInitiateAuth呼び出しが成功すると、data.ChallengeParameters.challengeに合言葉の質問が入ってきます。
また、次にrespondToAuthChallengeで合言葉の回答を返すのですが、同じ人からの応答あることを示すために、セッション情報を覚えておきます。（session = data.Session）

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/286fa979-9b7d-13e6-650c-e63dfde951ff.png)

合言葉の回答を返すrespond_call()を見てみます。
回答は、params.ChallengeResponses.ANSWER に指定しています。
その他パラメータを引数に設定して、AWSCognitoIdentityProviderService.RespondToAuthChallengeを呼び出します。
成功すると、レスポンスにトークン等もろもろの情報を取得することができます。

* tokens.AuthenticationResult.AccessToken：アクセストークンです。
* tokens.AuthenticationResult.IdToken：IDトークンです。
* tokens.AuthenticationResult.TokenType：トークンタイプです。「Bearer」になっています。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/28888c5f-c96f-c762-4c97-90fc886b5573.png)

ここで、ちょっと呼び出し関係を整理しておきます。
結局は、トークンを取得するまで、以下の順番で呼び出されています。

> クライアント側は認証したいユーザ名を入力
> start_call() → InitiateAuth → サーバ側へ
> 認証チャレンジの定義のLambda → 認証チャレンジの作成のLambda → クライアント側へ
> クライアント側は合言葉の質問受領

> クライアント側は合言葉の回答を入力
> respond_call() → RespondToAuthChallenge → サーバ側へ
> 認証チャレンジレスポンスの確認のLambda → 認証チャレンジの定義のLambda → クライアント側へ
> クライアント側はトークン受領

最後に、redirect_call()は何をしているかというと、前回の投稿で「サインインしていないと見れないLambda」を作ったので、それにリダイレクトして呼び出してみることにしました。
リダイレクト先は、「 https://localhost:3001/auth.html 」としています。(環境に合わせてください)
このとき、IDトークン等を引き渡したいのですが、前回ではフラグメント識別子に指定されていました。今回もそれにならって、IDトークン等をリダイレクト先URLのフラグメント識別子(#以降の文字列)で渡すようにしました。

以下、リダイレクト結果です。IDトークンを受け取れているのがわかります。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/c7223d94-99e7-3778-bbaa-552dd0eb7b14.png)

以下、さらに「secret呼び出し」ボタンを押下して、実際に「サインインしないと見れないLambda」を呼び出した結果です。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/261826/684d08c2-bc83-2691-41c1-9e8e62c0956b.png)

以上です。
いかがでしたでしょうか。今回は相手を確認するのに合言葉にしましたが、他にもいろんな本人確認のやり方があると思いますので、これらを参考にしていただければ幸いです。