CloudFront+S3構成にLambda@Edgeを挟み込む

CloudFront経由のS3にホスティングしたWebページにLambda@EdgeでBasic認証をかける。
何度か使いそうなので使いまわせるようCDK化(Python)しておく。

# コンソールから作成

## 1. S3バケットの作成

* 任意のS3バケット名を指定。
* パブリックアクセスのブロックのチェックを外し、作成。
* 任意のindex.htmlファイルをアップロード。

## 2. CloudFrontディストリビューションの作成

* オリジンドメインに上記で作成したS3バケット
* 「S3バケットアクセス」　> 「はい、OAI を使用します」 > 「新しいOAIを作成」
* 「バケットポリシー」 > 「はい、バケットポリシーを自動で更新します」

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406130/f8226abe-3a7a-7e00-acb3-39191298d997.png)

S3に以下のバケットポリシーが自動で設定される。

```
{
    "Version": "2008-10-17",
    "Id": "PolicyForCloudFrontPrivateContent",
    "Statement": [
        {
            "Sid": "1",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity xxxxxxxxxxxx"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/*"
        }
    ]
}
```

### 確認

ディストリビューションドメインにパス(/index.html)を付加してアクセスできることを確認する。

> https://YOUR_DISTRIBUTION.cloudfront.net/index.html

## 3. Lambda@Edge関数の作成

* バージニア北部リージョンへ移動。(バージニア北部でのみLambda@Edge関数を作成可能。)
* 任意の関数名、デフォルトのロールでLambda関数を作成する。
* デフォルトのIAMロールの信頼ポリシーとアクセス権限を編集する。

### 信頼ポリシー

```json:信頼ポリシー
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": [
          "lambda.amazonaws.com",
          "edgelambda.amazonaws.com" <-追加
        ]
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
```

上記を設定しないとデプロイ時に以下のエラーが発生。
> 下のエラーを修正し、もう一度お試しください。
関数の実行ロールは、edgelambda.amazonaws.com サービスプリンシパルによって引き受け可能である必要があります。

上記の修正実行後も表示される場合は、一度Lambdaコンソールをリロードする必要がある。

### アクセス権限

Lambda＠Edgeのログは実行されたロケーションの属するリージョンのCloudWatchLogGroupに書かれるので、各リージョンへのログ書き込みを許可する。
なお、Lambdaコンソール上でテスト実行する結果はバージニア北部で実行され、バージニア北部のログに記録される。

```json:アクセス権限
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:*:123456789012:*" <-編集
            ]
        }
    ]
}
```

* デプロイは 「アクション」 > 「Lambda@Edge へのデプロイ」
* ディストリビューションに上で作成したものを指定する。
* 「CloudFrontイベント」において、アクセスにおけるどの時点でLambda@Edgeを呼ぶかを決定する。
  * ビューアーリクエスト : CloudFrontでのキャッシュの有無確認前に実行される。(実行時間<5sec)
  * オリジンリクエスト : CloudFrontでのキャッシュが無かった場合に、オリジンへ取りに行き得に実行される。(実行時間<30sec)
* 「Lambda@Edgeへのデプロイを確認」にチェック。今後関数はバージョニングされ、更新/デプロイする度に新しいversionが発行され、デプロイされる。
* 2回目以降のデプロイ時には 「アクション」 > 「Lambda@Edge へのデプロイ」 > 「この関数で既存の CloudFront トリガーを使用」を選択してデプロイすることで更新できる。

以下は何もしないLambda関数

```js
exports.handler = async (event) => {
    console.log("start function");
    console.log(JSON.stringify(event));
    const request = event.Records[0].cf.request;
    return request;
};
```

以下は必ず401で返すLambda関数

```js
exports.handler = async (event) => {
    console.log("start function");
    console.log(JSON.stringify(event));
    const response = {
        status: "401",
        statusDescription: "unauthorized",
        body: "<b>Unauthorized</b>"
    };
    return response;
};
```

### 確認

東京リージョンのCloudWatchLogsを開き、Lambda@Edgeデプロイを数分くらい適当に待ったのち、2で用意したディストリビューションドメインにブラウザやPostmanからアクセス。
CloudWatchLogsにロググループが作成されるまで少し待ち、Lambda＠Edge関数名で検索、ログがあることを確認する。

※ なおブラウザからアクセスする場合、htmlとfaviconを取りにそれぞれアクセスされ、2回分ログが残る。

ビューワーリクエストにLambda@Edgeが正しく設定できているのかはCloudFrontコンソールのbehaviorタブ > 関数の関連付け > ビューワーリクエストの項目で確認可能。

## サンプル

### IP制限

特定のIPアドレスからのアクセスのみ許可する方法。
Wi-Fi経由でアクセスする場合と携帯回線でのアクセスで閲覧可否が異なることで確認できる。

```js
'use strict';
exports.handler = async (event) => {
    console.log("start function");
    console.log(JSON.stringify(event));
    const request = event.Records[0].cf.request;
    if (request.clientIp === "ALLOWED_IP") {
        return request;
    }
    const response = {
        status: "401",
        statusDescription: "unauthorized",
        body: "<b>Unauthorized</b>"
    };
    return response;
};
```

### 認証

リクエストにあたり認証を要求する場合、サーバはどの種類の認証を必要とするかをクライアントに指定する`www-authenticate`ヘッダを付して401を返却する。
認証を要求されたクライアントは`Authorization`ヘッダに認証情報を指定して再送する。

#### Basic認証

Authorizationヘッダには、ユーザ名とパスワードをコロンで結合し、base64エンコードしたものを指定し、サーバ側で検証。

```
Authorization: Basic <ユーザ名:パスワード>
```

```js
'use strict';
exports.handler = async (event) => {
    console.log("start function");
    const request = event.Records[0].cf.request;
    const headers = request.headers;
    console.log(JSON.stringify(request));
    
    // config
    const BASIC_AUTH_USER = 'testuser';
    const BASIC_AUTH_PASS = 'qwerty';
    
    const authString = 'Basic ' + Buffer.from(BASIC_AUTH_USER + ':' + BASIC_AUTH_PASS).toString('base64');

    if (typeof headers.authorization == 'undefined' || headers.authorization[0].value != authString) {
        const response = {
            status: "401",
            statusDescription: "unauthorized",
            body: "<b>Unauthorized</b>",
            headers: {
                'www-authenticate': [{key: 'WWW-Authenticate', value:'Basic'}]
            }
        };
        return response;
    }
    return request;
};
```

# CDK化

PythonでCDK化する。

```sh
$ npm install -g aws-cdk
$ cdk --version
1.130.0 (build 9c094ae)
$ cdk init sample-app --language python
$ python --version                  
Python 3.8.5
$ tree 
.
├── README.md
├── app.py # 編集する
├── cdk.json
├── cdk.out
├── stack_file_directory # 編集する
│   └── stack_file_name.py # 編集する
├── requirements.txt
├── setup.py
├── source.bat
├── src # 追加する
│   └── index.js # 追加する
└── tests
```

環境構築においてはワークショップ参照[^1]。

```sh
$ cdk diff
$ cdk deploy
$ cdk destroy --all # Lambda@Edgeが別リージョンで別スタックとして構成されるため
```

足りないモジュールは随時インポートする。

```
$ pip install aws-cdk.MODULE_NAME
```

CDK化したコードは以下に折り畳む。

<details><summary>CDKのコード</summary><div>

```python:stack_file_name.py(old)
from aws_cdk import (
    aws_iam as iam,
    aws_s3 as s3,
    aws_cloudfront as cloudfront,
    aws_lambda as _lambda,
    core
)

class HostingcdkStack(core.Stack):
    def __init__(self, scope: core.Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # == const ==
        bucket_name = "YOUR_BUCKET_NAME"
        cloudfront_name = "YOUR_DISTRIBUTION_NAME"
        oai_name = "YOUR_OAI_NAME"

        # == S3 ==
        bucket = s3.Bucket(self, f"{bucket_name}_bucket",
            bucket_name = bucket_name,
            block_public_access = 
                s3.BlockPublicAccess(
                    block_public_acls=False,
                    block_public_policy=False,
                    ignore_public_acls=False,
                    restrict_public_buckets=False
                ),
            versioned = False,   
        )

        # == Lambda@Edge ==
        auth_lambda = cloudfront.experimental.EdgeFunction(self, "lambda-edge",
            code = _lambda.Code.from_asset("src"),
            handler = "index.handler",
            runtime = _lambda.Runtime.NODEJS_12_X
        )

        # == CloudFront ==
        oai = cloudfront.OriginAccessIdentity(self, oai_name)

        cloudfront.CloudFrontWebDistribution(self, f"{cloudfront_name}_cloudfront",
            origin_configs = [
                cloudfront.SourceConfiguration(
                    s3_origin_source = cloudfront.S3OriginConfig(
                        s3_bucket_source = bucket,
                        origin_access_identity = oai
                        
                    ),
                    behaviors = [
                        cloudfront.Behavior(
                            is_default_behavior=True,
                            lambda_function_associations = [
                                cloudfront.LambdaFunctionAssociation(
                                    event_type = cloudfront.LambdaEdgeEventType.VIEWER_REQUEST,
                                    lambda_function = auth_lambda.current_version,
                                    include_body = False
                                )
                            ]
                        )
                    ]
                )
            ],
            price_class=cloudfront.PriceClass.PRICE_CLASS_ALL
        )

        bucket_policy = iam.PolicyStatement(
            effect = iam.Effect.ALLOW,
            actions = ["s3:GetObject"],
            principals = [
                iam.CanonicalUserPrincipal(
                    oai.cloud_front_origin_access_identity_s3_canonical_user_id
                )
            ],
            resources = [
                f"{bucket.bucket_arn}/*"
            ]
        )

        bucket.add_to_resource_policy(bucket_policy)

```

※ 下は`CloudFrontWebDistribution`は古いっぽいので`Distribution`を使うversion。(`CloudFrontWebDistribution`だとキャッシュポリシーなどが指定できない。レガシーモードになってしまう。)

```python:stack_file_name.py(latest)
from aws_cdk import (
    aws_iam as iam,
    aws_s3 as s3,
    aws_cloudfront as cloudfront,
    aws_cloudfront_origins as cloudfront_origins,
    aws_lambda as _lambda,
    core
)

class HostingcdkStack(core.Stack):
    def __init__(self, scope: core.Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # == const ==
        bucket_name = "YOUR_BUCKET_NAME"
        cloudfront_name = "YOUR_DISTRIBUTION_NAME"
        oai_name = "YOUR_OAI_NAME"

        # == S3 ==
        bucket = s3.Bucket(self, f"{bucket_name}_bucket",
            bucket_name = bucket_name,
            block_public_access = 
                s3.BlockPublicAccess(
                    block_public_acls=False,
                    block_public_policy=False,
                    ignore_public_acls=False,
                    restrict_public_buckets=False
                ),
            versioned = False,   
        )

        # == Lambda@Edge ==
        auth_lambda = cloudfront.experimental.EdgeFunction(self, "lambda-edge",
            code = _lambda.Code.from_asset("src"),
            handler = "index.handler",
            runtime = _lambda.Runtime.NODEJS_12_X
        )

        # == CloudFront ==
        oai = cloudfront.OriginAccessIdentity(self, oai_name)

        cloudfront.Distribution(self, f"{cloudfront_name}_cloudfront",
            default_behavior= cloudfront.BehaviorOptions(
                allowed_methods=cloudfront.AllowedMethods.ALLOW_GET_HEAD,
                cached_methods=cloudfront.CachedMethods.CACHE_GET_HEAD,
                cache_policy=cloudfront.CachePolicy.CACHING_OPTIMIZED,
                origin_request_policy= cloudfront.OriginRequestPolicy(self, f"{cloudfront_name}_origin_request_policy",
                    header_behavior=cloudfront.OriginRequestHeaderBehavior.allow_list("Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"),
                    cookie_behavior=cloudfront.OriginRequestCookieBehavior.none(),
                    query_string_behavior=cloudfront.OriginRequestQueryStringBehavior.none()
                ),
                edge_lambdas=[
                    cloudfront.EdgeLambda(
                        event_type=cloudfront.LambdaEdgeEventType.VIEWER_REQUEST,
                        function_version=auth_lambda.current_version,
                        include_body=False
                    )
                ],
                origin=cloudfront_origins.S3Origin(
                    bucket=bucket,
                    origin_access_identity=oai
                )
            ),
            default_root_object="index.html",
            enabled=True,
            price_class=cloudfront.PriceClass.PRICE_CLASS_ALL
        )

        bucket_policy = iam.PolicyStatement(
            effect = iam.Effect.ALLOW,
            actions = ["s3:GetObject"],
            principals = [
                iam.CanonicalUserPrincipal(
                    oai.cloud_front_origin_access_identity_s3_canonical_user_id
                )
            ],
            resources = [
                f"{bucket.bucket_arn}/*"
            ]
        )

        bucket.add_to_resource_policy(bucket_policy)
```

</div></details>

なお、Lambda@Edgeの作成にはリージョンの指定が必須らしい。
未指定で実行すると以下のエラー。

> stacks which use EdgeFunctions must have an explicitly set region

ここでlambda@Edgeはバージニア北部にしか建てられないことからusを指定するのかと思っていたが`aws_cdk.aws_cloudfront.experimental`がうまいことやってくれるらしい(というか最近そうなったらしい)[^2]。

そのため、他のリソースをデプロイしたいリージョンを指定して実行したらよい。

<details><summary>app.pyのコード</summary><div>

```python:app.py
#!/usr/bin/env python3

from aws_cdk import core

from stack_file_directory.stack_file_name import StackClassName

env = {
    "account": "123456789012",
    "region": "ap-northeast-1"
}

app = core.App()
StackClassName(app, "STACK_NAME", env=env)

app.synth()
```

</div></details>

上記設定の上、`index.html`をS3にアップロードして完了。アクセスして確認。

### 他

* 実行した環境では`ap-northeast-1`、`us-east-1`ともに`CDKToolKit`が存在していたため、もしかしたら実行時に`cdk bootstrap ~~`を実行しろと怒られるかもしれない。`$ cdk deploy`時に表示される案内に従えばいい。
* CDKでCloudFrontを建てると最も近いロケーションではなくコスト優先でロケーションが選択されるため、ログを見失う。price_classを設定することで解決する。[^3]
* CDKで構築するとデフォルトでCloudWatchログへのアクセス権限などが付与されたIAMロールが割り当てられる。
* Lambda@Edge削除時には`$cdk destroy --all`ではエラーする。エラー確認後、コンソールからスタックを消しにいき、レプリカが削除されるのを数分程度待ってからLambda@Edgeを手動削除する。[^4]

```
$ cdk bootstrap aws://123456789012/us-east-1
```

## 参考

https://docs.aws.amazon.com/cdk/api/latest/python/aws_cdk.aws_cloudfront.html

[^1]: https://cdkworkshop.com/30-python/20-create-project/500-deploy.html
[^2]: https://www.dkrk-blog.net/aws/lambda_edge_crossregion
[^3]: https://zenn.dev/yamatatsu/articles/2021-05-25-aws-cdk-cloudfront-price-class
[^4]: https://dev.classmethod.jp/articles/delete-a-stack-that-contains-lambda-edge-function/