2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-06-09

この記事の目的

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

auth-login.png

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

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

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

auth-login-qrcode.png

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

無題の動画 ‐ Clipchampで作成 (1).gif

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

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

無題の動画 ‐ Clipchampで作成.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」を入力して、サインインボタンを押したときと同じ動きをします。

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へとそのまま渡されます。

認証前トリガーの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

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

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

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

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

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

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

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

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

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を呼び出します。

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にログインさせられるので、便利に使うことができるケースはあるのではないかと思います。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?