この記事の目的
AWSのAmplify UIを使うと、サインインが必要なWebページを簡単に作ることができます。
ですが、ユーザー名とパスワードを画面に入力させたくないときもあります。
たとえば以下のような場合です。
- 不特定多数のユーザーをCognitoで制限したページに呼びたい
- ユーザー名とパスワードをチャットやメールの履歴に残したくない
- 明日以降のアクセスは制限したい、一度だけアクセスしてほしい
- iPhoneでサインインしたいが、キーボードでパスワードを打つのが面倒
そこで、今回の記事では以下のような構成を作ります。
実際に作成したものを動かすと、以下のようになります。
QRコードのURLに一時的に有効なトークンが書き込まれていて、トークンがユーザー名とパスワードの代わりになります。トークンの有効期限は好きな時間に設定することができます。
もし有効期限の切れたQRコードや、改ざんされたQRコードを読み込んだときは、従来通りのAmplify UIのサインイン画面が表示されます。
この記事では、この仕組みの実装方法を説明していきます。
実装する
事前準備
あらかじめ、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へとそのまま渡されます。
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
クライアントメタデータを認証前トリガーの中で検証して、不正があれば(※パスワードとユーザー名が合っていても)認証を失敗させることができます。
実装は下のような流れになります。
実装手順1. 画面側の実装
URLについた一時的な認証情報から、自動でサインインするための画面部品を作成します。
以下のようなクエリのついたURLを受け取った時に、サインイン画面を出さずにサインイン処理をします。
https://xxxxxxxxx?auth=true&dt=xxxxx&salt=xxxxxx&hash=xxxxxx
画面部品のソースコードは以下の通りです。
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
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トリガーを追加します。
このLambda(authenticate-handler)のapp.pyは以下のように実装します。
※標準ライブラリだけで動くので、requests.txtは不要です。
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を呼び出します。
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にログインさせられるので、便利に使うことができるケースはあるのではないかと思います。