経緯
AWSサーバレスを採用してWebアプリ(画面)を作ることになりました。コンシューマ(一般ユーザ)向けの画面ではなく、企業向けの管理画面です。
メンバーの皆さんにReactとかを学んでいただく時間的な余裕はなかったため、SPAではなく、メンバーの皆さんに経験のある「画面遷移型」の構成にしました。
ただ、AWSサーバレスで画面遷移型のWebアプリを作る、という事例を見つけることができず、実現方式をあれこれ考える必要がありました。構成が固まるまでに悩んだことや、自分なりの解を記事にすることで、同じようなことに悩まれている方のヒントになればと思ってます。
アーキテクチャ
ポイントは以下のとおりです。
- Lambdaではaws-serverless-expressを採用しました。エンドポイントごとにLambda関数を定義する必要がなくなるとともに、ExpressのノウハウやNPMライブラリを活用できるためです。
- テンプレートエンジンにはpug.jsを採用しました。Expressのテンプレートエンジンとしてデフォルト採用されているためです。初めて使ってみましたが、簡潔にコーディングできるので使いやすいと感じました。
シーケンス
①ログイン画面の表示
こちらについては特筆すべきことはありません。express-sessionなどについては後述します。
②ログイン処理
ポイントは以下のとおりです。
Cognitoでの認証
- ユーザープール認証フローに沿って、ユーザの認証を行います。公式ドキュメントの通りに、ブラウザ内のJavaScriptからCognitoにID/パスワードを送信します。公式ドキュメントに記載されている「AmazonCognitoIdentity」を利用するには、こちらの手順に従ってamazon-cognito-identity-jsを読み込む必要があります。
- Cognitoでの認証が成功すると、CognitoからIDトークンとアクセストークンが返却されます。今回は認証をしたいので、IDトークンを利用します。ブラウザ内のJavaScriptから、IDトークンをAPI gatewayに送ります(画面遷移型なので、FormをSubmitします)。
- 一方、ユーザープール認証フローの他に、OpenID Connectによる認証フローも用意されています。その場合、Cognitoのログインエンドポイントを使うことで、ログイン画面のUIすら開発しなくてもよくなります。ただ、ログインエンドポイントから返されるログイン画面には、英語のデフォルト文言をカスタマイズできない、という致命的なデメリットがあります。カスタマイズできるのはCSSでのスタイル定義のみです。今回の案件の場合、さすがに英語のデフォルト文言ではNGでしたので、ログインエンドポイントの利用を諦めました。
IDトークンの検証
- ブラウザから送信されたIDトークン(JWT)を検証します。Express側では、送信されてきたIDトークンが、正当なユーザから送信されたものか、あるいは攻撃者によって偽装されたものなのか、検証する必要があります。そこで、(図では記載を省略してますが)jwks-rsaを利用して、Cognitoの公開鍵でIDトークンの署名を検証します。
- その他、有効期限が切れてないか、などの点をjsonwebtokenを使って検証します。
- Cognitoが発行するIDトークンには、以下のとおりユーザの属性が含まれています。ログイン時には、これらの情報をセッションに格納し、次のリクエストで参照できるようにしておきます。
- 「cognito:groups」クレームに、そのユーザがどのCognitoグループに属するか、という情報が入っています。詳しくはこちらを参照。
- 「custom:~~」に、カスタム属性が入っています。ここに、例えば顧客企業のIDなど、業務処理で必要なデータを設定できます(Cognitoにユーザーを登録するときに、設定されるようにしておきます)。
セッション情報の管理
- セッション情報の管理には、express-sessionを使います。Express界隈でのデファクトみたいですね。Expressのミドルウェア(共通処理)として動作します。セッション情報の管理(作成、取得、削除など)をしっかりやってくれるので、とても便利です。
- express-sessionはセッションの保存先(ストア)の実装を持っておらず、ストアへのアクセス部分は別のライブラリが担当します。今回はセッションのストアとしてDynamoDBを利用したかったので、この「別のライブラリ」としてconnect-dynamodbを採用します。
- DynamoDBに、セッション情報を保存するテーブル(セッション管理TBL)を定義する必要があります。詳しくは、connect-dynamodbのドキュメントを参照してください。
セッションIDの返却
- セッション情報が新規に生成されると、express-sessionによってセッションIDが採番されます。このセッションIDをCookieに保存してブラウザに返却します。この時、(常識ですが)CookieにSecure属性を付与する必要があります。ただ、今回の構成の場合、aws-serverless-expressがプロキシの役割を果たすため、aws-serverless-express ⇔ Express間はhttp通信となります。このため、ExpressでSecure属性を付与すると、http通信なのでCookieが欠落した状態でレスポンスが送信されます。これを回避するには、app.set()でtrust proxyを設定する必要があります。今回はLambda内のaws-serverless-expressからしかExpressは呼ばれないので、単に
app.set('trust proxy', true)
)と設定しちゃいました。 - (これもまた常識ですが)CookieにはHttpOnly属性を必ず付けましょう。express-sessionの設定で制御可能です。デフォルト設定はONなので、知らなくても問題ないかもしれませんが。
③ログイン後の画面遷移(認証・認可チェック)
ポイントは以下のとおりです。
認証チェック
- ログイン時にセッションに格納しておいたユーザIDをreq.sessionから取得します。以下の場合、未認証と見なすべきです。いずれの場合も
if(req.session.userId)
という感じで判断できます。- そもそも、セッションIDが送られていない場合。この場合、req.sessionにSessionオブジェクトが生成されます(この時のSessionオブジェクトには、空のCookieしか入ってません)。
- セッションIDは送信されているが、セッション管理TBLに対応する項目(レコード)が無い場合や有効期限が切れている場合。
- 未認証の場合、ログイン画面にリダイレクトします。
- これらの処理は、Expressのミドルウェアとして実施します。
認可チェック
- ログイン時にセッションに格納しておいたCognitoグループ(IDトークンのcognito:groupsクレームに入っていたもの)をreq.sessionから取得します。req.originalUrlからアクセス対象のパスを取得します。ユーザが属するグループに、そのパスを実行する権限があるかを判定し、権限がなければエラー画面を表示します。どのグループにどのパスのどのメソッドの実行が許可されるのか、といった定義については、今回は設定ファイルにベタ書きしちゃいました。
その他
バリデーション
単項目のバリデーションには、express-validatorを利用します。
実戦でこれを使うには、色々と工夫が必要です。最終的には以下のようになりました。
router
// 商品登録処理
router.post('/registerItem', validator.forRegisterItem, controller.registerItem);
- 単項目のバリデーションについてはvalidatorにまとめて実装します。可読性を高めるためです。
validator
const { required, maxLength, alphanumeric } = require('../resources/message').BizError.SingleItemValidationError;
// 画面から入力されるのは、itemId(商品ID)、itemName(商品名)とします。
exports.forRegisterItem = [
body('itemId', required).isLength({ min: 1 }),
body('itemId', alphanumeric).isAlphanumeric(),
body('itemId', maxLength({ max: 10 })).isLength({ max: 10 }),
body('itemName', required).isLength({ min: 1 }),
body('itemName', maxLength({ max: 10 })).isLength({ max: 10 }),
];
- エラーメッセージの定義を共通化するため、messageに文言を定義します。
- trimやescape(サニタイジング)といった処理は、以下のようにExpressミドルウェアで共通処理として定義します。
app.use([body('*').trim().escape(), query('*').trim().escape(), param('*').trim().escape()]);
message
const BizError = {
SingleItemValidationError: {
/** 必須エラー */
required: '必須項目です。',
/** 英数字以外が入力された場合のエラー */
alphanumeric: '英数字で入力してください。',
/** 桁数上限エラー */
maxLength: ({ max }) => `${max}文字以下で入力してください。`,
},
// 以下、略。
- 「●●文字以下で入力してください」といったように、●●の部分を可変にできるようにすべきです。そこで、maxLengthは関数として定義しています。
controller
// 商品登録
exports.registerItem = commonLayer.wrap(async (req, res) => {
const itemId = req.body.itemId;
const itemName = req.body.itemName;
const errors = validationResult(req);
if (!errors.isEmpty()) {
const errMsgs = validationUtil.groupMsgsByProp(errors);
res.render('customer/registerItem', { ...errMsgs, itemId, itemName });
return;
}
// 後続処理
});
- validationUtilでは、pugで入力項目の近くにエラーメッセージを表示するためにひと工夫をしています。
- バリデーションとは関係ないですが、
commonLayer.wrap()
でやっていることはこちらの記事と同じです。
validationUtil
exports.groupMsgsByProp = (errors) => {
const errorsMappings = errors.errors.reduce((prev, current) => {
if (!prev[current.param]) {
prev[current.param] = [];
}
prev[current.param].push(current);
return prev;
}, {});
return {
'errorMappings': errorsMappings,
};
};
pug
registerItem.pug
extends ../common/layout.pug
block title
title 商品登録
block content
.container.mt-5
.d-flex.justify-content-center
.col-8
if successMsg
p.text #{successMsg}
+globalErrMsg()
form(method="post")
.form-group
label(for="itemId") 商品ID
input#company_id.form-control(type="text", name="itemId", value=itemId)
+errMsgsOf('itemId')
.form-group
label(for="itemName") 商品名
input#company_id.form-control(type="text", name="itemName", value=itemName)
+errMsgsOf('itemName')
input.btn.btn-primary(type="submit", value="登録")
common/layout.pug
mixin errMsgsOf(propName)
if errorMappings && errorMappings[propName]
each error in errorMappings[propName]
div #{error.msg}
Expressミドルウェア設定
- helmet、noCacheを利用して、レスポンスヘッダーを設定します。これにより、XSSなどの対策を行い、セキュリティレベルを高めます。
- その他、セキュリティについては、Express公式サイトでの解説をしっかり把握しておくのが良いです。
おわりに
新たな知見が得られましたら、今後も更新していきたいと思います。