Quantcast
Channel: Node.jsタグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 8868

AWSサーバレスで(SPAではなく)画面遷移型のWebアプリをつくる

$
0
0

経緯

AWSサーバレスを採用してWebアプリ(画面)を作ることになりました。コンシューマ(一般ユーザ)向けの画面ではなく、企業向けの管理画面です。

メンバーの皆さんにReactとかを学んでいただく時間的な余裕はなかったため、SPAではなく、メンバーの皆さんに経験のある「画面遷移型」の構成にしました。

ただ、AWSサーバレスで画面遷移型のWebアプリを作る、という事例を見つけることができず、実現方式をあれこれ考える必要がありました。構成が固まるまでに悩んだことや、自分なりの解を記事にすることで、同じようなことに悩まれている方のヒントになればと思ってます。

アーキテクチャ

スクリーンショット 2020-08-08 21.04.25.png

ポイントは以下のとおりです。

  • Lambdaではaws-serverless-expressを採用しました。エンドポイントごとにLambda関数を定義する必要がなくなるとともに、ExpressのノウハウやNPMライブラリを活用できるためです。
  • テンプレートエンジンにはpug.jsを採用しました。Expressのテンプレートエンジンとしてデフォルト採用されているためです。初めて使ってみましたが、簡潔にコーディングできるので使いやすいと感じました。

シーケンス

①ログイン画面の表示

aa.png

こちらについては特筆すべきことはありません。express-sessionなどについては後述します。

②ログイン処理

seq.png

ポイントは以下のとおりです。

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なので、知らなくても問題ないかもしれませんが。

③ログイン後の画面遷移(認証・認可チェック)

seq.png

ポイントは以下のとおりです。

認証チェック

  • ログイン時にセッションに格納しておいたユーザ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ミドルウェア設定

  • helmetnoCacheを利用して、レスポンスヘッダーを設定します。これにより、XSSなどの対策を行い、セキュリティレベルを高めます。
  • その他、セキュリティについては、Express公式サイトでの解説をしっかり把握しておくのが良いです。

おわりに

新たな知見が得られましたら、今後も更新していきたいと思います。


Viewing all articles
Browse latest Browse all 8868

Trending Articles