経緯
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
constBizError={SingleItemValidationError:{/** 必須エラー */required:'必須項目です。',/** 英数字以外が入力された場合のエラー */alphanumeric:'英数字で入力してください。',/** 桁数上限エラー */maxLength:({max})=>`${max}文字以下で入力してください。`,},// 以下、略。
- 「●●文字以下で入力してください」といったように、●●の部分を可変にできるようにすべきです。そこで、maxLengthは関数として定義しています。
controller
// 商品登録exports.registerItem=commonLayer.wrap(async(req,res)=>{constitemId=req.body.itemId;constitemName=req.body.itemName;consterrors=validationResult(req);if(!errors.isEmpty()){consterrMsgs=validationUtil.groupMsgsByProp(errors);res.render('customer/registerItem',{...errMsgs,itemId,itemName});return;}// 後続処理});
- validationUtilでは、pugで入力項目の近くにエラーメッセージを表示するためにひと工夫をしています。
- バリデーションとは関係ないですが、
commonLayer.wrap()
でやっていることはこちらの記事と同じです。
validationUtil
exports.groupMsgsByProp=(errors)=>{consterrorsMappings=errors.errors.reduce((prev,current)=>{if(!prev[current.param]){prev[current.param]=[];}prev[current.param].push(current);returnprev;},{});return{'errorMappings':errorsMappings,};};
pug
registerItem.pug
extends../common/layout.pugblocktitletitle商品登録blockcontent.container.mt-5.d-flex.justify-content-center.col-8ifsuccessMsgp.text#{successMsg}+globalErrMsg()form(method="post").form-grouplabel(for="itemId")商品IDinput#company_id.form-control(type="text",name="itemId",value=itemId)+errMsgsOf('itemId').form-grouplabel(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
mixinerrMsgsOf(propName)iferrorMappings&&errorMappings[propName]eacherrorinerrorMappings[propName]div#{error.msg}
Expressミドルウェア設定
- helmet、noCacheを利用して、レスポンスヘッダーを設定します。これにより、XSSなどの対策を行い、セキュリティレベルを高めます。
- その他、セキュリティについては、Express公式サイトでの解説をしっかり把握しておくのが良いです。
おわりに
新たな知見が得られましたら、今後も更新していきたいと思います。