この記事の内容
タイトルの通りですが、Serverless Framework と Cognito User Poolsを使って認証したユーザーのみが利用出来るTODOアプリを作ってみようと思います。
システム構成
簡単なシステム構成図です。
UserPool
ここにユーザー情報が格納されます。
サインアップや認証の処理は amazon-cognito-identity-js というライブラリを使って実現します。
CloudFront、S3
React + Redux + React Router で作ったシングルページアプリケーションを配置します。
以前、S3 CloudFront Route 53 でReactで作ったSPAを配信する という記事を書きました。こちらに載っている手順と同様になります。
こちらのサンプルは GitHub に上げてあります。
まだエラー処理等が甘い部分がありますが、フロントエンドの一通りの流れは理解出来るかと思います。
API Gateway, Lambda, DynamoDB
こちらはTODOアプリに必要なバックエンドの処理になります。
こちらのサンプルも GitHub に上げてあります。
ユーザープールを作成する
マネジメントコンソールよりユーザープールの作成を行っていきます。
ここでは「ステップに従って設定する」を選択して各項目にどのような設定値があるか確認しながら進めます。
ユーザープールの名前
分かりやすい名前を付けます。

サインイン(ログイン)の方法を選択
サインイン(ログイン)の方法を選択します。
注意点としてはこの項目は後で変える事が出来ないので、慎重に定義しましょう。
ここではメールアドレスでサインイン(ログイン)が出来るように許可しておきます。

標準属性の選択
サインアップ時にどの属性を必須にするかを選択します。
メールアドレスによるサインインを許可したので email
は必須にするとして birhdate
gender
も必須にしておきます。

余談ですがここに載っている属性名の物理名は OpenID Connect Standard Claims に載っている物理名が採用されているようです。
この項目も後で必須属性を追加するという事が出来ないので慎重に定義する必要があります。
他にもカスタム属性が設定可能ですが、データ構造によっては全てのユーザー情報をユーザープールに入れるより、DynamoDB等の別のデータソースに保存したほうが扱い易いケースもあります。
このあたりも考慮して要件にあった値を設定すれば良いでしょう。
パスワード強度、自己サインアップの設定
パスワード強度、自己サインアップの可否等を設定します。
どちらも後から変更可能です。ここではパスワード強度はデフォルト、ユーザーが自己サインアップが出来る形を選択します。

他要素認証の設定
MFAと呼ばれる他要素認証の設定を行います。
ここで必須にしなかった場合は後で必須にする事は出来ません。(オフにする事は可能)
例えば機密情報の管理を行う為の管理アプリケーション等を想定する場合は有効にしておくと良いでしょう。(まあこれも要件次第ですね。)

Eメール、電話番号の検証要求
個人的にはサインイン(ログイン)を許可した項目に関しては必ず検証要求を行ったほうが良いと思います。
これらの情報はパスワードのリセット時にも利用するので、検証されていないメールアドレス等が登録される事を防ぐ為です。

また他要素認証にはSMSメッセージを利用するケースが存在するので、ここで新規のロールを作成しておきましょう。
メッセージのカスタマイズ
メールのテンプレート等をカスタマイズ出来るようです。
後で変更可能なので、とりあえずデフォルトのまま進めます。

タグの追加
コスト配分タグを追加してコストの内訳を詳細に表示出来るようです。詳しくは ユーザープールへのコスト配分タグの追加 を参照して下さい。
ここでは追加なしで進めておきます。

ユーザーデバイスの記憶
ここでは「いいえ」を選択しますが、 他要素認証を有効にしている場合は記憶済のデバイスの場合は、2要素目の認証を要求しない、のような対応が出来るようなので記憶させるのはアリかもしれません。
ユーザープールデバイス追跡設定の指定 に詳しい記載があります。

アプリクライアントの追加
ユーザープールにアクセス出来るアプリクライアントの作成を行います。
安全にクライアントシークレットを保存する事が出来ない、SPAでの実装を想定しているので「クライアントシークレットを生成する」のチェックを外しておきましょう。

トリガーの設定
サインアップ前や認証後等の決まったタイミングで任意のLambda関数を呼び出す事が出来ます。
今回は利用しませんでしたが、これはかなり強力な機能です。
作り込めばかなり色々な事が出来るでしょう。
この手の物はマネジメントコンソールでGUIで設定してしまうと、後で訳が分からなくなるので、Serverless Framework等で管理するようにしたほうが良いと思います。

最終確認
今までの設定項目がリスト表示されるので、問題なければ作成しましょう。

サインアップ処理の実装
素早く作成を行う為に Material-UI で下記のようなサインアップフォームを作成します。

下記はTypeScriptでの実装例です。
import {
CognitoUserAttribute,
CognitoUserPool,
ISignUpResult,
CognitoUser,
} from 'amazon-cognito-identity-js';
import { AppConfig } from '../AppConfig';
import getCognitoUserPoolClientId = AppConfig.getCognitoUserPoolClientId;
import getCognitoUserPoolId = AppConfig.getCognitoUserPoolId;
/**
* Signupリクエスト型
*/
export interface ISignupRequest {
email: string;
password: string;
gender: string;
birthdate: string;
}
/**
* Signup成功時のレスポンス型
*/
export interface ISignupSuccessResponse {
email: string;
}
/**
* Signup失敗時のレスポンス型
*/
export interface ISignupFailureResponse {
error: Error;
}
/**
* サインアップ完了Request 引数IF
*/
export interface ISignupCompleteRequest {
email: string;
verificationCode: string;
}
/**
* signupCompleteFailureAction 引数IF
*/
export interface ISignupCompleteFailureResponse {
error: Error;
}
export const signup = (request: ISignupRequest) => {
return new Promise<ISignupSuccessResponse>((resolve, reject) => {
const poolData = {
UserPoolId: getCognitoUserPoolId(),
ClientId: getCognitoUserPoolClientId(),
};
const cognitoUserPool = new CognitoUserPool(poolData);
const dataEmail = {
Name: 'email',
Value: request.email,
};
const dataGender = {
Name: 'gender',
Value: request.gender,
};
const dataBirthdate = {
Name: 'birthdate',
Value: request.birthdate,
};
const attributeEmail = new CognitoUserAttribute(dataEmail);
const attributeGender = new CognitoUserAttribute(dataGender);
const attributeBirthdate = new CognitoUserAttribute(dataBirthdate);
const attributeList = [
attributeEmail,
attributeGender,
attributeBirthdate,
];
cognitoUserPool.signUp(
dataEmail.Value,
request.password,
attributeList,
attributeList,
(error: Error, signupResult: ISignUpResult) => {
if (error != null) {
return reject({ error });
}
return resolve({ email: signupResult.user.getUsername() });
});
});
};
export const signupComplete = (request: ISignupCompleteRequest): Promise<any> => {
return new Promise<any>((resolve, reject) => {
const poolData = {
UserPoolId: getCognitoUserPoolId(),
ClientId: getCognitoUserPoolClientId(),
};
const cognitoUserPool = new CognitoUserPool(poolData);
const userData = {
Username: request.email,
Pool: cognitoUserPool,
};
const cognitoUser = new CognitoUser(userData);
cognitoUser.confirmRegistration(
request.verificationCode,
true,
(error, result) => {
if (error) {
return reject({ error });
}
return resolve(result);
},
);
});
};
UserPoolId と ClientId はそれぞれ作成した値を渡して下さい。
※ getCognitoUserPoolClientId
等は単に環境変数からクライアントID等を取得する関数です。
サインアップを行うとメールアドレス宛に検証コードが送信されるので、それを受取る為の下記のようなフォームを作成します。
ここで受け取った検証コードを元に signupComplete
を呼び出しサインアップを完了させます。
ログイン
下記のようなログインフォームを作成します。

フォームからメールアドレスとパスワードを受け取って、以下のような処理を実行します。
import {
CognitoUser,
CognitoUserPool,
AuthenticationDetails,
CognitoUserSession,
} from 'amazon-cognito-identity-js';
import { AppConfig } from '../AppConfig';
import getCognitoUserPoolClientId = AppConfig.getCognitoUserPoolClientId;
import getCognitoUserPoolId = AppConfig.getCognitoUserPoolId;
import getAppUri = AppConfig.getAppUri;
/**
* ログイン時のリクエストIF
*/
export interface ILoginRequest {
email: string;
password: string;
}
/**
* ログインを行う
*
* @param {LoginService.ILoginRequest} request
* @returns {Promise<"amazon-cognito-identity-js".CognitoUserSession>}
*/
export const login = async (request: ILoginRequest): Promise<CognitoUserSession> => {
return new Promise<CognitoUserSession>((resolve, reject) => {
const poolData = {
UserPoolId: getCognitoUserPoolId(),
ClientId: getCognitoUserPoolClientId(),
};
const cognitoUserPool = new CognitoUserPool(poolData);
const userData = {
Username: request.email,
Pool: cognitoUserPool,
};
const cognitoUser = new CognitoUser(userData);
const authenticationData = {
Username: request.email,
Password: request.password,
};
const authenticationDetails = new AuthenticationDetails(authenticationData);
cognitoUser.authenticateUser(
authenticationDetails,
{
onSuccess: (session: CognitoUserSession) => {
resolve(session);
},
onFailure: (error: Error) => {
reject(error);
},
},
);
});
};
ログインに成功するとユーザープールから取得したIDトークンやアクセストークン等がローカルストレージに自動で保存されます。
中身を取り出すには以下のように getSession
を呼び出してあげればOKです。
export const fetchSession = async (): Promise<CognitoUserSession> => {
return new Promise<CognitoUserSession>((resolve, reject) => {
const poolData = {
UserPoolId: getCognitoUserPoolId(),
ClientId: getCognitoUserPoolClientId(),
};
const cognitoUserPool = new CognitoUserPool(poolData);
const cognitoUser = cognitoUserPool.getCurrentUser();
if (cognitoUser == null) {
return reject(
new Error('user dose not exist'),
);
}
cognitoUser.getSession((error: Error, session: CognitoUserSession) => {
if (error != null) {
return reject(error);
}
return resolve(session);
});
});
};
API GatewayにCognito User Pools Authorizerを設定する
API側でCognito User Pools Authorizerを有効化しましょう。
例えば createTodo
という関数に設定するには authorizer.arn に 作成したユーザープールのARNを入れます。(${env:TODO_APP_USER_POOL_ARN}
の部分を置換えて下さい。)
functions:
createTodo:
handler: build/functions/todo.create
events:
- http:
path: todo
method: post
cors: true
authorizer:
arn: ${env:TODO_APP_USER_POOL_ARN}
設定後デプロイすると、以下のように AuthorizationHeaderにIDトークンを入れたリクエストのみを受け付けるようになります。
curl -v \
-H "Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ" \
https://abcdefghi1.execute-api.ap-northeast-1.amazonaws.com/dev/todo
eyJh
から始まる長い文字列がIDトークンです。
ログイン成功後 amazon-cognito-identity-js というライブラリによってローカルストレージに保存されますので、ChromeDeveloperTool等で確認してみましょう。
なお、IDトークンは JWT というデータ形式です。
https://jwt.io/ を使うと中身を手軽に確認出来るのでオススメです。
ここまで確認が取れたら、後はAPIリクエスト時に同じ事をプログラム上からやってあげればOKです。
サンプルコードの紹介
先程も記載しましたが、ここまでのサンプルコードをGitHub上に公開してあります。
- serverless-todo-app-web(React製のフロント側)
- serverless-todo-app-backend(Serverless Frameworkで作成したAPI側の処理)
サンプルなのでエラー処理や考慮が足りていない点が多々ありますが、コードを動かしてみれば流れは掴めるかと思います。
今後やったほうが良いと思っている事
今回ユーザープールをマネジメントコンソール上からGUIで作ったのですが、設定が増えてくると管理するのが大変なので、Serverless Framework等で構成の管理が出来ないか挑戦したいです。
また ユーザープールへのドメインの割り当て を行いユーザーのサインアップ等をAWSが提供するUI上で行う方法も存在するようなのでこちらも試してみたいと思います。
どちらの内容も方法が分かり次第、ノウハウを公開しようと思います。
まとめ
記事の内容は以上になります。
ユーザープールはまだ比較的新しい機能ですが、今回開発を行ってみて非常に大きな可能性を感じました。
ユーザーの認証周りのデータや個人情報等を自分たちで持たなくて良いのは非常に楽だと感じました。
最後まで読んで頂きありがとうございました。