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

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

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

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

# アーキテクチャ

<img width="672" alt="スクリーンショット 2020-08-08 21.04.25.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/455418/b2ee334c-466a-87ea-d01f-982f1a2362e9.png">

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

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

# シーケンス
## ①ログイン画面の表示

![aa.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/455418/ce408e58-c723-2730-4e32-e8892e88c95b.png)

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

## ②ログイン処理
![seq.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/455418/4b8ab703-4e1c-ab8f-f8f1-79ae58c7c1d5.png)

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

### Cognitoでの認証
* [ユーザープール認証フロー](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow.html)に沿って、ユーザの認証を行います。[公式ドキュメント](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/authentication.html)の通りに、ブラウザ内のJavaScriptからCognitoにID/パスワードを送信します。公式ドキュメントに記載されている「AmazonCognitoIdentity」を利用するには、[こちらの手順](https://github.com/aws-amplify/amplify-js/tree/master/packages/amazon-cognito-identity-js#install-using-separate-javascript-file)に従ってamazon-cognito-identity-jsを読み込む必要があります。
* Cognitoでの認証が成功すると、CognitoからIDトークンとアクセストークンが返却されます。今回は認証をしたいので、IDトークンを利用します。ブラウザ内のJavaScriptから、IDトークンをAPI gatewayに送ります（画面遷移型なので、FormをSubmitします）。
* 一方、ユーザープール認証フローの他に、OpenID Connectによる認証フローも用意されています。その場合、Cognitoの[ログインエンドポイント](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/login-endpoint.html)を使うことで、ログイン画面のUIすら開発しなくてもよくなります。ただ、ログインエンドポイントから返されるログイン画面には、英語のデフォルト文言をカスタマイズできない、という致命的なデメリットがあります。カスタマイズできるのはCSSでのスタイル定義のみです。今回の案件の場合、さすがに英語のデフォルト文言ではNGでしたので、ログインエンドポイントの利用を諦めました。

### IDトークンの検証
* ブラウザから送信されたIDトークン(JWT)を検証します。Express側では、送信されてきたIDトークンが、正当なユーザから送信されたものか、あるいは攻撃者によって偽装されたものなのか、検証する必要があります。そこで、（図では記載を省略してますが）[jwks-rsa](https://www.npmjs.com/package/jwks-rsa)を利用して、Cognitoの公開鍵でIDトークンの署名を検証します。
* その他、有効期限が切れてないか、などの点を[jsonwebtoken](https://github.com/auth0/node-jsonwebtoken)を使って検証します。
* Cognitoが発行するIDトークンには、以下のとおりユーザの属性が含まれています。ログイン時には、これらの情報をセッションに格納し、次のリクエストで参照できるようにしておきます。
    * 「cognito:groups」クレームに、そのユーザがどのCognitoグループに属するか、という情報が入っています。詳しくは[こちら](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-user-pools-user-groups.html)を参照。
    * 「custom:~~」に、カスタム属性が入っています。ここに、例えば顧客企業のIDなど、業務処理で必要なデータを設定できます（Cognitoにユーザーを登録するときに、設定されるようにしておきます）。
 
### セッション情報の管理
* セッション情報の管理には、[express-session](https://www.npmjs.com/package/express-session)を使います。Express界隈でのデファクトみたいですね。Expressのミドルウェア（共通処理）として動作します。セッション情報の管理（作成、取得、削除など）をしっかりやってくれるので、とても便利です。
* express-sessionはセッションの保存先（ストア）の実装を持っておらず、ストアへのアクセス部分は別のライブラリが担当します。今回はセッションのストアとしてDynamoDBを利用したかったので、この「別のライブラリ」として[connect-dynamodb](https://www.npmjs.com/package/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()](http://expressjs.com/en/api.html#app.set)でtrust proxyを設定する必要があります。今回はLambda内のaws-serverless-expressからしかExpressは呼ばれないので、単に`app.set('trust proxy', true)`）と設定しちゃいました。
* （これもまた常識ですが）CookieにはHttpOnly属性を必ず付けましょう。express-sessionの設定で制御可能です。デフォルト設定はONなので、知らなくても問題ないかもしれませんが。

## ③ログイン後の画面遷移（認証・認可チェック）
![seq.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/455418/601ff2ab-956d-389a-d0b9-28ad34af155f.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](https://expressjs.com/ja/api.html#req.originalUrl)からアクセス対象のパスを取得します。ユーザが属するグループに、そのパスを実行する権限があるかを判定し、権限がなければエラー画面を表示します。どのグループにどのパスのどのメソッドの実行が許可されるのか、といった定義については、今回は設定ファイルにベタ書きしちゃいました。

# その他

## バリデーション

単項目のバリデーションには、[express-validator](https://express-validator.github.io/docs/)を利用します。

実戦でこれを使うには、色々と工夫が必要です。最終的には以下のようになりました。

### router

```javascript
// 商品登録処理
router.post('/registerItem', validator.forRegisterItem, controller.registerItem);
```

* 単項目のバリデーションについてはvalidatorにまとめて実装します。可読性を高めるためです。

### validator

```javascript
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ミドルウェアで共通処理として定義します。

```javascript
app.use([body('*').trim().escape(), query('*').trim().escape(), param('*').trim().escape()]);
```

### message

```javascript
const BizError = {
  SingleItemValidationError: {
    /** 必須エラー */
    required: '必須項目です。',
    /** 英数字以外が入力された場合のエラー */
    alphanumeric: '英数字で入力してください。',
    /** 桁数上限エラー */
    maxLength: ({ max }) => `${max}文字以下で入力してください。`,
  },
  // 以下、略。
```

* 「●●文字以下で入力してください」といったように、●●の部分を可変にできるようにすべきです。そこで、maxLengthは関数として定義しています。

### controller

```javascript
// 商品登録
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()`でやっていることは[こちらの記事](https://qiita.com/nyandora/items/cd4f12eb62295c10269c#express%E3%81%AB%E3%81%8A%E3%81%91%E3%82%8B%E3%82%A8%E3%83%A9%E3%83%BC%E3%83%8F%E3%83%B3%E3%83%89%E3%83%AA%E3%83%B3%E3%82%B0%E3%83%99%E3%82%B9%E3%83%88%E3%83%97%E3%83%A9%E3%82%AF%E3%83%86%E3%82%A3%E3%82%B9)と同じです。

### validationUtil

```javascript
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

```js: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="登録")
```

```js:common/layout.pug
mixin errMsgsOf(propName)
  if errorMappings && errorMappings[propName]
    each error in errorMappings[propName]
      div #{error.msg} 
```

## Expressミドルウェア設定

* [helmet](https://helmetjs.github.io/)、[noCache](https://www.npmjs.com/package/nocache)を利用して、レスポンスヘッダーを設定します。これにより、XSSなどの対策を行い、セキュリティレベルを高めます。
* その他、セキュリティについては、[Express公式サイトでの解説](http://expressjs.com/ja/advanced/best-practice-security.html)をしっかり把握しておくのが良いです。

# おわりに

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