署名付きCookieを用いてリソースへの直接アクセスを制御する。

## 概要
本記事はCloudFrontの署名付きCookieを用いてS3上の特定のリソースへのアクセス制御を構築する手順の忘備録となります。

## 背景
* とあるWebページにおいて、利用しているOSSの問題により、本来ならページのURLを共有するところで表示しているページ内のリソースのURLが共有され、意図せずS3からリソースファイルがダウンロードされるケースが発生していました。
* 構成は下図のようなAWSのCloudFront + S3を用いた形となります。
![【WebAR】署名付きCookieの対応について (5).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/ffef348d-284d-ffc3-c915-9d9cffaa0b40.png)

今回はCloudFrontの署名付きCookieを用いて対策を行ったので設定までの流れを記事として残します。

### 署名付きCookieについて
[署名付きCookie](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-cookies.html)とは、CloudFrontのコンテンツアクセス制御機能の一つで、キーペアを用いてCookieに電子署名を行い、設定したポリシーによって特定のコンテンツへのアクセスを制限する仕組みです。これによりCookieが設定されたブラウザからのみ特定のリソースファイルにアクセスできるようになります。

## 構築
前提としまして、Route53で取得したドメインを用いてCloudFront + S3の構成でWebページを既に構築している状態から構築を進めます。

### CloudFrontの設定①
#### キーペアの作成
*  [ドキュメント](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-trusted-signers.html#private-content-creating-cloudfront-key-pairs)の条件に従いキーペアを作成します。
```
$ openssl genrsa -out private_key.pem 2048
$ openssl rsa -pubout -in private_key.pem -out public_key.pem
```
#### 公開鍵の登録
* キー管理のパブリックキーに作成したpublic_key.pemの中身を登録します。
![スクリーンショット 2024-11-26 10.04.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/37b0b98d-6b96-7170-74a6-1ca703c2b709.png)

#### キーグループの作成
* キー管理のキーグループに先ほど登録した公開鍵を含んだグループを作成します。
![スクリーンショット 2024-11-26 13.55.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/13b74ff1-88d5-9388-8b3d-4d9c6158b01b.png)


#### ビヘイビアの作成
* 作成済みのディストリビューションに対して、アクセス制限を行うためのビヘイビアを作成します。
  * 例えばWebページで扱うmp4ファイルに対して署名付きCookieがないとアクセスできないよう制限を掛ける場合、`/*.mp4`がパスパターンとなります。
![screencapture-us-east-1-console-aws-amazon-cloudfront-v4-home-2024-11-26-10_17_36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/819987d4-38da-8055-3753-39aac8360ef9.png)

これにより署名付きCookieがない場合、Webページから対象のファイルへのアクセスが行えなくなります。

次に署名付きCookieを付与する仕組みを作成します。
現在WebページはCloudFrontの関数によりBasic認証を掛けており、認証後にAPIでCookieが設定されるよう下図のように追加の構築を行います。

![【WebAR】署名付きCookieの対応について (6).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/e3513c99-6736-4582-81a2-5a23da17163a.png)

## APIの作成
* 署名付きCookieをセットするAPIを東京リージョン(ap-northeast-1)に構築します。
  * CookieをセットするためにAPIは構築済みのWebサイトと同じドメインとする必要があります。
### Lambda作成
* 今回はNode.js 18.xでaws-sdkの`getSignedCookies`を用いて署名付きCookieを取得します。
  * 参考：[@aws-sdk/cloudfront-signer](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-cloudfront-signer/)内Get signed cookies with a Policy
```
import { getSignedCookies } from "@aws-sdk/cloudfront-signer"; // ESM

const s3ObjectKey = process.env.S3_OBJECT_KEY;
const url = `${s3ObjectKey}`;
const privateKey = (process.env.PRIVATE_KEY).replace(/\\n/g, '\n');
const keyPairId = process.env.KEY_PAIR_ID;

const policy = {
  Statement: [
    {
      Resource: url,
      Condition: {
        DateLessThan: {
          'AWS:EpochTime': Math.floor((Date.now() + 24 * 60 * 60 * 1000) / 1000)
        },
      },
    },
  ],
};

const policyString = JSON.stringify(policy);

const cookies = getSignedCookies({
  keyPairId,
  privateKey,
  policy: policyString,
});

export const handler = async (event) => {
  // カスタムドメインを設定
  const origin = 'custom-domain';

  const cookieString = [
    `CloudFront-Key-Pair-Id=${cookies['CloudFront-Key-Pair-Id']}; Path=/; Secure; SameSite=Strict`,
    `CloudFront-Policy=${cookies['CloudFront-Policy']}; Path=/; Secure; SameSite=Strict`,
    `CloudFront-Signature=${cookies['CloudFront-Signature']}; Path=/; Secure; SameSite=Strict`
  ].join('; ');

  const response = {
    statusCode: 200,
    headers: {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': origin,
      'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
      'Access-Control-Allow-Credentials': 'true',
    },
    multiValueHeaders:{
      'Set-Cookie': [
        `CloudFront-Key-Pair-Id=${cookies['CloudFront-Key-Pair-Id']}; Path=/; Domain=${origin}; Secure; SameSite=None;`, 
        `CloudFront-Policy=${cookies['CloudFront-Policy']}; Path=/; Domain=${origin}; Secure; SameSite=None`,
        `CloudFront-Signature=${cookies['CloudFront-Signature']}; Path=/; Domain=${origin}; Secure; SameSite=None`,
      ],
    },
    body: JSON.stringify({
      message: 'Signed cookies have been set'
    })
  };
  
  return response;
};
```

#### 環境変数
* S3_OBJECT_KEY：CF経由のアクセス制御対象のファイルパス。(e.g. `https://custom-domain/resource/*`)
* PRIVATE_KEY：作成した秘密鍵の改行を`\n`に置換した文字列。
  * 秘密鍵は現在環境変数から取得していますが、SecretsManagerから取得するよう変更予定です。
* KEY_PAIR_ID：CloudFrontに登録したパブリックキーのID。

### AWS Certificate Manager 設定
* API Gatewayで独自ドメインを利用するため、API Gatewayを構築するリージョン(今回はap-northeast-1)でパブリック証明書の発行を行います。
#### 証明書をリクエスト
![screencapture-ap-northeast-1-console-aws-amazon-acm-home-2024-11-26-14_19_04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/d801e2c6-b981-5b97-663c-fd0126bc0b60.png)

### API Gateway作成
クッキーセットに際しWebページと同じドメインでAPIを呼べるようAPI Gatewayを作成します。
#### API
##### APIを作成
![screencapture-ap-northeast-1-console-aws-amazon-apigateway-main-create-rest-2024-11-26-14_39_09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/7f69429e-6d71-ab88-8d11-54d4fe022753.png)

##### リソースを作成
![screencapture-ap-northeast-1-console-aws-amazon-apigateway-main-apis-dfp5veiugc-resources-5zkslzu70a-create-resource-2024-11-26-14_57_52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/9eb40c61-8acb-5212-a9d8-b44a4f2afd34.png)

##### メソッドを作成
![screencapture-ap-northeast-1-console-aws-amazon-apigateway-main-apis-dfp5veiugc-resources-5zkslzu70a-create-method-2024-11-26-14_58_25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/014a4280-e215-9cfb-5a35-018482ba01df.png)

上記のAPIをデプロイします。ステージ名は暫定で「test」としています。
![スクリーンショット 2024-11-26 16.10.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/19837e56-dab4-f70d-95d8-19821db045dd.png)


#### カスタムドメイン名を作成
##### ドメイン名を追加
![screencapture-ap-northeast-1-console-aws-amazon-apigateway-main-publish-domain-names-create-2024-11-26-15_02_43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/d798ba69-68c4-9046-cc90-9acba04e5964.png)

登録したカスタムドメインに作成済みのAPIとステージをマッピングします。
このとき、パスにはドメイン内でAPIを分岐するためのパスパターンを設定します。(e.g. `api`)
![screencapture-ap-northeast-1-console-aws-amazon-apigateway-main-publish-domain-names-mappings-2024-11-26-15_08_46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/3e11e057-d623-97a5-9eb2-f5d1e073ee45.png)

このパスパターンで後ほどCloudFrontのビヘイビアに登録を行います。

### CloudFrontの設定②
* API Gatewayにルーティングするためのオリジン登録します。
#### オリジンを作成
![screencapture-us-east-1-console-aws-amazon-cloudfront-v4-home-2024-11-26-15_17_21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/bd7bde52-dd39-0808-7774-d8d3de5f1a64.png)

#### ビヘイビアの作成
![screencapture-us-east-1-console-aws-amazon-cloudfront-v4-home-2024-11-26-15_17_54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/bc3c3849-2c25-0c4c-2848-4e1c306b7d71.png)

### APIの呼び出し
最後にWebページより作成したAPI(`https://custom-domain/api/API名`)を呼び出し、ブラウザにCookieが設定されていることを確認します。
![スクリーンショット 2024-11-26 15.29.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/698760fe-4ded-c55e-ca66-098528e23de5.png)


## カスタムエラーの登録
現在の設定では署名付きCookieが設定されていない状態で対象のリソースにアクセスがあるとCloudFrontのエラーページが表示されます。
![スクリーンショット 2024-11-26 15.34.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/8fe8de39-c963-ed91-def7-7464571aed54.png)

このままでも良いのですが、若干不親切なのでS3にエラーページのHTMLを追加してCloudFrontから403エラーの際に参照する形で設定します。
![スクリーンショット 2024-11-26 15.32.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/4d0dc66f-98d9-e556-f064-b8bce6245994.png)

カスタムエラー設定後のエラーページ
![スクリーンショット 2024-11-26 15.38.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/482413/b0e61bf4-433b-478e-a1ad-fdbdaaa2492a.png)

## さいごに
今回は署名付きCookieで対応を行いましたが、署名付きCookieを設定した上で対象リソースのURLがわかればファイルをDLできてしまうという問題が残っています。HTMLからの参照は許可しつつファイルへの直接アクセスを制御する方法がないか引き続き検討を行っていこうと思います。