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

Frohes neues Jahr! ベルリンの伊藤です。

こちらのAWSブログの内容を試してみました:

Implementing passwordless email authentication with Amazon Cognito

パスワードを覚えるのって辛いですよね。「パスワードを忘れた」時によく使う一時的なワンタイムコードの発行、これを通常のログイン方法とする仕組みです。

ブログのサンプルリソースやコードをそのままデプロイするだけだろうと気軽に始めたんですが、いろいろと不慣れでハマり思ったより時間がかかりました...デプロイ部分でおかしなところがあれば容赦なくご指摘ください。

概要

Amazon CognitoユーザプールとLambda関数を使って、ワンタイムコードを発行してサインインします。

Overview of the solution––diagram

  1. ユーザがサインインページでメールアドレスを入力すると、Cognitoユーザプールに送信される。
  2. CognitoユーザプールがDefine Auth Challenge関数を実行。認証チャレンジの動きを決める。
  3. CognitoユーザプールがCreate Auth Challenge関数を実行。秘密のログインコードを生成してAmazon SESでメール送信する。
  4. ユーザがログインコードをメールで受信し、サインインページで入力すると、Cognitoユーザプールに送信される。
  5. CognitoユーザプールがVerify Auth Challenge Response関数を実行。入力したコードを認証する。
  6. CognitoユーザプールがDefine Auth Challenge関数を実行。認証チャレンジが正しく行われ、以降の認証チャレンジが必要でないことを確認する。ユーザプールへのレスポンスに“issueTokens: true”を含め、ユーザプールはユーザが認証されているものと認識し、(ステップ4に対して)有効なJSON Web Tokens(JWTs)を送り返す。

上記ステップの3つの関数は認証チャレンジレスポンスの確認 Lambda トリガーを元に設定されており、ユーザのサインアップにはPre Sign-Up関数が使われ、これはサインアップ前 Lambda トリガーを元に設定されているようです。

サインイン処理を行うWebアプリはカスタムのUIページ(HTMLとJavascript)を使用しており、これらのサンプルリソースとサンプルコードが利用可能です。

今回利用するLambda関数

以下、AWSブログから一部抜粋してまとめています。

Define Auth Challenge trigger

認証フローのDecider機能に該当します。つまり次に何をするかというのを決めます。この関数は、先の図の2と6で登場しており、認証チャレンジの開始時とVerify Auth Challenge Responseの完了時に動きます。

export const handler: CognitoUserPoolTriggerHandler = async event => {
    if (event.request.session &&
        event.request.session.length >= 3 &&
        event.request.session.slice(-1)[0].challengeResult === false) {
        // ユーザの入力コードが3回間違っていた場合(認証失敗)
        event.response.issueTokens = false;
        event.response.failAuthentication = true;
    } else if (event.request.session &&
        event.request.session.length &&
        event.request.session.slice(-1)[0].challengeResult === true) {
        // ユーザの入力コードが正しい場合(認証成功)
        event.response.issueTokens = true;
        event.response.failAuthentication = false;
    } else {
        // それ以外: ユーザの入力コードが正しくなく、3回間違えてない場合 (認証チャレンジ継続)
        event.response.issueTokens = false;
        event.response.failAuthentication = false;
        event.response.challengeName = 'CUSTOM_CHALLENGE';
    }

    return event;
};

Create Auth Challenge trigger

前述のトリガーの指示に従って動き、ユーザにユニークなチャレンジを作成します。前半でログインコードを生成または再利用し、後半で生成したコードをユーザにメール送信しています。以下は前半部分の抜粋です。

export const handler: CognitoUserPoolTriggerHandler = async event => {

    let secretLoginCode: string;
    if (!event.request.session || !event.request.session.length) {

        // 新しいセッションの場合
        // 新しいシークレットログインコードを生成して、メール送信
        secretLoginCode = randomDigits(6).join('');
        await sendEmail(event.request.userAttributes.email, secretLoginCode);

    } else {

        // 既存のセッションの場合
        // 新規にコードは生成せず、既存セッションのコードを再利用
        const previousChallenge = event.request.session.slice(-1)[0];
        secretLoginCode = previousChallenge.challengeMetadata!.match(/CODE-(\d*)/)![1];
    }

    // クライアントアプリに送り返す
    event.response.publicChallengeParameters = {
        email: event.request.userAttributes.email
    };

    // ログインコードをパラメータに追加し、"Verify Auth Challenge Response"トリガーによって認証されるようにする
    event.response.privateChallengeParameters = { secretLoginCode };

    // ログインコードをセッションに追加し、次回の"Create Auth Challenge"トリガーで利用できるようにする
    event.response.challengeMetadata = `CODE-${secretLoginCode}`;

    return event;
};

Verify Auth Challenge trigger

ユーザがコードを入力した時に、ユーザプールから動かされ、入力コードが正しいかどうかを判断します。

export const handler: CognitoUserPoolTriggerHandler = async event => {
    const expectedAnswer = event.request.privateChallengeParameters!.secretLoginCode; 
    if (event.request.challengeAnswer === expectedAnswer) {
        event.response.answerCorrect = true;
    } else {
        event.response.answerCorrect = false;
    }
    return event;
};

作ってみる

Cognito、Lambda のデプロイ

下記のリソースはAWS Serverless Application Repositoryからサンプルをデプロイします。

上記ページから[Deploy]を押して、名前や送信元メールアドレスを指定するだけです。

しばらくすると作成完了します。

作成されたCloudFormationスタックを開き、「出力」を確認するとCognitoのIDが2つ出ており、この情報は後で使います。

メールアドレスの準備

前述のデプロイで指定した認証時のコードの送信元メールアドレスと、送信先(予定)のメールアドレスは事前にSESで検証済みである必要があります。

Amazon SES コンソールで[Verify a New Email Address]からメールアドレスを入力するか下記のようにCLIを実行します。

$ aws ses verify-email-identity --email-address xxxxx@xxxxx.com
$ aws ses verify-email-identity --email-address xxxxx@xxxxx.com

対象のメールアドレスに「Amazon Web Services - Email Address Verification Request in region xxx」といった検証用メールが届くので、リンクをクリックします。これで、SESコンソールのEmail Addressesの一覧に追加されており、Verification Statusがverifiedとなっているはずです。

サインインページのセットアップ

今回はEC2インスタンスでWebサーバを立てました。

コアな手順はサンプルコードのあるGithubのページに載っていますが、各種インストールの方法もいろいろ調べる羽目になったので、備忘録として載せておきます。(ページ内の Pre-requisites 2. は前述のCognito、Lambdaのデプロイが該当します。CLIで作成していく場合にはそちらのページからご参照ください。)

インスタンスの作成・設定

EC2インスタンス(Linux2)を作成します。

※簡単な処理だろうと小さいインスタンスタイプ(t2.micro)で試したところ、後述のnpm run startにおいて恐らくメモリ不足で、コンパイル成功に至らず途中で失敗することになり、やり直すことになりました。

今回のサンプルコードはAngularを使用しているので、作成したらセキュリティグループのインバウンド設定で4200ポートを追加しておきます。

事前準備

真っさらなEC2を立てたので、インスタンスにSSH接続したら、各種アップデートを済ませてから、インストールに使うgitをインストールしています。

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

Pre-requisites 1.である、Node.jsをインストールします。基本的にはこちらのAWSドキュメントを参考にしていますが、下記 nvm install --lts では最新バージョンをインストールしています。

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

バージョンはそれぞれ以下の通りです。

$ node -v
v10.15.0
$ npm -v
6.4.1

Webアプリの実行

改めまして、手順のRun the web appに従い、レポジトリをクーロン、clientディレクトリへ移動、依存パッケージのインストールを行います。Githubのページからファイルの中身を確認できますが、カレントリポジトリの package.json に記述された内容がインストールされます。

$ 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 ファイルに先ほどCloudFormationスタックの出力で確認した情報を入力します。

export const environment = {
  production: false,
  region: 'eu-west-1',  // リージョン
  userPoolId: 'eu-west-1_42RXa0b31',  // ユーザプールID
  userPoolWebClientId: '64fg1r8ab3bpkb4euf4l5ni14r',  // ユーザプールクライアントID
};

実行して、コンパイルに成功していることを確認したら、その状態で次に進みます。

$ npm run start

> cognito-email-auth-client@1.0.0 start /home/ec2-user/amazon-cognito-passwordless-email-auth/client
> ng serve

** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **
                                                                                          
Date: 2019-01-03T14:10:00.117Z
Hash: 11a95bfe597547e8bad3
Time: 27618ms
chunk {main} main.js, main.js.map (main) 70 kB [initial] [rendered]
chunk {polyfills} polyfills.js, polyfills.js.map (polyfills) 223 kB [initial] [rendered]
chunk {runtime} runtime.js, runtime.js.map (runtime) 6.08 kB [entry] [rendered]
chunk {styles} styles.js, styles.js.map (styles) 179 kB [initial] [rendered]
chunk {vendor} vendor.js, vendor.js.map (vendor) 9.65 MB [initial] [rendered]
ℹ 「wdm」: Compiled successfully.

別でターミナルを開いてSSH接続し、ローカルホストで繋いでみます。すると、きちんとhtmlが取得できました。しかし、いざ自分の端末のブラウザでEC2のIPアドレスやDNSを使って繋いでみても、Connection refusedされてしまいます。ターミナルからのcurlも同様です。

$ curl http://localhost:4200/
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>CognitoPasswordless</title>
  <base href="/">

  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">
  <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
  <app-root></app-root>
<script type="text/javascript" src="runtime.js"></script><script type="text/javascript" src="polyfills.js"></script><script type="text/javascript" src="styles.js"></script><script type="text/javascript" src="vendor.js"></script><script type="text/javascript" src="main.js"></script></body>
</html>

$ curl http://ec2-xx-xxx-xxx-xxx.eu-west-1.compute.amazonaws.com:4200
curl: (7) Failed to connect to ec2-xx-xxx-xxx-xxx.eu-west-1.compute.amazonaws.com port 4200: Connection refused

いろいろ調べたところ、提供されているサンプルコードのパッケージの内容はどうやらローカル環境を前提として作成されていたようなので、先ほどのコンパイルをキャンセルして代わりに以下を実行することで進められました。このオプションですべてのホストからlistenし、DNSでのアクセスを許可します。

$ node_modules/@angular/cli/bin/ng serve --host=0.0.0.0 --public-host=ec2-xx-xxx-xxx-xxx.eu-west-1.compute.amazonaws.com

サインアップ&サインイン&ログインコード認証

ようやくここまでたどり着きました。

改めてブラウザで「http://xx.xxx.xxx.xxx:4200/」または「http://ec2-xx-xxx-xxx-xxx.eu-west-1.compute.amazonaws.com:4200」にアクセスします。

1) まず、下記のサインインページが開きます。「Sign Up」をクリック。

2) 適当な名前とSES検証済みのメールアドレスを入力し、「SIGN UP」をクリックすると、コードの入力を求められる画面に遷移します。

3) 一方で入力したメールアドレス宛には、デプロイ時に登録したアドレスからログインコードが届きます。

4) メールで届いたコードを画面に入力すると

5) このような画面に遷移しました。これ以降しばらくは、一度このページを閉じて再度開いてサインインすると、コードが送られることなくこのページに遷移されました。Sign Outしたり異なるブラウザからアクセスすると、サインイン時に再びコードが送られ、4) のコード入力のプロセスを踏む動きとなりました。

Cognitoユーザ一覧を確認すると、作成したユーザが登録されていました。

まとめ

Cognito カスタム認証フローでパスワードなしのログインコード認証が実現できました。こんな方法もあるんだなぁと少しでも導入の参考や仕組みの理解に役立てば嬉しいです。

参考

困ったとき参考にさせていただきました

関連記事

AWS Amplify+Angular6+Cognitoでログインページを作ってみる ~フロントエンド編①~

SPAからCognito User Poolに管理者としてユーザーを登録する 〜Cognito + Lambda + API Gateway + Angular〜