NRIネットコム Blog

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

AWS Amplify Hosting(AWS Amplify Console)にAmazon CloudFrontとAWS WAFを追加してIP制限を設定してみた - カスタムオリジンにIP制限、基本認証、SSL/TLS証明書を追加するAWS CloudFormationテンプレート

小西秀和です。
以前、次の記事でAWS Amplify Hosting(AWS Amplify Console)の構築方法について紹介しました。

AWSの静的ウェブサイトホスティングで入門するAWS Amplify(Console、CLI) - 構築編(Amplify Console)

しかし、AWS Amplify Hosting(AWS Amplify Console)では基本認証や証明書追加の機能はありますが、IP制限の機能がサポートされていません。

そのため、今回は内部がAmazon S3とAmazon CloudFrontで構成されていると推測されるAWS Amplify Hostingをカスタムオリジンと見なし、Amazon CloudFront、AWS WAF、Lambda@Edgeを使用してIP制限機能の追加と基本認証機能のオーバーライドを試してみたいと思います。

補足ですが、AWS Amplifyのコンポーネントは最近ではAWS Amplify CLI、AWS Amplify Libraries、AWS Amplify Hosting、AWS Amplify Studioに分類され、AWS Amplify HostingにはAWS Amplify CLIによるホスティングとAWS Amplify Consoleによるホスティングを含むような概念になっているようです。

参考:Web アプリケーション開発のいろはと AWS Amplify

ここでは以前の記事と同様にAWS Amplify Consoleの機能について言及しますが、AWS Amplifyによるホスティング機能をまとめてAWS Amplify Hostingと呼ぶようになっている傾向が強いため、用語としては「AWS Amplify Hosting」を使用しています。

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

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

本記事で試す構成図

今回の構成ではAmazon S3とAmazon CloudFrontで構成されていると推測されるAWS Amplify Hostingをカスタムオリジンと見なし、Amazon CloudFront、AWS WAF、Lambda@Edgeと関連付けるので、Amazon CloudFrontが2段の構成になります。

また、AWS Amplify HostingのURLに直接アクセスされる可能性があるため、AWS Amplify Hostingで設定した基本認証のID・パスワードをBase64エンコードした文字列を追加するAmazon CloudFront側からカスタムヘッダーで送信することで、AWS Amplify Hostingへの直接アクセスを基本認証でブロックして、AWS CloudFormationで追加するAmazon CloudFrontからの通信を許可します。

一方でAmazon CloudFront側の基本認証はLambda@Edgeで実装し、AWS Amplify Hostingの基本認証情報とは別にID・パスワードを設定します。

Amazon CloudFront側のIP制限はAWS WAF Web ACLに入力したIPアドレスを許可するルールを作成して実現します。

今回の構成ではさらにAWS Certificate Manager(ACM)でSSL/TLS証明書も追加するAmazon CloudFrontに関連付けています。

AWS Amplify HostingにAmazon CloudFront、AWS WAF、Lambda@Edge、ACMを追加する構成例
AWS Amplify HostingにAmazon CloudFront、AWS WAF、Lambda@Edge、ACMを追加する構成例

AWS CloudFormationテンプレートとパラメータの例

AWS CloudFormationテンプレート(Amazon CloudFront、AWS WAF、Lambda@Edge、ACMをカスタムオリジンに関連付け)

このAWS CloudFormationテンプレートは、us-east-1リージョンにデプロイすることでAmazon CloudFront、AWS WAF、Lambda@Edge、ACMをカスタムオリジン(AWS Amplify Hosting、Amazon EC2など)に関連付けるものです。

CloudFrontOriginCustomHeaderNameAuthorizationCloudFrontOriginCustomHeaderValueBasic <基本認証用Base64文字列>を入力することでカスタムヘッダーを使用してAmazon CloudFrontの背後にあるカスタムオリジン(AWS Amplify Hosting、Amazon EC2など)で設定してある基本認証を通過します。

CloudFrontOriginCustomHeaderNameCloudFrontOriginCustomHeaderValueを変更すれば、基本認証以外のカスタムヘッダーを使用したカスタムオリジンの認証にも幅広く使用できるようにしています

一方でAmazon CloudFront側のLambda@Edgeを使用した基本認証は、LambdaEdgeBasicAuthIDにID、LambdaEdgeBasicAuthPWにパスワードを入力して設定します。

Amazon CloudFront側のIP制限は、WAFWebACLAllowIPListへプレフィックス表記の許可IPアドレスをカンマ区切りで入力します。

入力パラメータ例

CloudFrontCustomOriginDomain: "demo.xxxxxxxxxxxxxx.amplifyapp.com"
CloudFrontOriginCustomHeaderName: "Authorization"
CloudFrontOriginCustomHeaderValue: "Basic SWFtOkltbWVyc2VkSW5BV1M="  # ID: Iam, Password: ImmersedInAWSの場合
CloudFrontDistributionID: ""
CloudFrontCachePolicyName: "CachingDisabled"
CloudFrontOriginRequestPolicyName: "NONE"
CloudFrontResponseHeaderPolicyName: "CORS-with-preflight-and-SecurityHeadersPolicy"
WAFWebACLResourcePrefix: "WebHostIPRestrictionsForCustomOrigin"
WAFWebACLAllowIPList: "XXX.XXX.XXX.XXX/16,YYY.YYY.YYY.YYY/24,ZZZ.ZZZ.ZZZ.ZZZ/32"
LambdaEdgeBasicAuthFuncName: "WebHostBasicAuthLambdaEdgeForCustomOrigin"
LambdaEdgeBasicAuthID: "Iam"
LambdaEdgeBasicAuthPW: "Nobody"
ACMCustomDomainName: "cfn-acm-edge-waf-cfnt-custom-origin.h-o2k.com"
ACMHostedZoneId: "ZZZZZZZZZZZZZZZZZZZZZ"

※CloudFrontOriginCustomHeaderValueに入力する基本認証用のBase64文字列は次のUnix系コマンドで<ID>、<パスワード>にそれぞれ値を入れて実行すると取得できます。

$ echo -n '<ID>:<パスワード>' | base64

テンプレート本体

ファイル名:WebHostCFnAddCloudfrontWafEdgeAndAcmToCustomOrigin.yml

AWSTemplateFormatVersion: '2010-09-09'
Description: 'CFn Template for a stack that creates ACM, Lambda@Edge, WAF, and Custom Origin+CloudFront Hosting.'
Parameters:
  CloudFrontCustomOriginDomain: #【CloudFront用】Amazon CloudFrontに設定するCustomOriginのURL
    Type: String
    Default: "demo.xxxxxxxxxxxxxx.amplifyapp.com"
  CloudFrontOriginCustomHeaderName: #【CloudFront用】Amazon CloudFrontのオリジンカスタムヘッダーに設定するヘッダー名
    Type: String
    Default: "Authorization" # 基本認証を想定してデフォルトはAuthorization
  CloudFrontOriginCustomHeaderValue: #【CloudFront用】Amazon CloudFrontのオリジンカスタムヘッダーに設定するヘッダー値
    Type: String
    Default: "Basic SWFtOkltbWVyc2VkSW5BV1M=" # 基本認証を想定して「Basic <Base64文字列>」。例は「echo -n "Iam:ImmersedInAWS" | base64」を実行した場合のBase64文字列。
    Description: 'The following command creates a base64 string: echo -n "ID:Password" | base64'
  CloudFrontCachePolicyName: #【CloudFront用】Amazon CloudFrontのキャッシュポリシーの指定。マネージドポリシーからの選択式。  
    Type: String
    Default: CachingDisabled
    AllowedValues:
      - NONE
      - Amplify
      - CachingDisabled
      - CachingOptimized
      - CachingOptimizedForUncompressedObjects
      - Elemental-MediaPackage
  CloudFrontOriginRequestPolicyName: #【CloudFront用】Amazon CloudFrontのオリジンリクエストポリシー。マネージドポリシーからの選択式。  
    Type: String
    Default: NONE
    AllowedValues:
      - NONE
      - AllViewer
      - AllViewerAndCloudFrontHeaders-2022-06
      - AllViewerExceptHostHeader
      - CORS-CustomOrigin
      - CORS-S3Origin
      - Elemental-MediaTailor-PersonalizedManifests
      - UserAgentRefererHeaders
  CloudFrontResponseHeaderPolicyName: #【CloudFront用】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
  ACMCustomDomainName: #【ACM用】ACM証明書を発行する対象ドメイン名
    Type: String
    Default: cfn-acm-edge-waf-cfnt-custom-origin.h-o2k.com
  ACMHostedZoneId: #【ACM用】ACM証明書を発行する対象ドメイン名を管理するRoute53のホストゾーンID
    Type: String
    Default: ZZZZZZZZZZZZZZZZZZZZZ
  WAFWebACLResourcePrefix: #【WAFWebACL用】AWS WAF WebACLのリソースにつけるPrefix  
    Type: String
    Default: WebHostIPRestrictionsForCustomOrigin
  WAFWebACLAllowIPList: #【WAFWebACL用】AWS WAF WebACLのルールでIP制限をするCIDR
    #Type: CommaDelimitedList
    Type: String
    Default: "XXX.XXX.XXX.XXX/16,YYY.YYY.YYY.YYY/24,ZZZ.ZZZ.ZZZ.ZZZ/32"
  LambdaEdgeBasicAuthFuncName: #【Lambda@Edge用】基本認証をするLambda@Edgeの関数名
    Type: String
    Default: WebHostBasicAuthLambdaEdgeForCustomOrigin
  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
  CloudFrontDistributionID: #【共通】各リソースの関連付けに使用するAmazon CloudFrontのDistribution ID。
    #ALambda@Edgeで使用するAWS Secrets Managerシークレットの一意識別で使用する。
    Type: String #※スタックの初回Create処理でAmazon CloudFrontを作成してから、2回目のUpdate処理で入力する。
    Default: ""
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:
  IsAllowIPList:
    !Not [!Equals [!Ref WAFWebACLAllowIPList, ""]]
  IsCloudFrontDistributionID: #入力パラメータにAmazon CloudFrontのDistribution IDがある場合はTrueでLambda@Edgeを作成、ない場合はFalseでLambda@Edge作成をスキップ。
    !Not [!Equals [!Ref CloudFrontDistributionID, ""]]
Resources:
  #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証明書の発行  
  CertificateManagerCertificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: !Ref ACMCustomDomainName
      DomainValidationOptions:
        - DomainName: !Ref ACMCustomDomainName
          HostedZoneId: !Ref ACMHostedZoneId
      ValidationMethod: DNS #カスタムドメインの検証はRoute53のDNSを使用する方法で実施する

  #AWS WAF WebACLの作成
  WAFv2WebACL:
    Type: AWS::WAFv2::WebACL
    Properties:
      Name: !Sub "${WAFWebACLResourcePrefix}-WebACL"
      Scope: CLOUDFRONT
      DefaultAction:
        Block: {}
      VisibilityConfig:
        SampledRequestsEnabled: true
        CloudWatchMetricsEnabled: true
        MetricName: !Sub "${WAFWebACLResourcePrefix}-WebACL-Metric"
      Rules:
        - Name: !Sub "${WAFWebACLResourcePrefix}-WebACL-Rule"
          Action:
            Allow: {}
          Priority: 0
          Statement:
            IPSetReferenceStatement:
              Arn: !GetAtt WAFv2IPSet.Arn
          VisibilityConfig:
            SampledRequestsEnabled: true
            CloudWatchMetricsEnabled: true
            MetricName: !Sub "${WAFWebACLResourcePrefix}-WebACL-Rule-Metric"
  WAFv2IPSet:
    Type: AWS::WAFv2::IPSet
    Properties:
      Name: !Sub "${WAFWebACLResourcePrefix}-IPSet"
      Scope: CLOUDFRONT
      IPAddressVersion: IPV4
      Addresses: !If [IsAllowIPList, !Split [ ",", !Ref WAFWebACLAllowIPList ], []]

  #Lambda@Edgeの作成
  LambdaEdgeBasicAuth:
    Type: AWS::Lambda::Function
    DependsOn: 
      - SecretsManagerSecret
      - LambdaEdgeBasicAuthRole
    Properties:
      FunctionName: !Ref LambdaEdgeBasicAuthFuncName
      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-${LambdaEdgeBasicAuthFuncName}'
      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-${LambdaEdgeBasicAuthFuncName}'
        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:${LambdaEdgeBasicAuthFuncName}'
          - Effect: Allow
            Action:
            - cloudfront:UpdateDistribution
            Resource:
              !If
                - IsCloudFrontDistributionID
                - !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistributionID}'
                - !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/*'
      - PolicyName: SecretsManagerGetSecretValue
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
          - Effect: Allow
            Action:
            - secretsmanager:GetSecretValue
            Resource: 
              !If
                - IsCloudFrontDistributionID
                - !Sub 'arn:aws:secretsmanager:us-east-1:${AWS::AccountId}:secret:CloudFrontBasicAuth/${CloudFrontDistributionID}/*'
                - !Sub 'arn:aws:secretsmanager:us-east-1:${AWS::AccountId}:secret:CloudFrontBasicAuth/DUMMY/*'
  SecretsManagerSecret: #基本認証に使用するID、パスワードを格納するAWS Secrets Managerシークレットの作成
    Type: 'AWS::SecretsManager::Secret'
    Properties:
      Name: 
        !If
          - IsCloudFrontDistributionID
          - !Sub CloudFrontBasicAuth/${CloudFrontDistributionID}/${LambdaEdgeBasicAuthID}
          - !Sub CloudFrontBasicAuth/DUMMY/${LambdaEdgeBasicAuthID}
      SecretString: !Sub '{"Password":"${LambdaEdgeBasicAuthPW}"}'
      Description: "SecretsManagerSecret of LambdaEdgeBasicAuth"

  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    DependsOn:
      #ACM証明書が発行されてからCloudFrontに関連付けるためDependsOnを設定
      - CertificateManagerCertificate
      #AWS WAF WebACLが作成されてからCloudFrontに関連付けるためDependsOnを設定
      - WAFv2WebACL
      #基本認証用Lambda@Edgeバージョンが作成されてからCloudFrontに関連付けるためDependsOnを設定
      - LambdaEdgeBasicAuth
    Properties:
      DistributionConfig:
        #CloudFrontのエイリアスにACM証明書を発行したドメイン名を設定する
        Aliases:
          - !Ref ACMCustomDomainName
        #CloudFrontにACM証明書を設定する
        ViewerCertificate:
          SslSupportMethod: sni-only
          MinimumProtocolVersion: TLSv1.2_2021
          AcmCertificateArn: !Ref CertificateManagerCertificate #返却値はACM証明書のARN
        WebACLId: !GetAtt WAFv2WebACL.Arn
        HttpVersion: http2
        Origins:
          - DomainName: !Ref CloudFrontCustomOriginDomain
            Id: StaticWebsiteHostingCustomOrigin
            CustomOriginConfig:
              OriginProtocolPolicy: "https-only"
            OriginCustomHeaders:
              - HeaderName: !Ref CloudFrontOriginCustomHeaderName
                HeaderValue: !Ref CloudFrontOriginCustomHeaderValue
        Enabled: true
        DefaultCacheBehavior:
          AllowedMethods: [GET, HEAD, OPTIONS]
          TargetOriginId: StaticWebsiteHostingCustomOrigin #オリジンをターゲットに指定する
          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: !Ref LambdaEdgeBasicAuthVersion
                  }
                ]
              - !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
  ACMCustomDomainURL:
    Value:
      !Join ["", ["https://", !Ref ACMCustomDomainName]]
    Description: "Web hosting URL with Certificate"
  CloudFrontDistributionID:
    Value:
      !Ref CloudFrontDistribution
  CloudFrontDomainName:
    Value:
      !GetAtt CloudFrontDistribution.DomainName
  CloudFrontSecureURL:
    Value:
      !Join ["", ["https://", !GetAtt CloudFrontDistribution.DomainName]]
  LambdaEdgeCompleteSetupSkiped:
    Value:
      !If [IsCloudFrontDistributionID, "false", "true"]

構築手順

  1. 次の記事で説明してある手順を参考にAWS Amplify Hostingによる静的ウェブサイトを作成し、基本認証のID、パスワードを設定する。
    AWSの静的ウェブサイトホスティングで入門するAWS Amplify(Console、CLI) - 構築編(Amplify Console)
    以下の情報はAWS CloudFormationテンプレートのパラメータとして使用するのでメモしておく。
    ・作成したAWS Amplify Hostingのドメイン(例:<App Name>.xxxxxxxxxxxxxx.amplifyapp.com)
    ・Unix系OSで次のコマンドを実行して出力されるBase64文字列(<ID>、<パスワード>にはAWS Amplify Hostingに設定した基本認証のID、パスワードを入力する)
     echo -n '<ID>:<パスワード>' | base64

  2. us-east-1リージョンで「Amazon CloudFront、AWS WAF、Lambda@Edgeをカスタムオリジンに関連付け」のAWS CloudFormationテンプレートにカスタムオリジン(AWS Amplify Hosting)のドメインや基本認証用Base64文字列などをパラメータに入力した上で作成(初回実行)のデプロイをする。
    ※初回実行ではCloudFrontDistributionIDは入力しない。
    初回作成後にOutputフィールドへCloudFrontDistributionIDが出力されるのでメモしておく。

  3. us-east-1リージョンで「Amazon CloudFront、AWS WAF、Lambda@Edgeをカスタムオリジンに関連付け」のAWS CloudFormationテンプレートにCloudFrontDistributionIDのパラメータを入力した上で更新(2回目実行)のデプロイをする。
    入力されたCloudFrontDistributionIDに基づいてLambda@Edgeで使用するAWS Secrets Managerシークレットや権限が変更されてAmazon CloudFront側の基本認証が設定される。

  4. 結果確認
    正しく実行されていればパラメータで指定した許可IPアドレスからACM証明書を発行する対象ドメインにhttpsでアクセスすると基本認証のダイアログが表示され、Amazon CloudFront側の基本認証としてパラメータで指定したIDとパスワードで認証を通過します。
    CloudFrontOriginCustomHeaderNameAuthorizationCloudFrontOriginCustomHeaderValueBasic <基本認証用Base64文字列>を入力していれば、Amazon CloudFront側からカスタムオリジン(AWS Amplify Hosting)には前述した基本認証用Base64文字列がAuthorizationのカスタムヘッダーで送信され、カスタムオリジン側の基本認証を通過します。
    WAFWebACLAllowIPListパラメータで指定した許可IPアドレス以外からアクセスすると「403 ERROR」でアクセス拒否されます。
    正しく動作しない場合は呼出元AWS CloudFormationスタックのイベント内容、AWS CloudTrailのログから原因を特定して不具合を修正します。

削除手順

AWS CloudFormationではLambda@EdgeバージョンとAmazon CloudFrontの相互関係のある関連付け解除を順序制御することはしないので一度にすべてのスタックを削除することはできません。
そのため、削除する場合は次の手順のようにAmazon CloudFrontとLambda@Edgeバージョンの関連付けを解除した後、AWS CloudFormationスタックを削除する必要があります。

  1. AWS CloudFormationスタックのパラメータでAmazon CloudFrontのDistribution IDを空にしてUpdate処理をする。
    呼出元AWS CloudFormationスタックのパラメータ「CloudFrontDistributionID」を空にしてスタックを更新する。
  2. AWS CloudFormationスタックを削除する
  3. AWS CloudFormationスタックの削除が失敗するようであれば、Lambda@Edgeを残してAWS CloudFormationスタックを削除し、後からLambda@Edgeバージョンを個別に削除する。


参考:
Web アプリケーション開発のいろはと AWS Amplify
Route 53 template snippets - AWS CloudFormation
Tech Blog with related articles referenced

まとめ

今回はus-east-1リージョンへ作成したAmazon CloudFront、AWS WAF、Lambda@EdgeへAWS Amplify Hostingをカスタムオリジンとして関連付けてIP制限機能の追加と基本認証機能のオーバーライドを試しました。

結果として今回の構成で追加したAmazon CloudFront側のSSL/TLS証明書(AWS Certificate Manager)・基本認証(Lambda@Edge)・IP制限(AWS WAF)が機能し、Authorizationカスタムヘッダーで設定した基本認証用Base64文字列によってカスタムオリジン(AWS Amplify Hosting)の基本認証を通過してHTMLコンテンツが表示されることを確認できました。

また、今回の構成はAWS Amplify Hosting(AWS Amplify Console)の内部にあると想定されるAmazon CloudFrontとAWS CloudFormationによって追加したAmazon CloudFrontで2段のAmazon CloudFront構成のため、レスポンスは遅くなりましたが、特に不具合はなくHTMLコンテンツが表示されました(※動作保証ではありません。特にJavaScriptやバックエンドとのAPI通信などが絡んでくると不具合が出る可能性はあります。)。

ただ、本来であればAWS Amplify Hosting側でAWS WAFのIP制限などの機能がサポートされることが理想的です。
今後もAWS Amplify Hostingの改善を楽しみにしながら、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: 小西 秀和: 記事一覧