S3 + CloudFrontのサイトを署名付きCookieでアクセス制限する

## 概要
![cf-img-1.png](https://qiita-image-store.s3.amazonaws.com/0/109331/8ed18856-c13e-b4bd-ff74-5d98b8709646.png)

- 署名付きCookieを利用してコンテンツへのアクセスを制限する
- CloudFrontをバイパスして直接S3にアクセスされることを防ぐためOAIを使用する
- アクセスできるユーザーはApplication Load Balancerを利用して認証する
  - IDプロバイダーはOktaやAzure ADのようなIDaaSを想定
- 作成した署名付きCookieがCloudFrontで利用できるよう、CloudFrontとApplication Load Balancerのドメインは合わせておく

## ユーザー認証フロー
![cf-img-2.png](https://qiita-image-store.s3.amazonaws.com/0/109331/f7622b6f-0c28-52bc-fae3-e377edced8e6.png)

1. 署名付きCookieがないとCloudFrontはHTTP403エラーになる。
2. 403になった場合、ユーザーにはカスタムエラーページを返す。エラーページにはApplication Load Balancerへ転送する処理が書かれている。
3. Application Load Balancerで認証する。
4. 認証されたユーザーに対し署名付きCookieを作成する。

カスタムエラーページのソースは以下のような感じ。エラーページを返してもブラウザのアドレスバーには元々アクセスしていようとしたページのURLが入っているので認証後にリダイレクトさせるため引数としてLambdaに渡している。

```html
<!DOCTYPE html>
<html lang="ja">
  <title>403</title>
  <script>location.href='https://auth.example.com/auth?r='+location.pathname</script>
```

Lambdaでは以下のようなSet-Cookieヘッダーを使用して署名付きCookieを送信、かつユーザーがアクセスしようとしていたページへリダイレクトさせる、という応答を返す。

```html
HTTP/2.0 200 OK
content-type: text/html
set-cookie: CloudFront-Signature=...;Domain=example.com;path=/;Secure;HttpOnly;
set-cookie: CloudFront-Key-Pair-Id=...;Domain=example.com;path=/;Secure;HttpOnly;
set-cookie: CloudFront-Policy=...;Domain=example.com;path=/;Secure;HttpOnly;

<!DOCTYPE html>
<html lang="ja">
<meta http-equiv="refresh" content="1; url=https://contents.example.com/page.html">
<meta charset="utf-8"> 
<title>redirect...</title>
移動しない場合は<a href="https://contents.example.com/page.html">こちら</a>をクリックしてください
```

## 署名付きCookieの作成
うまくいかなくて悩んだ点について書き残しておく。ちなみにコード全体は[こちら](https://gist.github.com/m28dev/ea56ba7468996c4bb7180361074d47e7)。

### プライベート鍵の使用
今回はAWS Secrets Managerでプライベート鍵を管理することにした。CloudFrontのドキュメントにプライベート鍵は90日おきに更新することがおすすめだと書いてあったのでそういう面でも良い選択だと思っている:japanese_goblin:

ところが署名付きCookie作成時にエラーが発生してしまった。

```
error:0906D06C:PEM routines:PEM_read_bio:no start line
```

原因としてはSecrets Manager保存時にプライベート鍵から改行を削除してしまっていたからだった。

今回は「-----BEGIN CERTIFICATE-----」の直後と「-----END CERTIFICATE-----」の直前だけは改行を入れる（データとラベルを別行にする）ことで乗り切った。.pemに標準仕様があるわけではないようなので通ったもん勝ちってことで。

コードとしては以下のような感じ。Secrets Manager保存時に改行したいところへ空白を入れておき正規表現で置換している。

```js
// 署名付きCookie用のキーペア ID とプライベートキーを取得
const client = new SecretsManager({ region: 'ap-northeast-1' });
const data = await client.getSecretValue({ SecretId: 'restricted-cf-secret' }).promise();
const secretJson = JSON.parse(data.SecretString);

const keyPairId = secretJson.KEY_PAIR_ID;
const privateKey = secretJson.PRIVATE_KEY.replace(/(-----) /, '$1\n').replace(/ (-----)/, '\n$1');
```

### 既定ポリシーとカスタムポリシーの選択
署名付きCookieを使用してさえいれば複数ファイルへのアクセスを許可できると思い込んでいたところ、既定ポリシーでは個別のファイルへのアクセスしか許可できないことに気づけず悩んだ話。

カスタムポリシーで署名付きCookieを作成するコードを以下に置いておく。既定ポリシーだと```Resource```にワイルドカード文字を入れても効かないんだよなあ。

```js
const policy = {
    Statement: [{
        Resource: `https://${distributionSubDomain}.${distributionDomain}/*`,
        Condition: {
            DateLessThan: { "AWS:EpochTime": Math.floor(Date.now() / 1000) + (60 * 60 * 2) } // 2時間後
        }
    }]
};
const signer = new CloudFront.Signer(keyPairId, privateKey);
const signed = signer.getSignedCookie({ policy: JSON.stringify(policy) });
```



### リダイレクトループ
CloudFront + S3で構築した場合、存在しないファイルにアクセスしてもHTTP403エラーとなる。公式ドキュメント曰くセキュリティのためこの動作は変更するな、とのことだった。[^1]

つまり…

1. 存在しないコンテンツにアクセス
2. 403なのでカスタムエラーページが返り、Lambdaが呼び出される
3. Cookieが設定されアクセスしようとしたコンテンツにリダイレクトされる
4. 存在しないコンテンツにアクセス
5. 403なので…以下ループ:innocent:

仕方がないのでクライアントからCookieが送信されていた場合は改めてLambdaからHTTP403エラーを返すことにした。

しかしこれだと署名付きCookieの有効期限が切れた際、ユーザーがCookieをクリアするまでコンテンツにアクセスできなくなってしまうため、Cookieがあっても有効期限切れだった場合は通常の処理を行うというひと味を加えて解決とした。

## 完成
以上、Basic認証じゃなくてIdPに認証してもらいたいんだ！！！というお気持ちの具現化でした。

## 参考ドキュメント
### CloudFront
- [タスクリスト: プライベートコンテンツを供給する](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-task-list.html)

### Application Load Balancer
- [Application Load Balancer を使用してユーザーを認証する](https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/listener-authenticate-users.html)
- [ターゲットとしての Lambda 関数](https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/lambda-functions.html)

[^1]: https://aws.amazon.com/jp/premiumsupport/knowledge-center/s3-website-cloudfront-error-403/