Cognitoでパスワードを入力せず、QRコードでログインをする

# この記事の目的

AWSのAmplify UIを使うと、サインインが必要なWebページを簡単に作ることができます。

![auth-login.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/a721fb84-3508-08ad-0491-d66f4f017450.png)

ですが、ユーザー名とパスワードを画面に入力させたくないときもあります。
たとえば以下のような場合です。

- 不特定多数のユーザーをCognitoで制限したページに呼びたい
- ユーザー名とパスワードをチャットやメールの履歴に残したくない
- 明日以降のアクセスは制限したい、一度だけアクセスしてほしい
- iPhoneでサインインしたいが、キーボードでパスワードを打つのが面倒

そこで、今回の記事では以下のような構成を作ります。

![auth-login-qrcode.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/4b26456a-5f50-66b8-df86-f6a0ce62a97f.png)

実際に作成したものを動かすと、以下のようになります。

![無題の動画 ‐ Clipchampで作成 (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/21c784ca-9b40-072d-9d45-09f62b04abfc.gif)

QRコードのURLに一時的に有効なトークンが書き込まれていて、トークンがユーザー名とパスワードの代わりになります。トークンの有効期限は好きな時間に設定することができます。

もし有効期限の切れたQRコードや、改ざんされたQRコードを読み込んだときは、従来通りのAmplify UIのサインイン画面が表示されます。

![無題の動画 ‐ Clipchampで作成.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/e4f78fd2-0a65-2a4e-3561-83b093cf139e.gif)

この記事では、この仕組みの実装方法を説明していきます。

# 実装する

## 事前準備

あらかじめ、AmplifyでCognitoの環境を作っておきます。
リフレッシュトークンのデフォルト（30日間）は長すぎるため、1日に設定しておきます。また、Cognitoのユーザープールにはユーザーを1人登録しておきます。

どういった設定にしてもいいのですが、今回は例として、以下の名前とパスワードでユーザーを作りました。

|項目|設定する値|
|:--|:--|
|ユーザー名|autosignin|
|パスワード|Pa!sswd8765|

今回の仕組みではLambdaの中で検証をします。
今回は例として、Lambdaに持たせるパスワード、認証の有効期限を以下の値にします。

|項目|設定する値|
|:--|:--|
|パスワード|596573a5-699d-2693-08e3-c690d3b58467|
|トークンの有効期限|30秒|

## 実装の内容について

Amplify UI（`@aws-amplify/ui-react`）のAuthenticatorは、サインインボタンを押したときに、Amplify（`aws-amplify/auth`）のsignInを実行する部品です。

以下のようにsignInを呼び出すと、Amplify UIのログイン画面を開いて、ユーザー名に「autosignin」、パスワードに「Pa!sswd8765」を入力して、サインインボタンを押したときと同じ動きをします。

```typescript
import { signIn } from "aws-amplify/auth";

const result = await signIn({
    // ユーザー名
    username: "autosignin",
    // パスワード
    password: "Pa!sswd8765",
    // ログイン処理の方法
    options: {
      authFlowType: "USER_SRP_AUTH",
      // クライアントメタデータ（※設定した値はLambdaにそのまま渡される）
      clientMetadata: {}
    },
});
```

signInはAPIの実行だけではなく、localStorageへのキャッシュの書き込みもしています。signInを呼び出した後でAuthenticatorの画面部品を呼び出すと、サインイン画面をスキップして、サインイン後の画面に遷移します。

また、signInの引数に設定できるclientMetadataは、Cognitoの認証前トリガーに設定したLambdaへとそのまま渡されます。

```python: 認証前トリガーのLambda（python）
import json

def lambda_handler(event, context):
    # validationDataにクライアントメタデータが入っている
    client_metadata = event["request"]["validationData"]
    
    # clientMetadataに設定したdictがそのまま入る
    # 値は自由に設定できる
    print(client_metadata)
    
    if False:
        # もしLambdaが何かの例外を投げると認証エラーになる
        # 画面側のsignInも例外を投げて、サインインできない
        raise Exception("Auth Error")
        
    # 認証成功の時は、受け取ったeventをそのまま返却する
    # 画面側のsignInも成功する
    return event
```

クライアントメタデータを認証前トリガーの中で検証して、不正があれば（※パスワードとユーザー名が合っていても）認証を失敗させることができます。

実装は下のような流れになります。

![logint-low.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/3073f519-a53f-6f8a-0353-940679050abe.png)

## 実装手順1. 画面側の実装

URLについた一時的な認証情報から、自動でサインインするための画面部品を作成します。

以下のようなクエリのついたURLを受け取った時に、サインイン画面を出さずにサインイン処理をします。

```bash: Webアプリが受け取るURL
https://xxxxxxxxx?auth=true&dt=xxxxx&salt=xxxxxx&hash=xxxxxx
```

画面部品のソースコードは以下の通りです。

```tsx: URLParameterAuthenticate.tsx
import { signIn } from "aws-amplify/auth";

interface Props {
  // ハッシュ
  hash: string;
  // ソルト
  salt: string;
  // タイムスタンプ
  dt: string;
  // ログイン完了後のリダイレクト先
  callback: string;
}

/**
 * URLのクエリパラメータから認証情報を受け取る
 */
export function URLParameterAuthenticateRequest(): Props | undefined {
  // クエリURLに認証情報がないのならundefinedを返す
  if (!location.href.includes("?auth=true")) {
    return undefined;
  }
  // クエリをパースする
  const search = new URLSearchParams(location.search);
  const dt = search.get("dt") ?? "";
  const salt = search.get("salt") ?? "";
  const hash = search.get("hash") ?? "";
  // クエリURLの認証情報が不完全ならundefinedを返す
  if (dt.length < 1 || salt.length < 1 || hash.length < 1) {
    return undefined;
  }
  // 認証情報のリクエストを返す
  return {
    hash: hash,
    salt: salt,
    dt: dt,
    callback: "/",
  };
}

/**
 * 認証画面を表示する
 */
export default function URLParameterAuthenticate(props: Props) {
  const awaitable = async () => {
    try {
      // 未ログインならログインする
      await signIn({
        /** あらかじめCognitoに登録したユーザーの情報 */
        username: "autosignin",
        password: "Pa!sswd8765",
        /** Lambdaのフックに送る情報を設定する */
        options: {
          authFlowType: "USER_SRP_AUTH",
          clientMetadata: {
            hash: props.hash,
            salt: props.salt,
            dt: props.dt,
          },
        },
      });
    } catch (e) {
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      //@ts-ignore
      if (e.name === "UserLambdaValidationException") {
        // ログインに失敗したのなら、ログに出力する
        console.log("Lambdaが例外を投げました");
      }
    }
  };
  awaitable().then(() => {
    // ログインの成功、失敗を問わず、callback先にリダイレクトする
    location.href = props.callback;
  });

  return <>認証中...</>;
}
```

公式ドキュメントにあるAuthenticatorのサンプル通りの実装に、先ほど作った部品の呼び出しを書き加えます。
https://ui.docs.amplify.aws/react/connected-components/authenticator

```diff_tsx: App.tsx
import React from 'react';
import { Amplify } from 'aws-amplify';

import { Authenticator } from '@aws-amplify/ui-react';
import '@aws-amplify/ui-react/styles.css';

import awsExports from './aws-exports';
Amplify.configure(awsExports);

+ import URLParameterAuthenticate, {
+  URLParameterAuthenticateRequest,
+ } from "./URLParameterAuthenticate";

export default function App() {
+ const request = URLParameterAuthenticateRequest();

  return (
    <>
+      {request !== undefined && (
+        <URLParameterAuthenticate
+          dt={request.dt}
+          salt={request.salt}
+          hash={request.hash}
+          callback={request.callback}
+        />
+      )}
+      {request === undefined && (
          <Authenticator>
            {({ signOut, user }) => (
              /** ログイン後に表示したいWebページ */
            )}
          </Authenticator>
+       )}
    </>
  );
}
```

ここまで実装してから、`npm run dev`でローカル実行をして、`http://localhost:5173?auth=true&dt=xxxxx&salt=xxxxxx&hash=xxxxxx`のようなURLでリクエストをすると、ログイン画面が出ずに認証されます。

このままだとノーガードになりますから、Cognito側も実装をして、不正なリクエストを弾くようにします。

## 実装手順2. Cognitoの実装

Cognitoのユーザープールを開いて、ユーザープールのプロパティを設定、認証前Lambdaトリガーを追加します。

![userpool-preauth.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/f1af2f51-1a4d-7333-98b4-45fba6a447f2.png)

このLambda（authenticate-handler）のapp.pyは以下のように実装します。
※標準ライブラリだけで動くので、requests.txtは不要です。

```python:app.py
import json
import hashlib
import secrets
import datetime
from dataclasses import dataclass


# トークンの有効期限
EXPIRES_SECONDS = 30

# パスワード 任意の文字列（※設定した値は公開しないでください）
PASSWORD = "596573a5-699d-2693-08e3-c690d3b58467"


@dataclass
class Parameter:
    salt: str
    dt: str
    hash: str

    @staticmethod
    def keyphrase(salt: str, dt: int):
        """
        タイムスタンプ、ソルトに改ざんのないことを確認するために、ハッシュを生成する
        """
        return hashlib.sha256(f"{salt}:{dt}:{PASSWORD}".encode("utf-8")).hexdigest()

    @property
    def check(self):
        """
        受け取ったタイムスタンプ、ソルト、ハッシュを検証する
        """
        # 現在時刻を取得する
        now = int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())
        # 受信したタイムスタンプを取得する
        received_timestamp = int(self.dt)

        # ハッシュ値を検証、改ざんのないことを確認する
        if Parameter.keyphrase(self.salt, received_timestamp) != self.hash:
            raise Exception("Invalid token")
        # 有効期限を検証する
        if (now - received_timestamp) > EXPIRES_SECONDS:
            raise Exception("Token expired")
        return True

    @staticmethod
    def generate():
        ts = int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())
        salt = secrets.token_hex(16)
        # ハッシュ、タイムスタンプ、ソルトを返す
        return Parameter(salt, str(ts), Parameter.keyphrase(salt, ts))


def lambda_handler(event, context):
    client_metadata = event["request"]["validationData"]
    Parameter(**client_metadata).check
    return event
```

URLを発行するときは、`app.py`のgenerateを呼び出します。

```python: app.py
p = Parameter.generate()
print(f"https://{実装手順1で作ったWebサイトのURL}?auth=true&salt={p.salt}&dt={p.dt}&hash={p.hash}")
```

発行されたURLを開くと、URLの発行から30秒の間だけはサインイン画面をスキップして遷移します。
30秒が経って期限切れになるとURLは無効になります。

また、サインインしたあと、リフレッシュトークンの有効期限が切れるまではサインイン状態が続きます。

## まとめ

実際に運用するのなら、未来の日付の認証情報が来た時に弾く処理であるとか、パスワードをシークレットに入れて隠すであるとか、リフレッシュトークンの期間を調整するであるとか、もう少し凝った実装が必要になると思います。

ただ、パスワードを教えることなく、QRコードやトークンの付いたURLだけでCognitoにログインさせられるので、便利に使うことができるケースはあるのではないかと思います。