Amazon Cognitoのワンタイムパスワード(TOTP)認証をNode.jsで試してみた

## 概要

Amazon Cognitoのユーザー認証で多要素認証(MFA)を有効にすると、SMSテキストメッセージによる認証ができることは知っていたのですが、時間ベースのワンタイムパスワード(TOTP)にも対応していることは知らなかったので、利用してみました。


Amazon Cognito - TOTP ソフトウェアトークン MFA
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-settings-mfa-totp.html


Node.js実行のイメージ
<img width="819" alt="image" src="https://qiita-image-store.s3.amazonaws.com/0/48549/389c1658-df97-2ec4-7c33-c8240f6aac11.png">

そもそもCognitoとか、多要素認証、TOTPってなに？という方は下記あたりをご参考ください。

### Amazon Cognitoとは

Amazon Cognito とは
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/what-is-amazon-cognito.html

>Amazon Cognito は、ウェブアプリケーションやモバイルアプリケーションの認証、許可、ユーザー管理をサポートしています。 ユーザーは、ユーザー名とパスワードを使用して直接サインインするか、Facebook、Amazon、Google などのサードパーティーを通じてサインインできます。


### 多要素認証(Multi-Factor Authentication)とは

多要素認証 - Wikipedia
https://ja.wikipedia.org/wiki/%E5%A4%9A%E8%A6%81%E7%B4%A0%E8%AA%8D%E8%A8%BC

>多要素認証（たようそにんしょう）は、アクセス権を得るのに必要な本人確認のための要素（証拠）を複数、ユーザーに要求する認証方式である。 必要な要素が二つの場合は、二要素認証や二段階認証とも呼ばれる。


やはりお前らの多要素認証は間違っている
https://dev.classmethod.jp/etc/multi-factor-authentication/

>前述の通り、コンピュータの世界では knowledge factor を使った認証が一般的です。 しかし昨今、パスワードが流出したり、簡単すぎるパスワードを推測されたり、という事故が多発しています。 従って、そのような事故があっても直ちに損害が出ないように、2つの要素を組み合わせて認証を行うシステムがあります。これを2要素認証と呼びます。


今さら聞けない2段階認証の話いろいろ
https://qiita.com/isaoshimizu/items/5ca25efebdc5ecee7d9b

>　ここ数年、2段階認証（2要素認証、Two Factor Authencication）に対応したサービスがどんどん増えてきています。2段階認証は、IDとパスワードだけでなく、ユーザー専用のコード（数値）を入力することでセキュリティを強化するというもの。


### 時間ベースのワンタイムパスワード(Time-based One Time Password)

今すぐできる、Webサイトへの2要素認証導入
https://blog.ohgaki.net/use-2-factor-authentication-with-your-web-sites

>TOTPは名前の通りシードと時間をベースにパスワードを生成します。時間と共にパスワードは変化します。

2要素認証のTOTPとHOTP、どちらがより安全か？
https://blog.ohgaki.net/2fa-totp-hotp-which-is-safer


## 前提

- AWSアカウントがありAmazon Cognitoが利用できる
- Node.jsとnpmがインストールされている


## Amazon Cognitoでユーザープールを作成する

こちらの記事を参考にさせていただき、MFAを有効にしたユーザープールを作成します。

javascriptでCognitoユーザプール認証
https://qiita.com/tamo_breaker/items/2cba901565a4fe9dff1a


### AWS マネジメントコンソールでAmazon Cognitoを開く

Amazon Cognitoを開いたら「ユーザープールの管理」を選択し、「ユーザープールを作成する」ボタンをクリックします。

<img width="1049" alt="スクリーンショット_2018-11-07_11_38_01.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/cc021c93-1770-673f-2317-a7984be7b6a5.png">
<img width="1046" alt="スクリーンショット_2018-11-07_11_39_42.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/9497b14c-4f41-3423-45a8-403e13bbd10c.png">


### ユーザープールをステップに従って作成する

#### プール名を指定する

プール名は任意指定してください。
<img width="1085" alt="スクリーンショット_2018-11-07_11_42_06.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/022f44e4-dc44-8b93-c379-c15955ea8c74.png">


#### サインイン方法を指定する

「ユーザー名」でログイン可能にします。
<img width="1085" alt="スクリーンショット_2018-11-07_11_46_25.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/d70e1281-0916-f7c3-d8b4-3b23ddf33f73.png">


#### 属性を指定する

検証なので、属性指定はなしにしています。
ただし、ユーザープール後に属性の変更ができないので、実運用ではお気をつけください。
<img width="1082" alt="スクリーンショット_2018-11-07_11_44_45.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/c279f078-40cf-4094-e67a-301aef511869.png">


#### パスワードなどのポリシーを指定する

初期設定のままですすめます。
<img width="1086" alt="スクリーンショット_2018-11-07_11_48_17.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/3c9ad492-97d9-d663-2966-24c1a090a6a7.png">


#### 多要素認証を指定する

「必須」を選択します。
第2の要素に「時間ベースのワンタイムパスワード」を指定します。
<img width="1088" alt="スクリーンショット_2018-11-07_11_50_16.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/f3476d8c-1462-2888-8f41-3b8838afb39b.png">


#### Eメールまたは電話番号の検証要求を指定する

今回は検証なしにしました。アラート表示されますが、今回は検証なので気にしません。「SMSメッセージの送信を許可するロール」も作成せずにすすめます。
<img width="1077" alt="スクリーンショット_2018-11-07_11_52_31.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/87102f56-ac85-396f-fe95-a817f83f9e19.png">


#### メッセージをカスタマイズする

今回は、メッセージ送信しないので、初期設定のままですすめます。
<img width="1085" alt="スクリーンショット 2018-11-07 11.54.41.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/cf167a0c-1b85-fc8a-bc62-8595fc92f0b3.png">
<img width="1085" alt="スクリーンショット_2018-11-07_11_55_37.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/a6fef3a1-2944-ebc8-efa2-91ab81938bd1.png">


#### タグ追加、デバイス記憶の設定

両方とも初期設定のままですすめます。
<img width="1088" alt="スクリーンショット_2018-11-07_11_56_10.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/4355ed24-f59d-5819-d407-7d3b0fec315a.png">
<img width="1086" alt="スクリーンショット_2018-11-07_11_56_57.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/7c17be23-9987-3f54-7c77-46e06fc239a1.png">


#### アプリクライアントを追加する

Node.jsを利用して認証を検証するので、アプリクライアントを追加します。
<img width="1088" alt="スクリーンショット_2018-11-07_11_58_20.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/ce20ae2b-4e6a-b4b8-9651-e97abd98020a.png">


アプリクライアントは任意で入力してください。
「クライアントシークレットを生成」は利用しないので、外しておきます。
<img width="1086" alt="スクリーンショット_2018-11-07_11_59_58.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/9a51cde1-40cc-6c90-cf12-a6b36ddbe538.png">
<img width="1087" alt="スクリーンショット_2018-11-07_12_01_51.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/a61707af-3f4d-7f15-3c87-1f881786774e.png">


#### ワークフローのカスタマイズ

トリガーを使用してカスタマイズがいろいろとできますが、カスタマイズせずにすすみます。
<img width="1088" alt="スクリーンショット 2018-11-07 12.02.33.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/503efd43-354b-b2d6-00de-e8ad7c6b7527.png">
<img width="1089" alt="スクリーンショット_2018-11-07_12_02_45.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/a563267c-52bc-b78f-f574-fd8cedade031.png">


#### 設定確認して作成

ステップの最後に設定確認が表示されます。下の方に「プールの作成」ボタンがありますので、それをクリックしたら作成完了です。
<img width="1088" alt="スクリーンショット 2018-11-07 12.08.09.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/6a3ee842-b94b-0fb7-57eb-274fd024626b.png">
<img width="1086" alt="スクリーンショット_2018-11-07_12_08_31.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/8e196a4e-22ec-b6cf-8bdc-4ad7601f26ff.png">


作成完了すると、「プールID」や「プールARN」が発行されます。「プールID」はのちほど利用します。
<img width="1086" alt="スクリーンショット_2018-11-07_12_09_54.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/967efa0e-3286-f6b8-9ab4-aaa90c574c76.png">


## Node.jsで実装する

Node.jsによる実装もこちらの記事で紹介されている実装をベースにさせてもらいました。

javascriptでCognitoユーザプール認証
https://qiita.com/tamo_breaker/items/2cba901565a4fe9dff1a

実装はGitHubにアップしていますので、よろしければご参考ください。
https://github.com/kai-kou/use-cognito-totp-mfa


### 環境設定とライブラリのインストール

```sh
> node -v
v10.11.0
> npm -v
6.4.1

> mkdir 任意のディレクトリ
> cd 任意のディレクトリ

> npm init
> npm install --save node-fetch
> npm install --save amazon-cognito-identity-js
> npm install --save prompt
> npm install --save qrcode-terminal
```

CognitoへのアクセスにAWS JavaScript SDK(amazon-cognito-identity-js)を利用します。ユーザー名、パスワード、ワンタイムパスワードを入力するのにはprompt、QRコードを生成・表示するのにqrcode-terminalを利用しています。

amazon-archives/amazon-cognito-identity-js
https://github.com/amazon-archives/amazon-cognito-identity-js

flatiron/prompt
https://github.com/flatiron/prompt

gtanner/qrcode-terminal
https://github.com/gtanner/qrcode-terminal


### 実装

```sh
> touch config.js
> touch mfa-auth.js
```

#### 設定ファイル

CognitoのユーザープールIDとアプリクライアントのIDを設定します。

```js:config.js
module.exports =  {
  UserPoolId: '[CognitoのユーザープールID]',
  ClientId: '[アプリクライアントのID]'
}
```

ユーザープールIDはAWS マネジメントコンソールのCognitoページでユーザープール選択→「全般設定」から確認できます。
<img width="1088" alt="スクリーンショット_2018-11-07_12_52_01.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/2475a338-ff5b-670d-9cb0-7bd7467fc174.png">


アプリクライアントのIDはユーザープール選択→「全般設定」→「アプリクライアント」から確認できます。
<img width="1087" alt="スクリーンショット_2018-11-07_12_54_10.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/8018e51a-c7e2-c209-8437-3a963f6d5ab7.png">


#### 認証部分の実装

基本的には参考にさせていただいた[記事](https://qiita.com/tamo_breaker/items/2cba901565a4fe9dff1a#3-cognito%E8%AA%8D%E8%A8%BC%E3%81%99%E3%82%8B%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88%E5%AE%9F%E8%A3%85)をベースにして、```cognitoUser.authenticateUser``` にMFA時に発生するイベントを追加して、認証成功時にトークンが取得できるところまでを実装しています。

MFAに関する実装には公式ドキュメントを参考にしました。

Amazon Cognito - 例: JavaScript SDK の使用
「MFA メソッドを選択し、TOTP MFA を使用して認証する」
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/using-amazon-cognito-user-identity-pools-javascript-examples.html

```js:mfa-auth.js
global.fetch = require('node-fetch')
const AmazonCognitoIdentity = require('amazon-cognito-identity-js');
const prompt = require('prompt');
const qrcode = require('qrcode-terminal');

const config = require('./config');


login = function () {
    prompt.start();
    let prompt_schema = {
      properties: {
        username: { required: true },
        password: { hidden: true }
      }
    };
    prompt.get(prompt_schema, function (err, result) {
        let username = result['username'];
        let password = result['password'];

        // ユーザープールID、アプリクライアントIDの設定
        let poolData = {
            UserPoolId: config.UserPoolId,
            ClientId: config.ClientId
        };

        // ユーザーの設定
        let userData = {
            Username: username,
            Pool: new AmazonCognitoIdentity.CognitoUserPool(poolData)
        };
        let cognitoUser = new AmazonCognitoIdentity.CognitoUser(userData);

        // 認証情報の設定
        let authenticationData = {
            Username: username,
            Password: password
        };
        let authenticationDetails = new AmazonCognitoIdentity.AuthenticationDetails(authenticationData);

        // Cognitoに認証を要求
        cognitoUser.authenticateUser(authenticationDetails, {
            onSuccess: function (result) {
                // 認証成功時に発生します
                console.log('onSuccess');
                console.log('認証成功時に発生します');
                let idToken = result.getIdToken().getJwtToken();
                console.log('トークン取得できました^^');
                console.log(idToken);
            },
            newPasswordRequired: function (userAttributes, requiredAttributes) {
                // ユーザー作成後、初回ログイン時にパスワード変更が必要になる
                // とりあえず、仮パスワードをそのまま設定しています
                console.log('ユーザー作成後、初回ログイン時にパスワード変更が必要');
                cognitoUser.completeNewPasswordChallenge(password, {}, this);
            },
            mfaSetup: function (challengeName, challengeParameters) {
                // ユーザープールでMFAが有効化されていると発生します
                console.log('mfaSetup');
                console.log('ユーザープールでMFAが有効化されていると発生します');
                cognitoUser.associateSoftwareToken(this);
            },
            associateSecretCode: function (secretCode) {
                // MFA有効化されていてTOTP初回認証時に発生します
                // SecretCodeが発行されるので、Google Authenticatorなどに登録できるよう
                // QRコードを生成します。(シークレットコードを手動入力するのもあり)
                console.log('associateSecretCode');
                console.log('MFA有効化されていてTOTP初回認証時に発生します');

                // QRコードを生成してターミナルに表示
                let url = 'otpauth://totp/Test?secret=' + secretCode + '&issuer=Cognito-TOTP-MFA';
                console.log('シークレットコード: ' + secretCode);
                qrcode.generate(url, {small: true});

                let _this = this;
                let getValue = 'Google AuthenticatorでQRコードを読み取り、ワンタイムパスワードを入力してください';
                prompt.get([getValue], function (err, result) {
                    challengeAnswer = result[getValue];
                    cognitoUser.verifySoftwareToken(result[getValue], 'My TOTP device', _this);
                    console.log('2回目からはQRコードの読み取りは不要です');
                });
            },
            totpRequired: function (secretCode) {
                // ワンタイムパスワード要求時に発生
                // Google Authenticatorなどからワンタイムパスワードを入力して認証します
                console.log('totpRequired');
                console.log('ワンタイムパスワード要求時に発生');

                let _this = this
                let getValue = 'Google Authenticatorのワンタイムパスワードを入力してください';
                prompt.get([getValue], function (err, result) {
                    var challengeAnswer = result[getValue];
                    cognitoUser.sendMFACode(challengeAnswer, _this, 'SOFTWARE_TOKEN_MFA');
                });
            },
            onFailure: function (err) {
                console.log('onFailure');
                console.log('認証失敗時に発生します');
                console.log(err);
            }
        });
    });
};

login();
```

#### ```cognitoUser.authenticateUser``` メソッドのイベント

メソッドを実行すると、ユーザー名とパスワードによる認証から始まり、必要に応じてイベント？が発生する流れになります。

- ```newPasswordRequired``` : 初回ログイン時のパスワード変更要求
- ```mfaSetup``` : MFA有効時に発生
- ```associateSecretCode``` : TOTP認証の初回時に発生
- ```totpRequired``` : ワンタイムパスワード要求時に発生
- ```onFailure```　: 認証失敗時に発生

他にも```selectMFAType``` というのがありますが、これはSMSテキストメッセージによる認証やTOTP認証など要素が複数ある場合に、発生するみたいですが、今回はTOTP認証のみなので、割愛しています。

#### TOTP対応アプリ用にQRコード生成

QRコード生成に必要となるURIフォーマットについては下記を参考にさせてもらいました。

二段階認証(TOTP)メモ
https://qiita.com/xylitol45@github/items/4f8418554a6550189341


```Test``` 、```Cognito-TOTP-MFA``` は任意で設定可能です。

```js:mfa-auth.js(抜粋)
// QRコードを生成してターミナルに表示
let url = 'otpauth://totp/Test?secret=' + secretCode + '&issuer=Cognito-TOTP-MFA';
```

#### prompt利用時の注意点

イベント処理に```prompt.get``` を利用する場合、```cognitoUser``` のメソッドに```this``` パラメータを直接渡すと、```UnhandledPromiseRejectionWarning: TypeError: callback.onFailure is not a function``` とエラーが発生するので、ご注意ください。~~非同期怖いです。~~

```js:mfa-auth.js(抜粋)
let _this = this;
let getValue = 'Google AuthenticatorでQRコードを読み取り、ワンタイムパスワードを入力してください';
prompt.get([getValue], function (err, result) {
    challengeAnswer = result[getValue];
    // thisを直接渡すとエラーになる
    cognitoUser.verifySoftwareToken(result[getValue], 'My TOTP device', _this);
    console.log('2回目からはQRコードの読み取りは不要です');
});
```


## ユーザー登録と認証

Cognitoのユーザープールにユーザーを登録して、実際に認証してみます。
~~今回は手抜きで~~ユーザー登録はAWS マネジメントコンソールから行います。

ユーザープールを選択して、「ユーザーとグループ」から「ユーザーの作成」ボタンをクリックします。
<img width="1083" alt="スクリーンショット_2018-11-07_13_22_30.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/916450a2-349d-b0c0-d56b-96a41cc8143a.png">


- ユーザー名: 必須
- この新規ユーザーに招待を送信しますか？: チェックを外す
- 仮パスワード: 必須
- 電話番号: 空
- 電話番号を検証済みにしますか？: チェックを外す
- E メール: 空
- E メールを検証済みにしますか？: チェックを外す

<img width="1087" alt="スクリーンショット_2018-11-07_13_24_05.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/592dbeb7-a057-2ab3-b1d6-ca193af90b35.png">
<img width="1088" alt="スクリーンショット 2018-11-07 13.26.50.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/77d033e0-7485-f353-cd86-ce16313283ef.png">


ユーザー作成できたら、Node.jsを実行してみます。
```config.js``` に各IDの設定をお忘れなく(1敗

```sh
> node mfa-auth.js
```

先程登録したユーザ名とパスワードを入力します。
すると、シークレットコードとQRコードが出力されますので、TOTP認証に対応したスマホアプリで読み取ります。
<img width="720" alt="スクリーンショット_2018-11-07_13_31_02.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/f738948c-eb72-a86e-d1c3-e8b8ff6ad2f5.png">


TOTP認証に対応した認証アプリには「Google Authenticator」などがあります。

App Store - iTunes - Apple
https://itunes.apple.com/jp/app/google-authenticator/id388497605?mt=8

Google Play
https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=ja

QRコードを読み取り登録できると、ワンタイムパスワードが確認できるようになりますので、それを入力します。
<img width="200" alt="スクリーンショット_2018-11-07_13_31_02.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/3b4d8031-2809-a919-abad-c995fcf27acd.png">


ワンタイムパスワードが正しければ、無事に認証完了し、トークンを取得することができます。
<img width="825" alt="スクリーンショット_2018-11-07_13_43_30.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/90949b8c-363c-0c1f-b7be-dc8f005322e2.png">


ワンタイムパスワードの認証成功すると、次回からは```associateSecretCode``` イベントが発生せずにワンタイムパスワードの要求がされます。
<img width="821" alt="スクリーンショット_2018-11-07_13_46_21.png" src="https://qiita-image-store.s3.amazonaws.com/0/48549/818ea23d-ac46-fbfb-8010-ab38b645c695.png">

やったぜ。


## まとめ

多要素認証(MFA)の実装はややこしくて、大変そうなイメージがありましたが、Amazon Cognitoを利用すると比較的簡単に実装することができました。
ユーザープールの設定で、ユーザーごとに任意選択させることや、SMSテキストメッセージ認証を追加することもできるので、アプリクライアントに対して、よりセキュアな認証を実装することができますね^^


## 参考

Amazon Cognito - TOTP ソフトウェアトークン MFA
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-settings-mfa-totp.html

Amazon Cognito とは
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/what-is-amazon-cognito.html

多要素認証 - Wikipedia
https://ja.wikipedia.org/wiki/%E5%A4%9A%E8%A6%81%E7%B4%A0%E8%AA%8D%E8%A8%BC

やはりお前らの多要素認証は間違っている
https://dev.classmethod.jp/etc/multi-factor-authentication/

今さら聞けない2段階認証の話いろいろ
https://qiita.com/isaoshimizu/items/5ca25efebdc5ecee7d9b

今すぐできる、Webサイトへの2要素認証導入
https://blog.ohgaki.net/use-2-factor-authentication-with-your-web-sites

2要素認証のTOTPとHOTP、どちらがより安全か？
https://blog.ohgaki.net/2fa-totp-hotp-which-is-safer

javascriptでCognitoユーザプール認証
https://qiita.com/tamo_breaker/items/2cba901565a4fe9dff1a

Amazon Cognito - 例: JavaScript SDK の使用
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/using-amazon-cognito-user-identity-pools-javascript-examples.html

amazon-archives/amazon-cognito-identity-js
https://github.com/amazon-archives/amazon-cognito-identity-js

flatiron/prompt
https://github.com/flatiron/prompt

gtanner/qrcode-terminal
https://github.com/gtanner/qrcode-terminal

二段階認証(TOTP)メモ
https://qiita.com/xylitol45@github/items/4f8418554a6550189341

App Store - iTunes - Apple
https://itunes.apple.com/jp/app/google-authenticator/id388497605?mt=8

Google Play
https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=ja