署名付きクッキーによるCloudFront配信コンテンツの保護

# やりたいこと
概要イメージです。
![署名付きクッキー _やりたいこと.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1509934/aa762ed0-d13c-e788-d48a-49fb03f4b741.png)


社内システムをAWS内に配置することを想定したケースで、S3バケットからCloudFrontにより配信するコンテンツ(HTML、JavaScriptソースや画像等のリソース)を認可されたアクセス以外から保護します。
プライベートコンテンツ、というようですね。
[プライベートコンテンツ提供の概要 - Amazon CloudFront](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-overview.html)

認証APIはAPI Gatewayに配置し、CloudFrontで配信されるコンテンツ(HTML・JS等)とAPIはクロスオリジンかつクロスドメインになります。クッキーのSameSite=Noneを避けるため、Route53を利用してルートドメインを一致させます。
※実のところ、ルートドメインを別にして、SameSite=Noneの指定を試したのですが、クッキーが設定できませんでした。できるはずだと思っているのですが・・・。

# やること
* CloudFront OAI(オリジンアクセスアイデンティティ)によるS3アクセスの制限
* CloudFront 署名付きクッキーによるコンテンツ保護
    * キーペア作成
    * 公開鍵をキーグループに登録
    * パスパターン別の動作(Behaviors)設定で信頼されたキーグループを登録
* CloudFrontとAPI Gatewayのドメイン設定
* CORSの設定
    * リクエスト
    * プリフライトレスポンス(OPTIONS)
    * 本レスポンス
* Secrets Managerの利用
    * 秘密鍵をSecrets Managerに登録
    * IAMポリシーの作成
* Lambda 署名付きクッキーの作成と設定
    * Lambda ロール設定
    * Lambda レイヤー設定
    * Lambda コードの作成（署名の作成とクッキーの設定)

# CloudFront OAI(オリジンアクセスアイデンティティ)によるS3アクセスの制限
S3へのアクセスをCloudFrontからのみに制限します。
はじめてであればCloudFrontのオリジン設定でOAI作成とバケットポリシーの更新まで行うことができます。
CloudFront Dstributions → (ディストリビューションを選択) → Origins and Origion Groups → Origins Origin → (Originを選択、Edit)
![OAI設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1509934/f4319f7e-3b97-ce08-390d-5ade1753ca2d.png)

OAI横のiボタンの説明によるとOAIは毎回作成ではなく再利用が推奨されています。
S3バケットポリシーにひとつのOAI設定をしておいて、複数のCloudFrontディストリビューションでそのOAIを利用することができますね。
おそらく`S3バケット(多):(多)CloudFrontディストリビューション`の紐付きを1つのOAIで設定できそうですが、少なくともプロジェクト、本番・開発・テスト等では分けておいた方がよさそうです。

[オリジンアクセスアイデンティティを使用して Amazon S3 コンテンツへのアクセスを制限する - Amazon CloudFront
](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html)

# CloudFront 署名付きクッキーによるコンテンツ保護
CloudFrontでパスパターンによるアクセス保護を行います。デフォルト`Default(*)`で認可なしではアクセスできないように設定しますが、ログインページは認可なしでアクセスさせる必要がありますので`login/`配下はアクセス保護の対象外とします。
![署名付きクッキー_パスパターン別アクセスイメージ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1509934/afbce8ea-8a56-4ed7-a96d-c768c7c847ec.png)

`Default(*)`にTrusted Key Groupを設定します。その上でアクセス保護の対象外とするパスパターンを追加して、優先順位(Precedence)を`Default(*)`よりも上に設定します。こちらはTrusted Key Groupを設定しません。
下記、設定イメージです。（login配下に加えて、ルート直下のindex.htmlのみアクセス保護の対象外として、署名付きクッキーがない場合にlogin/index.htmlに遷移させるJavaScriptを埋め込みました）
![URLパターン別設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1509934/9a11bf9c-c684-341a-e902-5e8017aa3750.png)



下記の手順で進めます。

* キーペア作成
* 公開鍵をキーグループに登録
* パスパターン別の動作(Behaviors)設定で信頼されたキーグループを登録

キーペアの作成はAWSコンソール上ではなくローカルでopenssl等を使って行います。
下記の記事が参考になりました。
[root ユーザー作業が不要に！Amazon CloudFront で署名付き URL/Cookie 向け公開鍵を IAM ユーザー権限で管理できるようになりました。 | DevelopersIO](https://dev.classmethod.jp/articles/cloudfront-iam-signed-url)
[署名付き URL と署名付き Cookie を作成できる署名者の指定 - Amazon CloudFront](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-trusted-signers.html)

# CloudFrontとAPI Gatewayのドメイン設定
「やること」に書いたようにCloudFrontとAPI Gatewayのルートドメインを一致させます。
下記にようにCloudFrontに独自ドメインを設定し、API Gatewayをそのサブドメインにする等するとよいでしょう。

||ドメイン|
|-|-|
|CloudFront|example.com|
|API Gateway|api.example.com|

CloudFrontの独自ドメイン設定は下記記事が参考になります。
[Route53を用いてCloudFrontに独自ドメインを設定する - Qiita](https://qiita.com/N_H_tennis/items/c910d65f5aff27bfb93d)
[お名前.comからRoute53へネームサーバーを変更する時に迷子になる人へ - Qiita](https://qiita.com/ysKey2/items/0545e13ec05def42ad55)

API Gatewayのサブドメイン設定については単独の記事にしました。
[Amazon API Gatewayを独自サブドメインで使う - Qiita](https://qiita.com/ytkayamura/items/fd76aab6155633b70fac)

# CORSの設定
クロスオリジンになりますので、CORSの設定が必要になります。
プリフライトリクエスト(OPTIONS)はMockを利用しますので、レスポンスヘッダの設定はAPI Gatewayの設定として行います。
本リクエストでは動的にクッキーを作成して返すため「Lambda プロキシ統合」を使用し、レスポンスヘッダはLambdaの戻り値に設定します。

## CORS設定 - リクエスト
クッキーを返すためにリクエストで`withCredentials`を指定します。
axiosを利用すると下記のようになります。

```js:js
  const res = await axios.post(
    "https://api.example.com/login/login",
    { ...body },
    { withCredentials: true },
  );
```

## CORS設定 - プリフライトレスポンス(OPTIONS)
API Gateway → リソース → アクション → CORSの有効化 の操作でレスポンスヘッダの初期設定できます。
`Access-Control-Allow-Origin`は`'*'`ではなく明示的にCloudFrontのオリジンを設定しましょう。
リクエストの`withCredentials`の指定に対応して、`Access-Control-Allow-Credentials`の設定も必要になります。

レスポンスヘッダ設定の編集は「メソッドレスポンス」よりヘッダの追加・削除、ヘッダ値の設定は「統合レスポンス」より行います。
![OPTIONSメソッド設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1509934/d4b81901-fe5f-ecf5-9027-55b2355482ba.png)
![OPTIONSレスポンスヘッダ2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1509934/b57cc653-3226-9729-795b-cc48c34546e3.png)
`Access-Control-Allow-Method`にはOPTIONSと本リクエストのメソッドを設定します。ただ、私の試したところではこのヘッダ自体を削除しても動作しました。

## CORS設定 - 本レスポンス
API Gateway → リソース → アクション → メソッドの作成 より本リクエストのメソッドを作成します。
動的にクッキーを作成して返すために「Lambdaプロキシ統合」を使用します。
![Lamdaプロキシ統合の設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1509934/d1b6e9c4-fda5-9cef-a37f-70777b1e084e.png)

Lambdaプロキシ統合を使用するとLambdaの戻り値にレスポンスヘッダを設定することになります。
[API Gateway で Lambda プロキシ統合を設定する - Amazon API Gateway](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-output-format)

`Access-Control-Allow-Origin`と`Access-Control-Allow-Credentials`を設定します。

```python:python3
    return {
	  'statusCode': 200,
	  'headers': {
	    'Access-Control-Allow-Origin': 'https://api.example.com',
	    'Access-Control-Allow-Credentials': True,
	  },
	  'body': body,
	}
```
# Secrets Managerの利用
上で作成したキーペアの秘密鍵をシークレットマネージャーに登録し、Lambdaから取得します。
Lambdaからシークレットマネージャーを使用するロールに追加するIAMポリシーを作成します。

## 秘密鍵をSecrets Managerに登録

下記の記事が参考になります。
[AWS Secrets ManagerでEC2キーペア秘密鍵の安全な受け渡しを管理してみる | DevelopersIO](https://dev.classmethod.jp/articles/managing-keypairs-with-secrets-manager/)

シークレットの作成が完了すると、シークレットの個別画面にLambdaからのシークレットの取得のサンプルコードが表示されます。

## IAMポリシーの作成
IAM → ポリシー → ポリシーの作成
サービスにSecrets Managerを指定し、アクションの読み込みを選択します。
リソースは指定したほうがセキュアです。上で作成したシークレットのARNを指定します。
![IAMポリシーの作成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1509934/8c3eaa1b-ace9-76f8-3396-690b04ecf5b9.png)


# Lambda 署名付きクッキーの作成と設定
Lambdaの処理で署名付きクッキーを作成して返します。Lambdaの言語はPython3を採用します。
現時点(2021年6月)では、boto3やbotocoreに署名や署名付きクッキーを作成する処理がありません。下記のコードを参考に作成します。
[CloudFront — Boto3 Docs documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/cloudfront.html)
[Python script that generates signed cookies to control access to CloudFront content](https://gist.github.com/mjohnsullivan/31064b04707923f82484c54981e4749e)
この参考コードの中で使われている`cryptography`の利用にはLambdaレイヤーの追加が必要になります。


## Lambda ロール設定
Lambda → 関数 → (関数名を選択) → 設定 → アクセス権限 → 実行ロール → (ロール名を選択) → 「ポリシーをアタッチします」
![ポリシーのアタッチ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1509934/c0452f6d-6ebb-8852-216e-252ad11ec703.png)
ポリシー名は作成時につけた名前です。

この記事では扱いませんが、認証(Cognito等)やDBアクセスのためのポリシーも必要がに応じて追加します。
予めポリシーを追加したロールを作成しておき、Lambdaに設定しても構いません。この場合、AWSLambdaBasicExecutionRole(CloudWatch Logs書き込み)ポリシーを追加しておきましょう。


## Lambda レイヤー設定
暗号化ライブラリ`cryptography`を利用可能とするレイヤーを作成して、Lambdaに追加します。
下記の記事が参考になります。
[Lambdaでcryptographyを使う（Python3.8 + cryptography） - Qiita](https://qiita.com/Bacchus/items/7dcfc0237df196d1ab67)
[AWS Lambda Layersでライブラリを共通化 - Qiita](https://qiita.com/t_okkan/items/394a15577bd1aad46ec3)

## Lambda コードの作成（署名の作成とクッキーの設定)
署名と[ポリシー](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-setting-signed-cookie-canned-policy.html)を作成します。
`get_secret()`はシークレット個別画面のサンプルコードを参考に作成します。

```python:Python3
  origin = 'https://example.com'
  resource_url = origin + '/*'
  expire_at = datetime.now() + timedelta(days=1)
  secret_name = 'xxx'
  key_pair_id = 'xxx'

  priv_key = get_secret(secret_name)   # rsa_signer()内で使用
  def rsa_signer(message):
    private_key = serialization.load_pem_private_key(
      priv_key,
      password=None,
      backend=default_backend()
    )
    return private_key.sign(message, padding.PKCS1v15(), hashes.SHA1())

  def get_cookies(rsa_signer, key_pair_id, resource_url, expire_at):
    cf_signer = CloudFrontSigner(key_pair_id, rsa_signer)
    policy = cf_signer.build_policy(resource_url, expire_at).encode('utf8')
    policy_64 = cf_signer._url_b64encode(policy).decode('utf8')
    signature = rsa_signer(policy)
    signature_64 = cf_signer._url_b64encode(signature).decode('utf8')

    return {
      'CloudFront-Policy': policy_64,
      'CloudFront-Signature': signature_64,
      'CloudFront-Key-Pair-Id': key_pair_id,
  }
  cookies = get_cookies(rsa_signer, key_pair_id, resource_url, expire_at)
```
クッキーをヘッダーに設定します。上で説明したCORS用のヘッダも合わせて設定します。

```python:Python3
  domain = 'example.com'
  return {
    'statusCode': 200,
    'headers': {
        'Access-Control-Allow-Origin': origin,
        'Access-Control-Allow-Credentials': True,
    },
    'multiValueHeaders': {
      'Set-Cookie': [
          'CloudFront-Policy=%s; domain=%s; path=/;Secure;HttpOnly' % (cookies['CloudFront-Policy'], domain),
          'CloudFront-Signature=%s; domain=%s; path=/;Secure;HttpOnly' % (cookies['CloudFront-Signature'], domain),
          'CloudFront-Key-Pair-Id=%s; domain=%s; path=/;Secure;HttpOnly' % (cookies['CloudFront-Key-Pair-Id'], domain),
      ],
    },
    'body': json.dumps('success'),
  }
```

# おわりに

・SameSite=Noneで別ドメインでのクッキー設定ができなかった
・Access-Control-Allow-Methodはどういう時に必要なのか
など、心残り、理解不足なところがありますが、なんとかやりたいことはできました。

数多く嵌りましたが、特に苦労したのは下記あたりですかね。
・署名作成の参考コードは私がこの課題に挑戦していた当時はなかなか検索にかからなかった
・Lambdaから動的にヘッダーを返すための「Lambdaプロキシ統合」になかなかたどり着けなかった

署名作成の参考コードは今検索したら日本語の記事が見つかりました。コード以外にもいろいろ丁寧に書いてくれています。（この記事が当時あれば。。）
[CloudFront 署名付きURLと署名付きCookieをおさらいしてPythonで試してみた | DevelopersIO](https://dev.classmethod.jp/articles/cloudfront-signed-url-and-cookie-using-python/)

TODO：Lambdaのひと通りのコードサンプルをgithubにあげようと思います。