BFT名古屋 TECH BLOG

日々の業務で得た知識を所属するエンジニアたちがアウトプットしていきます。

【AWS】Cognitoのカスタム認証チャレンジを用いたSMS認証を実装する方法

はじめに

こんにちは!BFT名古屋支店のマッチです。今回はCognitoのカスタム認証チャレンジを使ってワンタイムパスワードを発行し、SMSで通知してサインインする仕組みを実装してみたいと思います。

幸いにも、同じような仕組みをメール認証で実装されている方がおりましたので、こちらのブログ内容をベースに改修する形で実装することとしました。

https://dev.classmethod.jp/articles/cognito-lambda-passwordless/#toc-10


構成概要

今回構築する環境の構成図は以下のようになります。

①ユーザーがサインインページで電話番号を入力する。
②認証チャレンジ定義Lambdaが実行され、認証フローが決定される。
③認証チャレンジ作成Lambdaが実行され、ワンタイムパスワード(OTP)を発行する。
④~⑤Amazon SNSが呼び出され、SMSが送信される。
⑥ユーザーが受信したOTPをサインインページに入力する。
⑦認証チャレンジ検証Lambdaが実行され、入力されたOTPが正しいか判定する。
⑧認証チャレンジ定義Lambdaが実行され、判定結果が正しければ認証成功し、トークンが発行される。
⑨認証成功後に、認証後Lambdaが実行され、ユーザー属性を更新する(電話番号を検証済みとする)。


作ってみた

リソースのデプロイ

AWS Serverless Application Repositoryから、CognitoユーザープールおよびLambda関数をデプロイします。 ただし、Cognitoユーザープールについてはここで作成されるものは使用せず、後ほど新しく作成するため、デプロイ後必要に応じて削除いただいて構いません。

  1. 以下にアクセスし、[Deploy]をクリックします。
    Application Search - AWS Serverless Application Repository

  2. 送信元メールアドレスとユーザープール名を入力し(SESおよびユーザープールは使用しないため適当で構いません)、[デプロイ]をクリックします。

  3. デプロイ履歴から[Create complete]と表示されることを確認します。

Cognitoユーザープールの作成

先程作成したユーザープールはメール認証用のもので、サインインオプションにメールアドレスが指定されているため、SMS認証用に電話番号を指定したユーザープールを新たに作成します。

  1. AWSマネジメントコンソールから[Amazon Cognito]の[ユーザープール]に移動し、[ユーザープールを作成]をクリックします。

  2. 画面に従い、各項目を設定していきます。ただし、次の項目については必ず以下のように設定してください。
    Cognito ユーザープールのサインインオプション:電話番号にのみチェックを入れる
    パスワードポリシー:最小文字数8文字、パスワード要件のチェックはすべて外す
    多要素認証:MFAなし
    必須の属性:phone_numberのみ
    認証フロー:ALLOW_CUSTOM_AUTH

  3. 設定が終わったら、[確認および作成]画面で[ユーザープールを作成]をクリックします。

  4. 作成したユーザープールを選択し、[ユーザープールのプロパティ]から[Lambdaトリガーの追加]をクリックし、以下のようにLambdaトリガーを設定します。
     サインアップ前Lambdaトリガー:PreSignUp
     認証チャレンジレスポンスを確認Lambdaトリガー:VerifyAuthChallengeResponse
     認証チャレンジを作成Lambdaトリガー:CreateAuthChallenge
     認証チャレンジを定義Lambdaトリガー:DefineAuthChallenge
     認証後Lambdaトリガー:PostAuthentication

  5. 以下からユーザープールIDとユーザープールクライアントIDを確認します。この2つは後ほど使うので控えておきます。
    ユーザープールID:ユーザープールの概要
    ユーザープールクライアントID:[アプリケーションの統合]-[アプリケーションクライアントのリスト]-[クライアントID]

Lambdaの改修

以下2つのLambda関数のコードとアクセス権限をSMS認証用に改修します。

Create Auth Challenge (認証チャレンジ作成Lambda )

create-auth-challenge.jsを以下のように改修します。元のコードからの主な変更点としてはEメールの送信を行う部分をSMSの送信に置き換えています。

"use strict";
// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: MIT-0
Object.defineProperty(exports, "__esModule", { value: true });
const crypto_secure_random_digit_1 = require("crypto-secure-random-digit");
const aws_sdk_1 = require("aws-sdk");
const sns = new aws_sdk_1.SNS();
exports.handler = async (event) => {
    let secretLoginCode;
    if (!event.request.session || !event.request.session.length) {
        // This is a new auth session
        // Generate a new secret login code and mail it to the user
        console.log(event.request.session);
        secretLoginCode = crypto_secure_random_digit_1.randomDigits(6).join('');
        await sendSMSviaSNS(event.request.userAttributes.phone_number, secretLoginCode);
    }
    else {
        // There's an existing session. Don't generate new digits but
        // re-use the code from the current session. This allows the user to
        // make a mistake when keying in the code and to then retry, rather
        // then needing to e-mail the user an all new code again.
        console.log(event.request.session);
        const previousChallenge = event.request.session.slice(-1)[0];
        secretLoginCode = previousChallenge.challengeMetadata.match(/CODE-(\d*)/)[1];
    }
    // This is sent back to the client app
    event.response.publicChallengeParameters = { phone: event.request.userAttributes.phone_number };
    // Add the secret login code to the private challenge parameters
    // so it can be verified by the "Verify Auth Challenge Response" trigger
    event.response.privateChallengeParameters = { secretLoginCode };
    // Add the secret login code to the session so it is available
    // in a next invocation of the "Create Auth Challenge" trigger
    event.response.challengeMetadata = `CODE-${secretLoginCode}`;
    return event;
};
async function sendSMSviaSNS(phoneNumber, secretLoginCode) {
    const params = { "Message": "Your secret code: " + secretLoginCode, "PhoneNumber": phoneNumber };
    await sns.publish(params).promise();
}

また、SNSへのアクセス権を付与するため、[設定]-[アクセス権限]から以下のIAMポリシーを追加します。
Allow: sns:Publish

PostAuthentication (認証後Lambda)

post-authentication.jsを以下のように改修します。元のコードではメールアドレスの属性を更新していましたが、今回は電話番号を使うので電話番号の更新に変更しています。

"use strict";
// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: MIT-0
Object.defineProperty(exports, "__esModule", { value: true });
const aws_sdk_1 = require("aws-sdk");
const cup = new aws_sdk_1.CognitoIdentityServiceProvider();
exports.handler = async (event) => {
    if (event.request.userAttributes.phone_number_verified !== 'true') {
        const params = {
            UserPoolId: event.userPoolId,
            UserAttributes: [{
                    Name: 'phone_number_verified',
                    Value: 'true',
                }],
            Username: event.userName,
        };
        await cup.adminUpdateUserAttributes(params).promise();
    }
    return event;
};

また、ユーザー属性のアップデート権限を付与する対象が始めに作成されたユーザープールのままになっているため、[設定]-[アクセス権限]から以下のIAMポリシーのリソースを新規作成したユーザープールのarnに変更します。
Allow: cognito-idp:AdminUpdateUserAttributes

クライアントサーバの構築

今回はEC2のインスタンスを作成し、以下GitHubからWebサーバを構築します。
https://github.com/aws-samples/amazon-cognito-passwordless-email-auth/tree/master/client

EC2インスタンスの作成
  1. AWSマネジメントコンソールから[EC2]-[インスタンス]に移動し、[インスタンスを起動]をクリックします。

  2. 画面に従い、各項目を設定していきます。今回は以下のような設定としました。
     AMI:Amazon Linux 2
     インスタンスタイプ:t3.small
     セキュリティグループ:インバウンド設定で4200ポートを追加(Angularを使用するため)

  3. 設定が終わったら[インスタンスを起動]をクリックし、インスタンスを作成します。

Webサーバの構築

作成したインスタンスSSH接続し、アップデートを行った後、gitをインストールします。

$ sudo yum update -y
$ sudo yum install -y git

Node.jsをインストールします。

$ curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.32.0/install.sh | bash
$ . ~/.nvm/nvm.sh
$ nvm install --lts

インストール後、バージョンを確認します。

$ node -v
$ npm -v

レポジトリをクローンし、依存パッケージをインストールします。

$ git clone https://github.com/aws-samples/amazon-cognito-passwordless-email-auth.git
$ cd amazon-cognito-passwordless-email-auth/client
$ npm install

src/environments/environment.tsファイルに先程確認したユーザープールIDとユーザープールクライアントIDを入力します。

environment.ts

export const environment = {
  production: false,
  region: 'ap-northeast-1',  // リージョン
  userPoolId: 'ap-northeast-1_XXXXXXXXX',  // ユーザプールID
  userPoolWebClientId: 'XXXXXXXXXXXXXXXX',  // ユーザプールクライアントID
};

アプリをビルドし、起動します。

node_modules/@angular/cli/bin/ng serve --host=0.0.0.0 --public-host=<ホスト名>

電話番号の登録

Amazon SNSサンドボックスを解除していない場合にはSMSメッセージを送るために以下手順で電話番号を登録する必要があります。

  1. AWSマネジメントコンソールから[Amazon SNS]-[テキストメッセージング(SMS)]に移動し、[サンドボックス送信先電話番号]の[電話番号を追加]をクリックします。

  2. 送信先の電話番号を入力し、[電話番号を追加]をクリックします。


動作確認

環境構築ができたため、実際にSMSメッセージが送られるか検証します。なお、サインインページのUIを変更していないので画面表示はメール認証のままとなっていますが、今回はCognitoの検証なので一旦このままとします。また機会があればページUI等もいじってみたいと思います。

初回サインアップ時の動き

初回サインアップ時(Cognitoユーザープールに登録されていないユーザーを認証するとき)は以下の手順でサインインします。

  1. ブラウザで「http://<クライアントサーバのIPアドレス>:4200/」にアクセスします。

  2. 以下のページで[Sign up]をクリックします。

  3. 電話番号を入力し、[SIGN UP]をクリックします(入力欄はE-mailとなっていますが電話番号を入力します)。

  4. スマホワンタイムパスワードが届きます。

  5. サインイン画面にワンタイムパスワードを入力し、[CONTINUE]をクリックします。

  6. 以下のようにユーザー情報が表示されれば認証成功です。

2回目以降のサインイン時の動き

2回目以降のサインイン時(すでにCognitoユーザープールに登録済みのユーザーを認証するとき)は以下の手順でサインインします。

  1. ブラウザで「http://<クライアントサーバのIPアドレス>:4200/」にアクセスします。

  2. 以下のページで電話番号を入力し、[SEND SIGN-IN CODE]をクリックします。

  3. スマホワンタイムパスワードが届くので、以降は初回サインアップ時と同じ手順で認証を進めていきます。


まとめ

今回は既存のコードを改修することでCognitoのカスタム認証チャレンジでSMS認証の仕組みを実装することができました。今回の手順はIVR認証など他の認証方法を使用する際にも応用ができそうなので、また機会があればいろいろと試してみたいと思っています。


参考サイト

CognitoとLambdaを使ったパスワードなしメール認証を試してみた | DevelopersIO