この記事では、Amplify SDK(JavaScript)を使い、Cognitoの認証機能を試してみたいと思います。
ただ、Amplify でアプリを作成したいわけではないので、amplify init
などは使わず、Amplify SDKだけを利用する形で検証してみたいと思います。
これができれば、既存のアプリにCognitoを組み込みたい場合にも都合が良さそうです。
登場人物
今回はホストされたUIは利用せず、UIを自作した上でAPIを直接呼び出して、サインアップ、サインインなどの処理を行います。
- Cognito ユーザプール
- サインアップ画面
- コード確認画面
- サインイン画面
- 現在のセッション確認画面
- パスワード変更画面
- パスワード忘れ画面
ユーザプールを作成
AWSコンソール画面からユーザプールを作成します。
APIの検証が主目的なので、最もシンプルな構成にしようと思います。
1.サインインの設定
サインインエクスペリエンスを設定します。
ユーザープールだけを利用する設定にしました。
Eメールを選択したので、ユーザはEメールでサインインする想定です。
2.セキュリティ要件の設定
セキュリティ要件を設定します。
パスワードはデフォルト設定のままです。
多要素認証(MFA)は無しにしました。
パスワードを忘れた場合に、自分で再登録できるようにしました。
3.サインアップの設定
サインアップエクスペリエンスを設定します。
サインアップ画面からユーザが自分自身でサインアップできるようにしたいので、自己登録にチェックを入れます。
なんとなく、ニックネームも登録するようにしてみました。
4.メッセージ配信の設定
メッセージ配信を設定します。
今回は検証用で、メール件数が50件/日を超えることはないので、CognitoでEメールを送信するようにしました。
5.アプリケーションの設定
アプリケーションを統合します。
今回はホストされたUIは使いません。
自分で画面を作って、その中でAPIを呼び出します。
ブラウザから認証するので「パブリッククライアント」を選択しました。
また、「クライアントのシークレットを生成しない」を選択しました。
これは、JavaScriptのAmplify SDKが、2022年1月現在はまだクライアントシークレットを指定して認証処理を行うことに対応してないためです。
「高度なアプリケーションクライアントの設定」および「属性の読み取りおよび書き込み許可」ともに、デフォルト設定のままです。
SRP(セキュアリモートパスワード)がよくわかってないですが、通信時に生のパスワードをそのまま使うのではなくて、よりセキュアな方法らしいです。
ここまでで、ユーザープールを作成が完了しました。
認証付きWebアプリ作成
こちらを参考にしつつ、とはいえAmplifyでアプリを作るわけではなく、Amplify SDKだけを利用して、認証機能付きのWebアプリを作成してみます。
フォルダ/ファイル構成
最終的にフォルダ/ファイル構成は以下のようになりました。
サインアップ、サインインなど個別に画面を作って、それぞれ専用のJavaScriptのファイルが作ってる感じです。
├── dist
├── node_modules
├── public
│ ├── index.html
│ ├── sign-up.html
│ ├── confirm-sign-up.html
│ ├── sign-in.html
│ ├── current-session.html
│ ├── sign-out.html
│ ├── change-password.html
│ └── forgot-password.html
├── src
│ ├── auth.js
│ ├── sign-up.js
│ ├── confirm-sign-up.js
│ ├── sign-in.js
│ ├── current-session.js
│ ├── sign-out.js
│ ├── change-password.js
│ └── forgot-password.js
├── package.json
└── webpack.config.js
package.json
webpack以外で実際に使ってるライブラリは、aws-amplify
だけです。
{
"name": "cognito-amplify-sample",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"aws-amplify": "latest"
},
"devDependencies": {
"copy-webpack-plugin": "^6.1.0",
"webpack": "^4.46.0",
"webpack-cli": "^4.9.1",
"webpack-dev-server": "^4.4.0"
},
"scripts": {
"start": "webpack && webpack-dev-server --mode development",
"build": "webpack"
}
}
webpack.config.js
各画面用のJavaScriptファイルをそれぞれバンドルしてます。
HTMLファイルは、copy-webpack-plugin
を使ってコピーしてるだけです。
const CopyWebpackPlugin = require("copy-webpack-plugin");
const webpack = require("webpack");
const path = require("path");
module.exports = {
mode: "development",
entry: {
"sign-up": "./src/sign-up.js",
"confirm-sign-up": "./src/confirm-sign-up.js",
"sign-in": "./src/sign-in.js",
"current-session": "./src/current-session.js",
"sign-out": "./src/sign-out.js",
"change-password": "./src/change-password.js",
"reset-password": "./src/reset-password.js",
},
output: {
filename: "[name].bundle.js",
path: path.resolve(__dirname, "dist"),
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
},
],
},
devServer: {
client: {
overlay: true,
},
hot: true,
watchFiles: ["src/*", "public/*"],
},
plugins: [
new CopyWebpackPlugin({
patterns: ["public"],
}),
new webpack.HotModuleReplacementPlugin(),
],
};
auth.js
こちら を参考に設定しました。
コメントに書いてあるように、aws_user_pools_web_client_id
を設定する場合は、クライアントシークレットは無効化しておく必要がありますので、注意が必要です。
import Amplify from "aws-amplify";
export const initAuth = () => {
Amplify.configure({
aws_cognito_region: "ap-northeast-1", // (required) - Region where Amazon Cognito project was created
aws_user_pools_id: "us-west-2_xxxxxxxxx", // (optional) - Amazon Cognito User Pool ID
aws_user_pools_web_client_id: "xxxxxxxxxxxxxxxxxxxxxxx", // (optional) - Amazon Cognito App Client ID (App client secret needs to be disabled)
});
};
サインアップ画面
サインアップ(ユーザ登録)をするための画面を作成したいと思います。
-
sign-up.html
今回は、サインインに利用するユーザ属性をEメールとし、nickname
を追加の属性としたので、email
、nickname
、password
の三つの項目が必要になります<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>Sign Up</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> </head> <body> <div id="app"> <h1>Sign up</h1> <form id="sign-up"> <table> <tr> <td>email</td> <td><input type="email" id="email" /></td> </tr> <tr> <td>nickname</td> <td><input type="text" id="nickname" /></td> </tr> <tr> <td>password</td> <td><input type="password" id="password" /></td> </tr> </table> <div><button type="submit">sign-up</button></div> </form> <br /> <a id="resend-sign-up" href="">resend-confirm-code</a> </div> <script src="sign-up.bundle.js"></script> </body> </html>
-
sign-up.js
attachEventlistener()
でsign-up
のsubmit
イベントとresend confirm code
のclick
イベントをリッスンするようにしてます。
Auth.signUp()
でサインアップ処理を実行しています。追加したnickname
はattributes
の方で指定します。
Auth.resendSignUp()
で確認コード再送処理を実行しています。import { Auth } from "aws-amplify"; import { initAuth } from "./auth"; class App { constructor() { initAuth(); this.attachEventlistener(); } /** UIにイベントをアタッチする */ attachEventlistener() { // sign-up form document .getElementById("sign-up") .addEventListener("submit", async (event) => { event.preventDefault(); const email = document.getElementById("email").value; const nickname = document.getElementById("nickname").value; const password = document.getElementById("password").value; if (!email || !nickname || !password) { alert("Please input email, nickname and password."); return; } await this.signUp(email, nickname, password); }); // resend-sign-up link document .getElementById("resend-sign-up") .addEventListener("click", async (event) => { event.preventDefault(); const email = document.getElementById("email").value; if (!email) { alert("Please input email."); return; } await this.resendSignUp(email); }); } /** サインアップを実行する */ async signUp(email, nickname, password) { try { const result = await Auth.signUp({ username: email, password, attributes: { nickname }, }); console.log(result); } catch (error) { console.log(error); alert(error.message); } } /** 確認コードを再送する */ async resendSignUp(email) { try { const result = await Auth.resendSignUp(email); console.log(result); } catch (error) { console.log(error); alert(error.message); } } } new App();
- 画面確認(http://localhost:8080/sign-up.html)
実際にサインアップを実行すると、以下のようにコンソールに結果が表示されました。
認証フローとしてUSER_SRP_AUTH
が利用されていることが分かります。 - AWSコンソール確認
サインアップを実行すると、ユーザプールにユーザが登録されました。
「Eメール確認済み」が「いいえ」、「確認ステータス」が「未確認」で、ユーザが登録されていることが確認できました。
- メール確認
「Your verification code」というタイトルで、メールで確認コードが通知されす。
- 発生するエラー
色々なエラーが発生しますが、自分が遭遇したエラーを列挙しておきます。-
signUp()
-
InvalidPasswordException: Password did not conform with policy: Password must have lowercase characters
- パスワードがポリシーを満たしてないときに発生します。
-
InvalidPasswordException: Password did not conform with policy: Password not long enough
- パスワードの長さが足りないときに発生します。
-
InvalidParameterException: Attributes did not conform to the schema: nickname: The attribute is required
-
nickname
が必要なのに、実装ミスでsignUp()
の引数にattributes
を指定し忘れてるときに発生しました。
-
-
UsernameExistsException: An account with the given email already exists.
- ユーザ名がすでに登録されている場合に発生します。
- このケースは、
resend confirm code
をクリックすることで、確認コードを再送するイメージです。
-
-
resendSignUp()
-
InvalidParameterException: User is already confirmed.
- ユーザがすでに「確認済」のステータスの場合に発生します。
-
-
コード確認画面
サインアップを実行すると、確認コードがメールで通知されます。
この確認コードを登録するためのコード確認画面を作成します。
これにより、メール確認およびユーザ確認をまとめて行うことができます。
-
confirm-sign-up.html
email
とcode
の2項目が必要になります。
このcode
にメールで通知された6桁の確認コードを入力します。<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>Confirm Sign Up</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> </head> <body> <div id="app"> <h1>Confirm Sign Up</h1> <h5>Please input confirm code on your email address.</h5> <form id="confirm-sign-up"> <table> <tr> <td>email</td> <td><input type="email" id="email" /></td> </tr> <tr> <td>code</td> <td><input type="password" id="code" /></td> </tr> </table> <div><button type="submit">confirm</button></div> </form> </div> <script src="confirm-sign-up.bundle.js"></script> </body> </html>
-
confirm-sign-up.js
特に説明はいらないと思いますが、submit
イベントをリッスンして、その中でconfirmSignUp()
を呼び出しています。import { Auth } from "aws-amplify"; import { initAuth } from "./auth"; class App { constructor() { initAuth(); this.attachEventlistener(); } /** UIにイベントをアタッチする */ attachEventlistener() { document .getElementById("confirm-sign-up") .addEventListener("submit", async (event) => { event.preventDefault(); const email = document.getElementById("email").value; const code = document.getElementById("code").value; if (!email || !code) { alert("Please input email and code."); return; } await this.confirmSignUp(email, code); }); } /** コードを確認する */ async confirmSignUp(email, code) { try { const result = await Auth.confirmSignUp(email, code); console.log(result); } catch (error) { console.log(error); alert(error.message); } } } new App();
- 画面確認(http://localhost:8080/confirm-sign-up.html)
メールで通知された確認コードを入力する必要があります。
ボタンを押して、コンソールのを見るとSUCCESS
という文字列が出力されました。
- AWSコンソール確認
「Eメール確認済み」が「はい」に、「確認ステータス」が「確認済み」に変わっていることが確認できました。
- 発生するエラー
-
CodeMismatchException: Invalid verification code provided, please try again.
- 入力した確認コードが間違っているときに発生します。
-
ExpiredCodeException: Invalid code provided, please request a code again.
- 入力した確認コードが期限切れやすでに利用済みの場合に発生します。
-
サインイン画面
サインイン(ログイン)をするための画面を作成したいと思います。
-
sign-in.html
email
とpassword
が必須入力の項目です。
サインインが成功した場合、取得したid-token
を表示するようにしてます。<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>Sign In</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <style> textarea { width: 500px; } </style> </head> <body> <div id="app"> <h1>Sign In</h1> <form id="sign-in"> <table> <tr> <td>email</td> <td><input type="email" id="email" /></td> </tr> <tr> <td>password</td> <td><input type="password" id="password" /></td> </tr> </table> <div> <button type="submit">sign-in</button> </div> </form> <div class="result"> <div>id-token</div> <textarea id="id-token" readonly></textarea> </div> </div> <script src="sign-in.bundle.js"></script> </body> </html>
-
sign-in.js
Auth.signIn()
でサインインしているだけです。
サインインが成功したら、id-token
を取得できますので、それを画面に表示しています。import { Auth } from "aws-amplify"; import { initAuth } from "./auth"; class App { constructor() { initAuth(); this.attachEventlistener(); } /** UIにイベントをアタッチする */ attachEventlistener() { document .getElementById("sign-in") .addEventListener("submit", async (event) => { event.preventDefault(); const email = document.getElementById("email").value; const password = document.getElementById("password").value; if (!email || !password) { alert("Please input email and password."); return; } await this.signIn(email, password); }); } /** サインインを実行する */ async signIn(email, password) { try { const result = await Auth.signIn(email, password); console.log(result); document.getElementById("id-token").value = result.signInUserSession.idToken.jwtToken; } catch (error) { console.log(error); alert(error.message); } } } new App();
- 画面確認(http://localhost:8080/sign-in.html)
無事サインインできて、id-token
を取得することができています。
コンソールにAuth.signIn()
の戻り値を出力しているのですが、トークンだけでなく、id-token
のpayload
がすでに復号化されて入ってるので、ユーザ属性を取得するのにも便利です。
- ローカルストレージ確認
中身は良くわかってないですが、サインインが成功するとローカルストレージにもデータが保存されます。
このデータは、Auth.currentSession()
などで利用されます。
- 発生するエラー
-
NotAuthorizedException: Incorrect username or password.
-
email
かpassword
が間違っている場合に発生します。
-
-
現在のセッション確認画面
現在のセッションを確認する画面を作成します。
-
current-session.html
現在のセッション情報を取得して、email
、nickname
、id-token
の3項目を表示する画面だけの画面になります。<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>Current Session</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <style> textarea { width: 500px; } </style> </head> <body> <div id="app"> <h1>Current Session</h1> <div>email</div> <input type="text" id="email" readonly></input> <div>nickname</div> <input type="text" id="nickname" readonly></input> <div>id-token</div> <textarea id="id-token" readonly></textarea> </div> <script src="current-session.bundle.js"></script> </body> </html>
-
current-session.js
Auth.currentSession()
で現在のセッション情報を取得して画面に表示しています。
引数が何もないのですが、ローカルストレージの情報を利用しているようです。import { Auth } from "aws-amplify"; import { initAuth } from "./auth"; class App { constructor() { initAuth(); this.currentSession(); } /** 現在のセッションを取得する */ async currentSession() { try { const session = await Auth.currentSession(); console.log(session); document.getElementById("email").value = session.idToken.payload.email; document.getElementById("nickname").value = session.idToken.payload.nickname; document.getElementById("id-token").value = session.idToken.jwtToken; } catch (error) { console.log(error); } } } new App();
- 画面確認(http://localhost:8080/current-session.html)
無事、セッション情報が取得できています。
サインアウトしたり、ローカルストレージのデータを消したりすると、情報を取得できません。
- 発生するエラー
-
No current user
- エラーではないですが、サインインしていない状態だと、この文字列が返却されます。
-
パスワード変更画面
サインインした状態で、パスワードを変更する画面を作成しました。
-
change-password.html
現在のパスワードと新しいパスワードの2項目が必要です。
サインインしている前提なので、email
のような項目は必要ありません。<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>Change Password</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <style> textarea { width: 500px; } </style> </head> <body> <div id="app"> <h1>Change Password</h1> <form id="change-password"> <table> <tr> <td>current-password</td> <td><input type="password" id="current-password" /></td> </tr> <tr> <td>new-password</td> <td><input type="password" id="new-password" /></td> </tr> </table> <div> <button type="submit">change-password</button> </div> </form> </div> <script src="change-password.bundle.js"></script> </body> </html>
-
change-password.js
ポイントは、Auth.currentAuthenticatedUser()
で現在の認証済みユーザを取得して、そのユーザを第一引数にAuth.changePassword()
を呼び出している部分です。import { Auth } from "aws-amplify"; import { initAuth } from "./auth"; class App { constructor() { initAuth(); this.attachEventlistener(); } /** UIにイベントをアタッチする */ attachEventlistener() { document .getElementById("change-password") .addEventListener("submit", async (event) => { event.preventDefault(); const currentPassword = document.getElementById("current-password").value; const newPassword = document.getElementById("new-password").value; if (!currentPassword || !newPassword) { alert("Please input current-password and new-password."); return; } await this.changePassword(currentPassword, newPassword); }); } /** パスワード変更を実行する */ async changePassword(currentPassword, newPassword) { try { const user = await Auth.currentAuthenticatedUser(); console.log(user); const result = await Auth.changePassword( user, currentPassword, newPassword ); console.log(result); } catch (error) { console.log(error); alert(error.message); } } } new App();
- 画面確認(http://localhost:8080/change-password.html)
古いパスワードと新しいパスワードを入力してボタンをクリックすると、無事パスワードを変更できました。SUCCESS
という文字列が返ってきています。
実際にサインイン画面で新しいパスワードでサインインできました。
- 発生するエラー
-
LimitExceededException: Attempt limit exceeded, please try after some time.
- 何度かパスワードを間違うとこのエラーが発生します。
-
パスワード忘れ画面
パスワードを忘れた場合に、パスワードを再登録を実施するための画面を作成しました。
確認コードをメールで通知し、確認コードと一緒に新しいパスワードを入力する画面になります。
-
forgot-password.html
確認コードをメール通知する機能と、確認コードを使って新しいパスワードを登録する機能を提供します。<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>Forgot Password</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> </head> <body> <div id="app"> <h1>Forgot Password</h1> <h5>If you forgot your password, you can reset your password.</h5> <h3>Send Mail</h3> <form id="forgot-password"> <table> <tr> <td>email</td> <td><input type="email" id="email" /></td> </tr> </table> <div> <button type="submit">forgot-password</button> </div> </form> <h3>Submit</h3> <form id="forgot-password-submit"> <table> <tr> <td>email</td> <td><input type="email" id="email" /></td> </tr> <tr> <td>code</td> <td><input type="password" id="code" /></td> </tr> <tr> <td>password</td> <td><input type="password" id="password" /></td> </tr> </table> <div> <button type="submit">forgot-password-submit</button> </div> </form> </div> <script src="forgot-password.bundle.js"></script> </body> </html>
-
forgot-password.js
二つのフォームにイベントリスナーをアタッチしています。
Auth.forgotPassword()
で確認コードをメール通知し、Auth.forgotPasswordSubmit()
で新しいパスワードを登録しています。import { Auth } from "aws-amplify"; import { initAuth } from "./auth"; class App { constructor() { initAuth(); this.attachEventlistener(); } /** UIにイベントをアタッチする */ attachEventlistener() { // forgot-password form document .getElementById("forgot-password") .addEventListener("submit", async (event) => { event.preventDefault(); const email = document.getElementById("email").value; if (!email) { alert("Please input email."); return; } await this.forgotPassword(email); }); // forgot-password-submit form document .getElementById("forgot-password-submit") .addEventListener("submit", async (event) => { event.preventDefault(); const email = document.getElementById("email").value; const code = document.getElementById("code").value; const password = document.getElementById("password").value; if (!email || !code || !password) { alert("Please input email, code and password."); return; } await this.forgotPasswordSubmit(email, code, password); }); } /** 確認コードを通知する */ async forgotPassword(email) { try { const result = await Auth.forgotPassword(email); console.log(result); } catch (error) { console.log(error); alert(error.message); } } /** 確認コードを使って新しいパスワードを設定する */ async forgotPasswordSubmit(email, code, password) { try { const result = await Auth.forgotPasswordSubmit(email, code, password); console.log(result); } catch (error) { console.log(error); alert(error.message); } } } new App();
- 画面確認(http://localhost:8080/forgot-password.html)
forgot-password
で確認コードがメール通知されました。内容は、サインアップの時と全く同じです。
また、forgot-password-submit
で新しいパスワードが登録されました。
Amplify SDKのインターフェースがシンプルすぎて、わざわざ確認しなくても分かる気がしなくもないですが、一通りベーシックな認証の機能を確認できました。
その他
クライアントシークレットについて
クライアントシークレットの作成をした場合、CognitoのAPIを呼び出した際に、以下のようにエラーが発生しました。
Client xxxxx is configured for secret but secret was not received
これに関しては、こちらのISSUE で上がっているのですが、Amplify SDK(JavaScript)では現在クライアントシークレットを指定するいことはできないみたいです。
製作者側も検討中とのことですが、今のところはクライアントシークレットを作成するのチェックを外すのが良さそうです。
refresh-tokenを使ったid-tokenの再発行
id-token
が期限切れの場合に、refresh-token
を使ってid-token
を再発行するのだと思って、Amplify SDKのインターフェースを確認してみたのですが、それらしい関数が見当たりません。
ググってみると、StackOverflowに以下のQ&Aがありました。
https://stackoverflow.com/questions/53375350/how-handle-refresh-token-service-in-aws-amplify-js
結論としては、Auth.currentSession()
を行うと、id-token
の期限が切れている場合に、
勝手にid-token
を再発行してくれるらしいです。
なので、id-token
を利用する場合は、毎度Auth.currentSession()
を実行する必要があるようです。
さいごに
今回、Amplify SDKでCognitoの認証機能を試してみましたが、特にAmplifyでアプリを作成しなくても、SDK だけを使ってちゃんと動いてくれました。
Amplify SDKのインターフェースも非常に分かりやすいので、特にドキュメントを読み込んだりしなくても、普通に実装できると思います。
今回は試してないのですが、気が向いたら以下のような点も今後試してみようと思います。
- 認証イベントの前後でLambdaトリガーをしこむ
- 例)ユーザ移行に利用。サインアップ時にユーザープールに存在しなかったら、既存のDBからデータを取得して、ユーザプールにデータを登録する
- ユーザをCSVでまとめてインポートする
- CognitoをAWS GatewayのAuthenticatorとして設定し、トークンをチェックする
- Admin用のAPIで、直接パスワードを変更したり、ユーザのステータスを変更したりする