22
23

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-08-10

経緯

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

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ミドルウェア設定

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

おわりに

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

22
23
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
23

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?