NRIネットコム Blog

NRIネットコム社員が様々な視点で、日々の気づきやナレッジを発信するメディアです

Amazon S3とAmazon CloudFrontによる静的ウェブサイトにSSL/TLS証明書(AWS Certificate Manager)・基本認証(Lambda@Edge)・IP制限(AWS WAF)をクロスリージョンで追加するAWS CloudFormationテンプレートとAWS Lambdaカスタムリソース

小西秀和です。
この記事は過去に投稿した次の記事の続編で、SSL/TLS証明書(AWS Certificate Manager)、基本認証(Lambda@Edge)に加えてIP制限(AWS WAF)を追加したパターンでAmazon S3とAmazon CloudFrontによる静的ウェブサイトホスティングをAWS CloudFormationテンプレートとAWS Lambdaカスタムリソースを使用して構成するものです。

今回は更にOrigin Access Identity(OAI)をOrigin Access Control (OAC)に変更し、キャッシュポリシー(CachePolicy)、オリジンリクエストポリシー(OriginRequestPolicy)、レスポンスヘッダーポリシー(ResponseHeaderPolicy)をマネージドポリシーから選択式で設定できるようにしています。
ここではAWSマネジメントコンソールでの操作とAWS WAFを柔軟に設定することを重視し、AWS AmplifyやAWS CDKはあえて使用していません。
AWSマネジメントコンソールから特定のリージョンにAmazon AWS CloudFormationをデプロイし、us-east-1で作成が必要なAWSリソースをAWS Lambdaカスタムリソースを使用してクロスリージョンで作成します。

AWS Amplify、AWS CDKを使用した静的ウェブサイトホスティングについては以下の記事に基本的な内容を書いていますので、そちらをご参考に御覧ください。
* AWSの静的ウェブサイトホスティングで入門するAWS Amplify(Console、CLI) - 概要編
* AWSの静的ウェブサイトホスティングで入門するAWS Amplify(Console、CLI) - 構築編(Amplify Console)
* AWSの静的ウェブサイトホスティングで入門するAWS Amplify(Console、CLI) - 構築編(Amplify CLI)
* AWS Amplify CLIとAWS CloudFormationでAmplify Console Hostingと同じ機能の再現を試みる - AWS CloudFormationによるAWS Amplify CLIの拡張
* AWS CDKで別リージョンにSSL証明書・基本認証・レプリケーション用S3バケットを作成するスタックをデプロイしてAmazon CloudFrontオリジンフェイルオーバーを設定する

※本記事および当執筆者のその他の記事で掲載されているソースコードは自主研究活動の一貫として作成したものであり、動作を保証するものではありません。使用する場合は自己責任でお願い致します。また、予告なく修正することもありますのでご了承ください。

今回の記事の内容は次のような構成になっています。

本記事で試す構成の概要図

今回は次の手順で概要図の構成をAWS CloudFormationとカスタムリソースを使用して作成することを試しています。
初回作成時と2回目更新時で処理を分けている理由はAmazon CloudFrontのDistribution IDがAmazon CloudFront作成後にしか取得できず、Distribution IDが必要なAWSリソースを作成した後に再び関連付けのためにAmazon CloudFrontを更新する必要があるからです。

【初回作成時】

  • 呼出元AWS CloudFormationスタックをパラメータにAmazon CloudFrontのDistribution IDを入力せずに作成する
  • 呼出元AWS CloudFormationスタックはデプロイしたリージョンでOriginAccessControl(OAC)、Amazon S3バケット、Amazon CloudFrontディストリビューションを作成する
  • 呼出元AWS CloudFormationスタックはカスタムリソースを使用して次の内容をus-east-1へデプロイし、作成した結果を呼出元AWS CloudFormationスタックに返却する
    • AWS Certificate Manager(ACM)証明書
  • 呼出元AWS CloudFormationスタックは呼出元リージョンのリソースを作成し、カスタムリソースからの返却値を使用してus-east-1リージョンの「AWS Certificate Manager(ACM)証明書」をAmazon CloudFrontに関連付ける

【2回目更新時】

  • 呼出元AWS CloudFormationスタックをパラメータにAmazon CloudFrontのDistribution IDを入力して更新する
  • 呼出元AWS CloudFormationスタックは次の内容をus-east-1にデプロイするカスタムリソースを呼び出し、作成した結果を呼出元AWS CloudFormationスタックに返却する
    • IP制限をするAWS WAF Web ACL
    • 基本認証をするAWS Lambda@Edgeとそのバージョン
  • 呼出元AWS CloudFormationスタックは呼出元リージョンのリソースを更新してOriginAccessControl(OAC)を関連付け、カスタムリソースからの返却値を使用してus-east-1リージョンの「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」をAmazon CloudFrontに関連付ける

AWS LambdaカスタムリソースによるSSL証明書・基本認証・IP制限のスタックデプロイと関連付けの例
AWS LambdaカスタムリソースによるSSL証明書・基本認証・IP制限のスタックデプロイと関連付けの例

テンプレートの名称設定

AWS CloudFormationテンプレートに定義するAWSリソースは一意に必要なものを識別して連携できれば各ファイルの名称は任意ですが、説明のために次のように各ファイルの名称を設定します。

  • 任意のリージョンへAmazon S3とAmazon CloudFrontによる静的ウェブサイトを作成し、カスタムリソースを使用してSSL/TLS証明書(AWS Certificate Manager)・基本認証(Lambda@Edge)・IP制限(AWS WAF)をus-east-1リージョンにデプロイして関連付けるAWS CloudFormationテンプレート(呼び出し元)
    WebHostCFnS3CloudFrontWithAcmLambdaEdgeWaf.yml
  • 呼び出し元テンプレートから呼び出され、指定したAmazon S3にあるAWS CloudFormationテンプレートを使用してリソースをus-east-1リージョンへデプロイするカスタムリソースを作成するAWS CloudFormationテンプレート(カスタムリソース作成用)
    WebHostCFnCustomResourceToDeployAWS CloudFormationStack.yml
  • カスタムリソースからデプロイし、us-east-1リージョンへSSL/TLS証明書用AWS Certificate Managerを作成するAWS CloudFormationテンプレート(SSL/TLS証明書用AWS Certificate Manager)
    WebHostWebHostCFnACMCertificate.yml
  • カスタムリソースからデプロイし、us-east-1リージョンへ基本認証用Lambda@Edgeを作成するAWS CloudFormationテンプレート(基本認証用Lambda@Edge)
    WebHostWebHostCFnBasicAuthLambdaEdge.yml
  • カスタムリソースからデプロイし、us-east-1リージョンへIP制限用AWS WAFを作成するAWS CloudFormationテンプレート(IP制限用AWS WAF)
    WebHostCFnWAFWebACL.yml

各ファイルやパラメータの例

各ファイルやパラメータの例を記載していきます。
特に入力パラメータに関しては入力形式を知っていただくための例なので、使用する場合は各パラメータを要件に合わせて設定する必要があります。

AWS CloudFormationテンプレート(カスタムリソース作成用)

参考記事:AWS LambdaカスタムリソースでAWS CloudFormationスタックを別リージョンにデプロイする

テンプレート本体

ファイル名:WebHostCFnCustomResourceToDeployCloudformationStack.yml

AWSTemplateFormatVersion: '2010-09-09'
Description: 'CFn Template for a stack that creates AWS Lambda Custom Resource which deploys AWS CloudFormation stack.'
Resources:
  LambdaCustomResourceToDeployCFnStack:
    Type: AWS::Lambda::Function
    DependsOn: 
      - LambdaCustomResourceToDeployCFnStackRole
    Properties:
      FunctionName: LambdaCustomResourceToDeployCFnStack
      Description : 'AWS Lambda Custom Resource which deploys AWS CloudFormation stack.'
      Runtime: python3.9
      MemorySize: 10240
      Timeout: 900
      Role: !GetAtt LambdaCustomResourceToDeployCFnStackRole.Arn
      Handler: index.lambda_handler
      Code:
        ZipFile: |
          # ## このカスタムリソースの特徴
          # * カスタムリソース仕様のレスポンスを返すメソッドを自前で作成した
          #   CloudFormationスタックから呼び出す場合はcfnresponse.sendが使用できますが、Lambda単体での実行やテストには使用できないためcfn_response_sendという自前のメソッドを作成しました。
          #   参考:https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html
          # * デプロイするテンプレートをS3またはハードコーディングから選択できるようにした
          #   S3からテンプレートを取得する方法では様々なテンプレートが実行できます。一方で特定のテンプレート実行に特化させたい場合はハードコーディングを選択することで他のテンプレートを実行させないようにできます。
          # * カスタムリソースからデプロイするCloudFormationテンプレートの引数の指定を簡素化した
          #   デプロイするCloudformationテンプレートの引数として使用しないキーを指定して、それ以外を渡すことで引数の受け渡しを汎用的にしました。
          # * 要求された処理と異なる状況の場合でもリソースが作成される方針に寄せた
          #   要求されたCreate、Update処理をそのままCloudFormationのデプロイに使用するのではなく、Update処理でスタックが存在しない場合にはCreate処理に切り替えるなど、なるべくリソースが作成される方針に寄せました。
          # * 処理のスキップを選択できるようにした
          #   呼出元で依存関係を維持しながらCreate処理では使用せず、Update処理から使用するといったことができるようにスキップ処理が選択できるようにしました。

          import urllib3
          import json
          import boto3
          import botocore
          import datetime
          # import cfnresponse

          SUCCESS = 'SUCCESS'
          FAILED = 'FAILED'

          http = urllib3.PoolManager()

          # 呼出元のCloudformationスタックから受けた引数のうち、デプロイするCloudformationスタックの引数として使用しないキー
          config = {
              "ExcludeKeys": ["ServiceToken", "StackName", "CfnTplS3Bucket", "CfnTplS3Key", "IsSkip"]
          }

          # デプロイするCloudformationテンプレートを直接コードに記述したい場合はここに記述
          fixed_cfn_template = '''
          '''

          # us-east-1リージョンにCloudformationスタックをデプロイするクライアント
          cfn_client = boto3.client('cloudformation', region_name='us-east-1')
          # Cloudformationテンプレートを取得するS3リソース
          s3_resource = boto3.resource('s3')

          # カスタムリソースの仕様でCloudFormationにレスポンスを返す独自メソッド
          # CloudFormationスタックから呼び出す場合はcfnresponse.sendが使用できるが、Lambda単体での実行には使用できないため独自メソッドを使用する
          def cfn_response_send(event, context, response_status, response_data, physical_resource_id=None, no_echo=False, reason=None):
              response_url = event['ResponseURL']
              print(response_url)

              response_body = {
                  'Status': response_status,
                  'Reason': reason or 'See the details in CloudWatch Log Stream: {}'.format(context.log_stream_name),
                  'PhysicalResourceId': physical_resource_id or context.log_stream_name,
                  'StackId': event['StackId'],
                  'RequestId': event['RequestId'],
                  'LogicalResourceId': event['LogicalResourceId'],
                  'NoEcho': no_echo,
                  'Data': response_data
              }

              json_response_body = json.dumps(response_body)

              print('Response body:')
              print(json_response_body)

              headers = {
                  'content-type': '',
                  'content-length': str(len(json_response_body))
              }

              try:
                  response = http.request('PUT', response_url, headers=headers, body=json_response_body)
                  print('Status code:', response.status)
              except Exception as e:
                  print('cfn_response_send(..) failed executing http.request(..):', e)

          def lambda_handler(event, context):
              response_data = {}

              try:
                  print(('event:' + json.dumps(event, indent=2)))

                  resource_properties = event['ResourceProperties']
                  is_skip = resource_properties.get('IsSkip')

                  # IsSkipにtrueが入ってきた場合は何も処理せず空のレスポンスを返す
                  # 呼出元でカスタムリソース処理をスキップする場合に使用する
                  if is_skip is not None and is_skip.strip().lower() == 'true':
                      print('Skip All Process.')
                      cfn_response_send(event, context, SUCCESS, response_data)
                      # cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)
                      return

                  stack_name = resource_properties.get('StackName')

                  exclude_keys = config['ExcludeKeys']

                  cfn_params = []
                  for key, val in resource_properties.items():
                      if key not in exclude_keys:
                          cfn_params.append({
                              'ParameterKey': key,
                              'ParameterValue': val
                          })

                  print('----------[Start]cfn_params:----------')
                  print(cfn_params)
                  print('----------[End]cfn_params:------------')

                  cfn_template = fixed_cfn_template
                  try:
                      # fixed_cfn_template変数に直接テンプレートを記述していない場合は指定されたS3バケットのオブジェクトからテンプレートを取得する
                      if cfn_template.strip() == '':
                          cfn_tpl_s3_bucket = resource_properties['CfnTplS3Bucket']
                          cfn_tpl_s3_key = resource_properties['CfnTplS3Key']
                          bucket = s3_resource.Bucket(cfn_tpl_s3_bucket)
                          obj = bucket.Object(cfn_tpl_s3_key).get()
                          cfn_template = obj['Body'].read().decode('utf-8')
                          print('----------[Start]cfn_template:----------')
                          print(cfn_template)
                          print('----------[End]cfn_template:------------')
                  except Exception as e:
                      print('lambda_handler(..) failed executing read s3 obj(..):', e)
                      raise

                  if event['RequestType'] == 'Delete':
                      try:
                          print('Delete Stacks')
                          # Delete処理の場合は単純に削除する
                          print('Run cfn_client.delete_stack.')
                          response = cfn_client.delete_stack(
                              StackName=stack_name
                          )

                          print('cfn_client.delete_stack response:')
                          print(response)

                          # スタックのDelete処理が完了するまで待つ
                          waiter = cfn_client.get_waiter('stack_delete_complete')
                          waiter.wait(StackName=stack_name)
                      except (botocore.exceptions.ValidationError, botocore.exceptions.ClientError) as err:
                          err_msg = str(err)
                          print('err_msg:')
                          print(err_msg)
                          #相互に関連付けしたリソース同士で削除エラーのループが発生するため、例外は上げない。

                  if event['RequestType'] == 'Create' or event['RequestType'] == 'Update':
                      # Create処理とUpdate処理の実行方針
                      # Create処理要求かつスタックが存在しない ⇒ Create処理
                      # Create処理要求かつスタックが存在する ⇒ 何もしない
                      # Update処理要求かつスタックが存在しない ⇒ Create処理
                      # Update処理要求かつスタックが存在するかつ変更点が存在する ⇒ Update処理
                      # Update処理要求かつスタックが存在するかつ変更点が存在しない ⇒ 何もしない

                      # 同名のスタックが存在するかを示すフラグ
                      exist_stack = False

                      try:
                          print('Run cfn_client.describe_stacks.')
                          existing_stacks = cfn_client.describe_stacks(StackName=stack_name)
                          print('Existing Stacks:')
                          print(existing_stacks)

                          # スタックが存在し、describe_stacksが正常に処理されればexist_stackはTrue
                          exist_stack = True
                      except (botocore.exceptions.ValidationError, botocore.exceptions.ClientError) as err:
                          err_msg = str(err)
                          print('err_msg:')
                          print(err_msg)
                          # スタックが存在せず、describe_stacksがエラーになればexist_stackはFalse
                          if 'DescribeStack' in err_msg and 'does not exist' in err_msg:
                              print(('describe_stacks error: ' + err_msg))
                              exist_stack = False
                          else:
                              raise

                      # Create処理が必要かを示すフラグ
                      need_create = True

                      if event['RequestType'] == 'Update':
                          print('Update Stacks')
                          if exist_stack == False:
                              # Update処理かつスタックが存在しない場合はCreate処理でスタックを作成する
                              need_create = True
                          else:
                              # Create処理が必要かどうかを示すフラグ
                              need_create = False
                              # すでにスタック名に一致するスタックが存在すればUpdateで処理する
                              try:
                                  print('Run cfn_client.update_stack.')
                                  response = cfn_client.update_stack(
                                      StackName=stack_name,
                                      TemplateBody=cfn_template,
                                      Parameters=cfn_params,
                                      Capabilities=[
                                          'CAPABILITY_NAMED_IAM'
                                      ]
                                  )

                                  print('cfn_client.update_stack response:')
                                  print(response)

                                  # スタックのUpdate処理が完了するまで待つ
                                  waiter = cfn_client.get_waiter('stack_update_complete')
                                  waiter.wait(StackName=stack_name)
                              except (botocore.exceptions.ValidationError, botocore.exceptions.ClientError) as err:
                                  err_msg = str(err)
                                  print('err_msg:')
                                  print(err_msg)
                                  # Update処理を要求されたが、変更点がない場合は何もしない
                                  if 'UpdateStack' in err_msg and 'No updates are to be performed' in err_msg:
                                      print(('update_stacks error: ' + err_msg))
                                  else:
                                      raise

                      if event['RequestType'] == 'Create' or need_create == True:
                          print('Create Stacks')
                          # Create処理要求かつスタックが存在しない場合、またはUpdate処理要求かつスタックが存在しない場合にスタックをCreate処理する
                          if exist_stack == False:
                              print('Run cfn_client.create_stack.')
                              response = cfn_client.create_stack(
                                  StackName=stack_name,
                                  TemplateBody=cfn_template,
                                  Parameters=cfn_params,
                                  Capabilities=[
                                      'CAPABILITY_NAMED_IAM'
                                  ]
                              )

                              print('cfn_client.create_stack response:')
                              print(response)

                              # スタックのCreate処理が完了するまで待つ
                              waiter = cfn_client.get_waiter('stack_create_complete')
                              waiter.wait(StackName=stack_name)

                      # CreateまたはUpdateされたスタックを取得する
                      stacks = cfn_client.describe_stacks(StackName=stack_name)

                      # Outputsの内容を取得し、返却値として整形する
                      outputs = stacks['Stacks'][0]['Outputs']
                      print(('Outputs:' + json.dumps(outputs, indent=2)))

                      for output in outputs:
                          response_data[output['OutputKey']] = output['OutputValue']
                      print(('Outputs:' + json.dumps(response_data, indent=2)))
                  # CloudFormationカスタムリソース仕様で呼出元のスタックにレスポンスを返す
                  cfn_response_send(event, context, SUCCESS, response_data)
                  # cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)

                  return
              except Exception as e:
                  print('Exception:')
                  print(e)
                  # CloudFormationカスタムリソース仕様で呼出元のスタックにレスポンスを返す
                  cfn_response_send(event, context, FAILED, response_data)
                  # cfnresponse.send(event, context, cfnresponse.FAILED, response_data)

                  return

  LambdaCustomResourceToDeployCFnStackRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: IAMRole-LambdaCustomResourceToDeployCFnStack
      Path: /
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - edgelambda.amazonaws.com
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
      - PolicyName: IAMPolicy-LambdaCustomResourceToDeployCFnStack
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - cloudformation:CancelUpdateStack
                - cloudformation:ContinueUpdateRollback
                - cloudformation:Describe*
                - cloudformation:Get*
                - cloudformation:List*
                - cloudformation:CreateStack
                - cloudformation:UpdateStack
                - cloudformation:DeleteStack
                - cloudformation:ValidateTemplate
                - s3:GetObject
                - s3:ListAllMyBuckets
                - s3:ListBucket
                - iam:AttachRolePolicy
                - iam:CreatePolicy
                - iam:CreatePolicyVersion
                - iam:CreateRole
                - iam:DeletePolicy
                - iam:DeletePolicyVersion
                - iam:DeleteRole
                - iam:DeleteRolePermissionsBoundary
                - iam:DeleteRolePolicy
                - iam:DetachRolePolicy
                - iam:GetPolicy
                - iam:GetPolicyVersion
                - iam:GetRole
                - iam:GetRolePolicy
                - iam:ListAttachedRolePolicies
                - iam:ListInstanceProfilesForRole
                - iam:ListPolicyTags
                - iam:ListPolicyVersions
                - iam:ListRolePolicies
                - iam:ListRoles
                - iam:ListRoleTags
                - iam:PassRole
                - iam:PutRolePermissionsBoundary
                - iam:PutRolePolicy
                - iam:SetDefaultPolicyVersion
                - iam:TagPolicy
                - iam:TagRole
                - iam:UntagPolicy
                - iam:UntagRole
                - iam:UpdateAssumeRolePolicy
                - iam:UpdateRole
                - iam:UpdateRoleDescription
                - acm:*
                - lambda:*
                - route53:*
                - secretsmanager:*
                - wafv2:*
              Resource:
                - '*'
            - Effect: Allow
              Action:
                - logs:CreateLogGroup
              Resource:
                - 'arn:aws:logs:*:*:*'
            - Effect: Allow
              Action:
                - logs:CreateLogStream
                - logs:PutLogEvents
              Resource:
                - !Sub 'arn:aws:logs:*:*:log-group:/aws/lambda/LambdaCustomResourceToDeployCFnStack:*'

AWS CloudFormationテンプレート(呼び出し元)

入力パラメータ例

ACMCFnStackName: WebHostCFnACMCertificate
ACMCustomDomainName: cfn-acm-edge-waf-cfnt-s3.h-o2k.com
ACMHostedZoneId: ZZZZZZZZZZZZZZZZZZZZZ
ACMS3BucketNameOfStoringTemplate: h-o2k
ACMS3KeyOfStoringTemplate: WebHostCFnACMCertificate.yml
CloudFrontCachePolicyName: CachingDisabled
CloudFrontDistributionID: 
CloudFrontOriginRequestPolicyName: NONE
CloudFrontResponseHeaderPolicyName: CORS-with-preflight-and-SecurityHeadersPolicy
CustomResourceLambdaARN: arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:LambdaCustomResourceToDeployCFnStack
Env: dev
LambdaEdgeBasicAuthFuncName: WebHostCFnBasicAuthLambdaEdge
LambdaEdgeBasicAuthID: Iam
LambdaEdgeBasicAuthPW: Nobody
LambdaEdgeCFnStackName: WebHostCFnBasicAuthLambdaEdge
LambdaEdgeS3BucketNameOfStoringTemplate: h-o2k
LambdaEdgeS3KeyOfStoringTemplate: WebHostCFnBasicAuthLambdaEdge.yml
S3BucketName: cfn-acm-edge-waf-cfnt-s3-20230311144618
S3BucketVersioningStatus: Suspended
WAFWebACLAllowIPList: XXX.XXX.XXX.XXX/16,YYY.YYY.YYY.YYY/24,ZZZ.ZZZ.ZZZ.ZZZ/32
WAFWebACLCFnStackName: WebHostCFnWAFWebACL
WAFWebACLResourcePrefix: WebHostIPRestrictions
WAFWebACLS3BucketNameOfStoringTemplate: h-o2k
WAFWebACLS3KeyOfStoringTemplate: WebHostCFnWAFWebACL.yml

テンプレート本体

ファイル名:WebHostCFnS3CloudFrontWithAcmLambdaEdgeWaf.yml

AWSTemplateFormatVersion: '2010-09-09'
Description: 'CFn Template for a stack that creates ACM, Lambda@Edge, WAF, and S3+CloudFront Hosting.'
#<作成する順番>
#ACM証明書[US]→OriginAccessControl[JP]→S3バケットポリシー(作成)[JP]→S3バケット[JP]→CloudFront(作成)[JP]→Lambda@Edge(CloudFrontDistributionIDで作成)[US]
#→S3バケットポリシー(更新:CloudFrontDistributionIDでOCA設定)[JP]→CloudFront(更新:Lambda@Edge設定)[JP]
Parameters:
  Env: #【共通】作成するS3バケットのサフィックスに追加する環境名
    Type: String
    Default: dev
  S3BucketName: #【共通】静的ウェブサイトホスティングに使用するS3バケット名
    Type: String
    Default: cfn-acm-edge-waf-cfnt-s3-20230311144618
  S3BucketVersioningStatus: #【共通】静的ウェブサイトホスティングに使用するS3バケットのバージョニング設定
    Type: String
    Default: Suspended
    AllowedValues:
    - Enabled
    - Suspended
  CustomResourceLambdaARN: #【共通】ACM証明書、基本認証用Lambda@Edge、IP制限用AWS WAFを作成するスタックをus-east1にデプロイするLambdaカスタムリソースのARN
    Type: String
    Default: arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:LambdaCustomResourceToDeployCFnStack
  ACMCFnStackName: #【ACM用】ACM証明書を作成するためにus-east1にデプロイするCloudformationスタック名
    Type: String
    Default: WebHostCFnACMCertificate
  ACMS3BucketNameOfStoringTemplate: #【ACM用】ACM証明書を作成するCloudformationテンプレートを保存しているバケット名
    Type: String
    Default: h-o2k
  ACMS3KeyOfStoringTemplate: #【ACM用】ACM証明書を作成するCloudformationテンプレートを保存しているバケット以降のオブジェクトキー
    Type: String
    Default: WebHostCFnACMCertificate.yml
  ACMCustomDomainName: #【ACM用】ACM証明書を発行する対象ドメイン名
    Type: String
    Default: cfn-acm-edge-waf-cfnt-s3.h-o2k.com
  ACMHostedZoneId: #【ACM用】ACM証明書を発行する対象ドメイン名を管理するRoute53のホストゾーンID
    Type: String
    Default: ZZZZZZZZZZZZZZZZZZZZZ
  WAFWebACLCFnStackName: #【WAFWebACL用】AWS WAF WebACLを作成するためにus-east1にデプロイするCloudformationスタック名
    Type: String
    Default: WebHostCFnWAFWebACL
  WAFWebACLS3BucketNameOfStoringTemplate: #【WAFWebACL用】AWS WAF WebACLを作成するCloudformationテンプレートを保存しているバケット名
    Type: String
    Default: h-o2k
  WAFWebACLS3KeyOfStoringTemplate: #【WAFWebACL用】AWS WAF WebACLを作成するCloudformationテンプレートを保存しているバケット以降のオブジェクトキー
    Type: String
    Default: WebHostCFnWAFWebACL.yml
  WAFWebACLResourcePrefix: #【WAFWebACL用】AWS WAF WebACLのリソースにつけるPrefix  
    Type: String
    Default: WebHostIPRestrictions
  WAFWebACLAllowIPList: #【WAFWebACL用】AWS WAF WebACLのルールでIP制限をするCIDR
    #Type: CommaDelimitedList
    Type: String
    Default: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
  LambdaEdgeCFnStackName: #【Lambda@Edge用】Lambda@Edgeを作成するためにus-east1にデプロイするCloudformationスタック名
    Type: String
    Default: WebHostCFnBasicAuthLambdaEdge
  LambdaEdgeS3BucketNameOfStoringTemplate: #【Lambda@Edge用】Lambda@Edgeを作成するCloudformationテンプレートを保存しているバケット名
    Type: String
    Default: h-o2k
  LambdaEdgeS3KeyOfStoringTemplate: #【Lambda@Edge用】Lambda@Edgeを作成するCloudformationテンプレートを保存しているバケット以降のオブジェクトキー
    Type: String
    Default: WebHostCFnBasicAuthLambdaEdge.yml
  LambdaEdgeBasicAuthFuncName: #【Lambda@Edge用】基本認証をするLambda@Edgeの関数名
    Type: String
    Default: WebHostCFnBasicAuthLambdaEdge
  LambdaEdgeBasicAuthID: #【Lambda@Edge用】基本認証をするLambda@Edgeで認証するID。AWS Secrets Managerシークレットとして保管する。
    Type: String
    Default: Iam
  LambdaEdgeBasicAuthPW: #【Lambda@Edge用】基本認証をするLambda@Edgeで認証するパスワード。AWS Secrets Managerシークレットとして保管する。
    Type: String
    Default: Nobody
  CloudFrontCachePolicyName: #【共通】Amazon CloudFrontのキャッシュポリシーの指定。マネージドポリシーからの選択式。  
    Type: String
    Default: CachingDisabled
    AllowedValues:
      - NONE
      - Amplify
      - CachingDisabled
      - CachingOptimized
      - CachingOptimizedForUncompressedObjects
      - Elemental-MediaPackage
  CloudFrontOriginRequestPolicyName: #【共通】Amazon CloudFrontのオリジンリクエストポリシー。マネージドポリシーからの選択式。  
    Type: String
    Default: NONE
    AllowedValues:
      - NONE
      - AllViewer
      - AllViewerAndCloudFrontHeaders-2022-06
      - AllViewerExceptHostHeader
      - CORS-CustomOrigin
      - CORS-S3Origin
      - Elemental-MediaTailor-PersonalizedManifests
      - UserAgentRefererHeaders
  CloudFrontResponseHeaderPolicyName: #【共通】Amazon CloudFrontのレスポンスヘッダーポリシー。マネージドポリシーからの選択式。  
    Type: String
    Default: CORS-with-preflight-and-SecurityHeadersPolicy
    AllowedValues:
      - NONE
      - SimpleCORS
      - CORS-With-Preflight
      - SecurityHeadersPolicy
      - CORS-and-SecurityHeadersPolicy
      - CORS-with-preflight-and-SecurityHeadersPolicy
  CloudFrontDistributionID: #【共通】各リソースの関連付けに使用するAmazon CloudFrontのDistribution ID。
    #Amazon S3バケットポリシーのOriginAccessControl関連付け、Lambda@Edgeで使用するAWS Secrets Managerシークレットの一意識別、AWS WAFの関連付けで使用する。
    Type: String #※スタックの初回Create処理でAmazon CloudFrontを作成してから、2回目のUpdate処理で入力する。
Mappings: 
  CloudFrontCachePolicyIds:
    NONE:
      Id: ""
    Amplify:
      Id: 2e54312d-136d-493c-8eb9-b001f22f67d2
    CachingDisabled:
      Id: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
    CachingOptimized:
      Id: 658327ea-f89d-4fab-a63d-7e88639e58f6
    CachingOptimizedForUncompressedObjects:
      Id: b2884449-e4de-46a7-ac36-70bc7f1ddd6d
    Elemental-MediaPackage:
      Id: 08627262-05a9-4f76-9ded-b50ca2e3a84f
  CloudFrontOriginRequestPolicyIds:
    NONE:
      Id: ""
    AllViewer:
      Id: 216adef6-5c7f-47e4-b989-5492eafa07d3
    AllViewerAndCloudFrontHeaders-2022-06:
      Id: 33f36d7e-f396-46d9-90e0-52428a34d9dc
    AllViewerExceptHostHeader:
      Id: b689b0a8-53d0-40ab-baf2-68738e2966ac
    CORS-CustomOrigin:
      Id: 59781a5b-3903-41f3-afcb-af62929ccde1
    CORS-S3Origin:
      Id: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf
    Elemental-MediaTailor-PersonalizedManifests:
      Id: 775133bc-15f2-49f9-abea-afb2e0bf67d2
    UserAgentRefererHeaders:
      Id: acba4595-bd28-49b8-b9fe-13317c0390fa
  CloudFrontResponseHeaderPolicyIds:
    NONE:
      Id: ""
    CORS-and-SecurityHeadersPolicy:
      Id: e61eb60c-9c35-4d20-a928-2b84e02af89c
    CORS-With-Preflight:
      Id: 5cc3b908-e619-4b99-88e5-2cf7f45965bd
    CORS-with-preflight-and-SecurityHeadersPolicy:
      Id: eaab4381-ed33-4a86-88ca-d9558dc6cd63
    SecurityHeadersPolicy:
      Id: 67f7725c-6f97-4210-82d7-5512b31e9d03
    SimpleCORS:
      Id: 60669652-455b-4ae9-85a4-c4c02393f86c
Conditions:
  IsEnv:
    !Equals [!Ref Env, NONE]
  IsCloudFrontDistributionID: #入力パラメータにAmazon CloudFrontのDistribution IDがある場合はTrueでLambda@Edgeを作成、ない場合はFalseでLambda@Edge作成をスキップ。
    !Not [!Equals [!Ref CloudFrontDistributionID, ""]]
Resources:
  S3Bucket:
    Type: AWS::S3::Bucket
    #DependsOn:
    #DeletionPolicy: Retain
    Properties:
      BucketName:
        !If [IsEnv, !Ref S3BucketName, !Join ["", [!Ref S3BucketName, "-", !Ref Env]]]
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: index.html
      CorsConfiguration:
        CorsRules:
          - {
              AllowedHeaders: ["*"],
              AllowedMethods: ["GET","PUT","POST","DELETE","HEAD"],
              AllowedOrigins: ["*"],
              MaxAge: 3000,
            }
      VersioningConfiguration:
        Status: !Ref S3BucketVersioningStatus

  PrivateBucketPolicy:
    Type: AWS::S3::BucketPolicy
    DependsOn:
      - OriginAccessControl
      - S3Bucket
    Properties:
      Bucket: !Ref S3Bucket
      PolicyDocument:
        Statement:
        - Action: s3:GetObject
          Effect: Allow
          Resource: !Sub ${S3Bucket.Arn}/*
          Principal:
            Service: cloudfront.amazonaws.com
          Condition:
            StringEquals:
              AWS:SourceArn:
                !If
                  - IsCloudFrontDistributionID
                  - !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistributionID}
                  - !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/*
  OriginAccessControl: 
    Type: AWS::CloudFront::OriginAccessControl
    Properties: 
      OriginAccessControlConfig:
        Description: !Join ["", ["OAC-", !If [IsEnv, !Ref S3BucketName, !Join ["", [!Ref S3BucketName, "-", !Ref Env]]]]]
        Name: !Join ["", ["OAC-", !If [IsEnv, !Ref S3BucketName, !Join ["", [!Ref S3BucketName, "-", !Ref Env]]]]]
        OriginAccessControlOriginType: s3
        SigningBehavior: always
        SigningProtocol: sigv4
  #CloudFrontをエイリアスレコードとしてRoute53ホストゾーンに登録するRoute53レコードセット
  Route53RecordSetGroup:
    Type: AWS::Route53::RecordSetGroup
    DependsOn:
      - CloudFrontDistribution
    Properties:
      HostedZoneId:
        !Ref ACMHostedZoneId
      RecordSets:
        - Name: !Ref ACMCustomDomainName
          Type: A
          #CloudFrontをエイリアスレコードとして登録する場合はエイリアスターゲットのHostedZoneIdが次の固定値となる
          #参考:https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/quickref-route53.html
          AliasTarget:
            HostedZoneId: Z2FDTNDATAQYW2
            DNSName: !GetAtt CloudFrontDistribution.DomainName
  #ACM証明書を発行するために呼び出すカスタムリソース
  CustomResourceForACM:
    Type: Custom::CustomResourceForACM
    Properties:
      ServiceToken: !Ref CustomResourceLambdaARN
      StackName: !Ref ACMCFnStackName
      CfnTplS3Bucket: !Ref ACMS3BucketNameOfStoringTemplate
      CfnTplS3Key: !Ref ACMS3KeyOfStoringTemplate
      CustomDomainName: !Ref ACMCustomDomainName
      HostedZoneId: !Ref ACMHostedZoneId
  #AWS WAF WebACLを作成するために呼び出すカスタムリソース
  CustomResourceForWAFWebACL:
    Type: Custom::CustomResourceForWAFWebACL
    Properties:
      ServiceToken: !Ref CustomResourceLambdaARN
      StackName: !Ref WAFWebACLCFnStackName
      CfnTplS3Bucket: !Ref WAFWebACLS3BucketNameOfStoringTemplate
      CfnTplS3Key: !Ref WAFWebACLS3KeyOfStoringTemplate
      ResourcePrefix: !Ref WAFWebACLResourcePrefix
      AllowIPList: !Ref WAFWebACLAllowIPList
      IsSkip: ##入力パラメータにAmazon CloudFrontのDistribution IDがない場合はカスタムリソースにIsSkip:trueを送信し、AWS WAF WebACL作成処理をスキップする。
        !If [IsCloudFrontDistributionID, "false", "true"]
  #Lambda@Edgeを作成するために呼び出すカスタムリソース
  CustomResourceForLambdaEdge:
    Type: Custom::CustomResourceForLambdaEdge
    Properties:
      ServiceToken: !Ref CustomResourceLambdaARN
      StackName: !Ref LambdaEdgeCFnStackName
      CfnTplS3Bucket: !Ref LambdaEdgeS3BucketNameOfStoringTemplate
      CfnTplS3Key: !Ref LambdaEdgeS3KeyOfStoringTemplate
      CloudFrontDistId: !Ref CloudFrontDistributionID
      BasicAuthFuncName: !Ref LambdaEdgeBasicAuthFuncName
      BasicAuthID: !Ref LambdaEdgeBasicAuthID
      BasicAuthPW: !Ref LambdaEdgeBasicAuthPW
      IsSkip: ##入力パラメータにAmazon CloudFrontのDistribution IDがない場合はカスタムリソースにIsSkip:trueを送信し、Lambda@Edge作成処理をスキップする。
        !If [IsCloudFrontDistributionID, "false", "true"]
  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    DependsOn:
      #OriginAccessControlが作成されてからCloudFrontに関連付けるためDependsOnにカスタムリソースを追加
      - OriginAccessControl
      #Amazon S3バケットが作成されてからCloudFrontに関連付けるためDependsOnにカスタムリソースを追加
      - S3Bucket
      #カスタムリソースでACM証明書が発行されてからCloudFrontに関連付けるためDependsOnにカスタムリソースを追加
      - CustomResourceForACM
      #カスタムリソースでAWS WAF WebACLが作成されてからCloudFrontに関連付けるためDependsOnにカスタムリソースを追加
      - CustomResourceForWAFWebACL
      #カスタムリソースで基本認証用Lambda@Edgeバージョンが作成されてからCloudFrontに関連付けるためDependsOnにカスタムリソースを追加
      - CustomResourceForLambdaEdge
    Properties:
      DistributionConfig:
        #CloudFrontのエイリアスにACM証明書を発行したドメイン名を設定する
        Aliases:
          - !Ref ACMCustomDomainName
        #CloudFrontにカスタムリソースで作成したACM証明書を設定する
        ViewerCertificate:
          SslSupportMethod: sni-only
          MinimumProtocolVersion: TLSv1.2_2021
          AcmCertificateArn: !GetAtt CustomResourceForACM.AcmCertificateArn
        WebACLId:
          !If
            - IsCloudFrontDistributionID
            - !GetAtt CustomResourceForWAFWebACL.WAFv2WebACLArn
            - !Ref AWS::NoValue
        HttpVersion: http2
        Origins:
          - DomainName: !GetAtt S3Bucket.RegionalDomainName
            Id: StaticWebsiteHostingS3Bucket
            OriginAccessControlId: !GetAtt OriginAccessControl.Id
            S3OriginConfig: 
              OriginAccessIdentity: ""
        Enabled: true
        DefaultCacheBehavior:
          AllowedMethods: [GET, HEAD, OPTIONS]
          TargetOriginId: StaticWebsiteHostingS3Bucket #オリジンをターゲットに指定する
          ForwardedValues:
            QueryString: false
          ViewerProtocolPolicy: redirect-to-https
          CachePolicyId: !FindInMap [ CloudFrontCachePolicyIds, !Ref CloudFrontCachePolicyName , Id ]
          OriginRequestPolicyId: !FindInMap [ CloudFrontOriginRequestPolicyIds, !Ref CloudFrontOriginRequestPolicyName , Id ]
          ResponseHeadersPolicyId: !FindInMap [ CloudFrontResponseHeaderPolicyIds, !Ref CloudFrontResponseHeaderPolicyName , Id ]
          #DefaultTTL: 86400
          #MaxTTL: 31536000
          #MinTTL: 60
          #Compress: true
          LambdaFunctionAssociations:
            !If
              - IsCloudFrontDistributionID
              - [
                  {
                    EventType: viewer-request,
                    IncludeBody: "true",
                    LambdaFunctionARN: !GetAtt CustomResourceForLambdaEdge.LambdaFunctionVersionArn
                  }
                ]
              - !Ref AWS::NoValue
        DefaultRootObject: index.html
        CustomErrorResponses:
          - {
              ErrorCachingMinTTL: 10,
              ErrorCode: 400,
              ResponseCode: 200,
              ResponsePagePath: /,
            }
          - {
              ErrorCachingMinTTL: 10,
              ErrorCode: 404,
              ResponseCode: 200,
              ResponsePagePath: /,
            }
Outputs:
  Region:
    Value:
      !Ref AWS::Region
  HostingS3BucketName:
    Description: "Hosting bucket name"
    Value:
      !Ref S3Bucket
  ACMCustomDomainURL:
    Value:
      !Join ["", ["https://", !Ref ACMCustomDomainName]]
    Description: "Web hosting URL with Certificate"
  S3BucketWebsiteURL:
    Value:
      !GetAtt S3Bucket.WebsiteURL
    Description: "URL for website hosted on S3"
  S3BucketSecureURL:
    Value:
      !Join ["", ["https://", !GetAtt S3Bucket.DomainName]]
    Description: "Name of S3 bucket to hold website content"
  CloudFrontDistributionID:
    Value:
      !Ref CloudFrontDistribution
  CloudFrontDomainName:
    Value:
      !GetAtt CloudFrontDistribution.DomainName
  CloudFrontSecureURL:
    Value:
      !Join ["", ["https://", !GetAtt CloudFrontDistribution.DomainName]]
  CloudFrontOriginAccessControl:
    Value:
      !Ref OriginAccessControl
  CustomResourceSkiped:
    Value:
      !If [IsCloudFrontDistributionID, "false", "true"]

AWS CloudFormationテンプレート(SSL/TLS証明書用AWS Certificate Manager)

テンプレート本体

ファイル名:WebHostWebHostCFnACMCertificate.yml

AWSTemplateFormatVersion: "2010-09-09"
Description: "CFn Template for a stack that creates AWS CertificateManager Certificate."
Parameters:
  CustomDomainName: #ACM証明書を発行する対象のカスタムドメイン名
    Type: String
  HostedZoneId: #ACM証明書を発行する対象のカスタムドメインを管理しているAmazon Route53ホストゾーンID
    Type: String
Resources:
  CertificateManagerCertificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: !Ref CustomDomainName
      DomainValidationOptions:
        - DomainName: !Ref CustomDomainName
          HostedZoneId: !Ref HostedZoneId
      ValidationMethod: DNS #カスタムドメインの検証はRoute53のDNSを使用する方法で実施する
Outputs:
  Region:
    Value: !Ref AWS::Region
  AcmCertificateArn:
    Value: !Ref CertificateManagerCertificate #返却値はACM証明書のARN

AWS CloudFormationテンプレート(基本認証用Lambda@Edge)

テンプレート本体

ファイル名:WebHostWebHostCFnBasicAuthLambdaEdge.yml

AWSTemplateFormatVersion: '2010-09-09'
Description: 'CFn Template for a stack that creates Lambda@Edge Version and AWS Secrets Manager Secret.'
Parameters:
  CloudFrontDistId: #基本認証用Lambda@Edgeを呼び出すAmazon CloudFrontのDistribution ID。AWS Secrets ManagerのシークレットIDに使用する。
    Type: String    #※このCloudFormationテンプレートではAmazon CloudFrontとLambda@Edgeバージョンの関連付けはしないため注意。関連付けは呼出元で実施する想定。
  BasicAuthFuncName: #基本認証用Lambda@Edgeの関数名
    Type: String
  BasicAuthID: #AWS Secrets Managerシークレットに保管し、基本認証で使用するID。
    Type: String
  BasicAuthPW: #AWS Secrets Managerシークレットに保管し、基本認証で使用するパスワード。
    Type: String
Resources:
  LambdaEdgeBasicAuth:
    Type: AWS::Lambda::Function
    DependsOn: 
      - SecretsManagerSecret
      - LambdaEdgeBasicAuthRole
    Properties:
      FunctionName: !Ref BasicAuthFuncName
      Runtime: python3.8
      MemorySize: 128 #Lambda@Edgeのクォータ最大値を設定
      Timeout: 5 #Lambda@Edgeのクォータ最大値を設定
      Role: !GetAtt LambdaEdgeBasicAuthRole.Arn
      Handler: index.lambda_handler
      Code:
        ZipFile: |
          # 基本認証を実施するLambda@Edgeの関数
          import json
          import boto3
          import base64
          # ID、パスワードを保管し、認証に使用するAWS Secrets Managerを扱うクライアントを作成
          asm_client = boto3.client('secretsmanager', region_name='us-east-1')
          # エラーの場合のレスポンスを定義
          err_response = {
              'status': '401',
              'statusDescription': 'Unauthorized',
              'body': 'Authentication Failed.',
              'headers': {
                  'www-authenticate': [
                      {
                        'key': 'WWW-Authenticate',
                        'value': 'Basic realm="Basic Authentication"'
                      }
                  ]
              }
          }
          
          def lambda_handler(event, context):
              try:
                  print('event:')
                  print(event)
                  # イベントからCloudFrontのリクエストを取得
                  request = event['Records'][0]['cf']['request']
                  # イベントからCloudFrontのDistribution IDを取得
                  cf_dist_id = event['Records'][0]['cf']['config']['distributionId']
                  # リクエストからヘッダーを取得
                  headers = request['headers']
                  if (headers.get('authorization') != None):
                      # ヘッダーにauthorizationがあれば認証を試行する
                      # headers['authorization'][0]['value']の中身は「Basic <base64でエンコードされた[ID:Password]の文字列>」
                      # authorizationの内容を分解してID、パスワードを取り出す
                      target_credentials_str = headers['authorization'][0]['value'].split(
                          " ")
                      target_credentials = base64.b64decode(
                          target_credentials_str[1]).decode().split(":")
                      target_id = target_credentials[0]
                      target_pw = target_credentials[1]
          
                      # 取得したCloudFrontのDistribution IDおよび基本認証で入力されたIDでシークレットを取得する
                      response = asm_client.get_secret_value(
                          SecretId='CloudFrontBasicAuth/' +
                          cf_dist_id + '/' + str(target_id)
                      )
                      # シークレットが取得でき、格納されている文字列が基本認証で入力されたパスワードと一致すれば認証成功としてリクエストを返却する。
                      # それ以外の場合はエラーレスポンスを返す。
                      if (response.get('SecretString') != None):
                          secret_string = json.loads(response['SecretString'])
                          if (secret_string.get('Password') == target_pw):
                              return request
                          else:
                              return err_response
                      else:
                          return err_response
          
                  else:
                      return err_response
          
              except Exception as e:
                  print("Exception:")
                  print(e)
          
                  return err_response
          
  LambdaEdgeBasicAuthVersion: #呼出元スタックでAmazon CloudFrontと関連付けるためのLambdaEdgeバージョンを作成する
    Type: AWS::Lambda::Version
    DependsOn: 
      - LambdaEdgeBasicAuth
    Properties:
      FunctionName: !Ref LambdaEdgeBasicAuth
      Description: 'Basic Auth Lambda Edge for Amazon CloudFront'
  LambdaEdgeBasicAuthRole: #基本認証用Lambda@Edgeに適用するIAMロールとIAMポリシーの設定
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub 'IAMRole-${BasicAuthFuncName}'
      Path: /
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - edgelambda.amazonaws.com
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
      - PolicyName: !Sub 'IAMPolicy-${BasicAuthFuncName}'
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
          - Effect: Allow
            Action:
            - iam:CreateServiceLinkedRole
            Resource:
            - '*'
          - Effect: Allow
            Action:
            - lambda:GetFunction
            - lambda:EnableReplication
            Resource:
            - !Sub 'arn:aws:lambda:us-east-1:${AWS::AccountId}:function:${BasicAuthFuncName}'
          - Effect: Allow
            Action:
            - cloudfront:UpdateDistribution
            Resource:
            - !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistId}'
      - PolicyName: SecretsManagerGetSecretValue
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
          - Effect: Allow
            Action:
            - secretsmanager:GetSecretValue
            Resource:
            - !Sub 'arn:aws:secretsmanager:us-east-1:${AWS::AccountId}:secret:CloudFrontBasicAuth/${CloudFrontDistId}/*'
  SecretsManagerSecret: #基本認証に使用するID、パスワードを格納するAWS Secrets Managerシークレットの作成
    Type: 'AWS::SecretsManager::Secret'
    Properties:
      Name: !Sub CloudFrontBasicAuth/${CloudFrontDistId}/${BasicAuthID}
      SecretString: !Sub '{"Password":"${BasicAuthPW}"}'
      Description: "SecretsManagerSecret of LambdaEdgeBasicAuth"
Outputs:
  Region:
    Value:
      !Ref 'AWS::Region'
  LambdaFunctionArn:
    Value:
      !Ref LambdaEdgeBasicAuth #基本認証用Lambda@EdgeのARN
  LambdaFunctionVersionArn:
    Value:
      !Ref LambdaEdgeBasicAuthVersion #基本認証用Lambda@EdgeバージョンのARN
  SecretsManagerSecretArn:
    Value:
      !Ref SecretsManagerSecret #基本認証情報を格納したAWS Secrets ManagerシークレットのARN
      

AWS CloudFormationテンプレート(IP制限用AWS WAF)

テンプレート本体

ファイル名:WebHostCFnWAFWebACL.yml

AWSTemplateFormatVersion: '2010-09-09'
Description: 'CFn Template for a stack that creates AWS WAF WebACL.'
Parameters:
  ResourcePrefix: #AWS WAFのリソースにつけるPrefix
    Type: String
    Default: IPRestrictions
  AllowIPList: #IP制限をするCIDR
    #Type: CommaDelimitedList
    Type: String
    Default: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
Conditions:
  IsAllowIPList:
    !Not [!Equals [!Ref AllowIPList, ""]]
Resources:
  WAFv2WebACL:
    Type: AWS::WAFv2::WebACL
    Properties:
      Name: !Sub "${ResourcePrefix}-WebACL"
      Scope: CLOUDFRONT
      DefaultAction:
        Block: {}
      VisibilityConfig:
        SampledRequestsEnabled: true
        CloudWatchMetricsEnabled: true
        MetricName: !Sub "${ResourcePrefix}-WebACL-Metric"
      Rules:
        - Name: !Sub "${ResourcePrefix}-WebACL-Rule"
          Action:
            Allow: {}
          Priority: 0
          Statement:
            IPSetReferenceStatement:
              Arn: !GetAtt WAFv2IPSet.Arn
          VisibilityConfig:
            SampledRequestsEnabled: true
            CloudWatchMetricsEnabled: true
            MetricName: !Sub "${ResourcePrefix}-WebACL-Rule-Metric"
  WAFv2IPSet:
    Type: AWS::WAFv2::IPSet
    Properties:
      Name: !Sub "${ResourcePrefix}-IPSet"
      Scope: CLOUDFRONT
      IPAddressVersion: IPV4
      Addresses: !If [IsAllowIPList, !Split [ ",", !Ref AllowIPList ], []]
Outputs:
  Region:
    Value: !Ref AWS::Region
  WAFv2WebACLArn:
    Value: !GetAtt WAFv2WebACL.Arn #返却値はWAF WebACLのARN

構築手順

  1. 呼び出し元リージョンで「WebHostCFnCustomResourceToDeployAWS CloudFormationStack.yml」を使用してAWS CloudFormationスタックを作成し、AWS Lambdaカスタムリソースをデプロイする
    us-east-1リージョンに「AWS Certificate Manager(ACM)証明書」、「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」を作成するためのAWS Lambdaカスタムリソースを呼び出し元リージョンへ予めデプロイしておく。
  2. 「AWS Certificate Manager(ACM)証明書」、「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」を作成するAWS CloudFormationテンプレートファイルを任意のAmazon S3バケットに置く
    「WebHostCFnACMCertificate.yml」「WebHostCFnBasicAuthLambdaEdge.yml」「WebHostCFnWAFWebACL.yml」を作成するAWS CloudFormationテンプレートをAmazon S3バケット(前述のパラメータ例では「h-o2k」)に予め配置しておく。
  3. 呼出元AWS CloudFormationテンプレートファイル「WebHostCFnS3CloudFrontWithAcmLambdaEdgeWaf.yml 」をAWS CloudFormationで実行する
    呼出元AWS CloudFormationテンプレートをAWSマネジメントコンソールなどからパラメータを入力の上でAWS CloudFormationスタックとして実行する(CloudFrontDistributionIDは入力しない)。
  4. 静的ウェブサイトホスティングに使用するS3バケットにコンテンツを追加する
    静的ウェブサイトホスティング用S3バケット(前述のパラメータ例では「cfn-acm-edge-waf-cfnt-s3-20230311144618-dev」)にindex.htmlなどコンテンツを追加する。
    今回の例では「I will always remember that day and all of you.」とだけ表示されるindex.htmlを追加しました。
  5. 初回実行(Create処理でAmazon CloudFrontが作成され、Distribution IDが発行される。カスタムリソースによる「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」の作成は実行しない。)
    最初の呼出元AWS CloudFormationスタックの実行では「CloudFrontDistributionID」にAmazon CloudFrontのDistribution IDを入力しないことで、「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」を作成するAWS CloudFormationスタックの作成処理はスキップして、それ以外のリソースを作成します。

    ■初回実行時(Create)のパラメータ入力例
    ~省略~
    CloudFrontDistributionID: #初回時は入力しない(Distribution IDが発行されていないので入力できない)
    ~省略~
    
  6. 2回目実行(Update処理でAmazon CloudFrontのDistribution IDをカスタムリソースに渡し、「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」を作成する。)
    2回目の呼出元AWS CloudFormationスタックの実行では「CloudFrontDistributionID」に初回実行で発行されたAmazon CloudFrontのDistribution IDを入力することで、「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」を作成するAWS CloudFormationスタックの作成処理を実行して、これらのリソースと呼出元のAmazon CloudFrontを関連付けます。

    ■2回目実行時(Update)のパラメータ入力例
    #2回目実行時(Update)はAmazon CloudFrontのDistribution IDを含め、すべてのパラメータを入力
    ~省略~
    CloudFrontDistributionID: XXXXXXXXXXXXX
    ~省略~
    
  7. 結果確認
    正しく実行されていればパラメータで指定した許可IPアドレスからACM証明書を発行する対象ドメインにhttpsでアクセスすると基本認証のダイアログが表示され、パラメータで指定した基本認証のIDとパスワードで認証を通過します。
    許可IPアドレス以外からアクセスすると「403 ERROR」でアクセス拒否されます。
    正しく動作しない場合は呼出元AWS CloudFormationスタックのイベント内容、カスタムリソースでデプロイしたスタックのイベント内容、AWS LambdaカスタムリソースのAmazon CloudWatch Logsの内容、AWS CloudTrailのログから原因を特定して不具合を修正します。

削除手順

AWS CloudFormationではLambda@EdgeおよびAWS WAF Web ACLとAmazon CloudFrontの相互関係のある関連付け解除を順序制御することはしないので一度にすべてのスタックを削除することはできません。
そのため、削除する場合は次の手順のように関連付けを解除した後、呼出元AWS CloudFormationスタックと「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」を作成したスタックをそれぞれ削除する必要があります。

  1. 呼出元AWS CloudFormationスタックのパラメータでAmazon CloudFrontのDistribution IDを空にしてUpdate処理をする
    呼出元AWS CloudFormationスタックのパラメータ「CloudFrontDistributionID」を空にしてスタックを更新する。
    (us-east-1の基本認証用Lambda@EdgeのスタックはバージョンでAmazon CloudFrontと関連付けがあるので失敗しますが、呼び出し元のスタックではAmazon CloudFrontとLambda@Edgeの関連付けが削除されます。)
  2. 呼出元AWS CloudFormationスタックを削除する
    呼出元AWS CloudFormationスタックと「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」のスタックの関連付けが解除されているため、呼出元AWS CloudFormationスタックが削除できる。
  3. us-east-1で基本認証用Lambda@Edgeバージョンを作成したAWS CloudFormationスタックを削除する
    呼出元AWS CloudFormationスタックと基本認証用Lambda@Edgeのスタックの関連付けが解除されているため、基本認証用Lambda@Edgeバージョンを作成したAWS CloudFormationスタックを単独で削除する。


参考:
Route 53 template snippets - AWS CloudFormation
cfn-response module - AWS CloudFormation
Tech Blog with related articles referenced

まとめ

今回は「AWS LambdaカスタムリソースでAWS CloudFormationスタックを別リージョンにデプロイする」の記事で説明したAWS CloudFormationスタックを別リージョンにデプロイするAWS Lambdaカスタムリソースを使用し、SSL/TLS証明書(AWS Certificate Manager)・基本認証(Lambda@Edge)・IP制限(AWS WAF)をそれぞれus-east-1で作成して、呼出元AWS CloudFormationスタックで作成したリソースと関連付ける例を紹介しました。
今後もAWS CloudFormation、AWS Amplify、AWS CDKなどのデプロイ関連サービスや静的ウェブホスティングに関するアップデートについて様々なパターンを試してみたいと考えています。

Written by Hidekazu Konishi
Hidekazu Konishi (小西秀和), a Japan AWS Top Engineer and a Japan AWS All Certifications Engineer

執筆者小西秀和

Japan AWS All Certifications Engineer(AWS認定全冠)の知識をベースにAWSクラウドの活用に取り組んでいます。
Amazon.co.jp: 小西 秀和: books, biography, latest update
NRIネットコムBlog: 小西 秀和: 記事一覧