CloudFormationを使用して、既存のCloudFrontにCloudFront Functions(CF2)でBasic認証を設定する

2021.12.09

AWS事業本部 梶原@福岡です。 既存のCloudFrontのサイトにBasic認証をさくっとかけて、さくっと外したかったのでCloudFormation一撃化しました。

新規のサイトであれば、CloudFrontを新規につくる際にCloudFormationで一緒に定義すればいいのですが、既存のサイトの場合はCloudFront Functionsの設定のみする!
みたいなCloudFormationの定義はないのでCloudFront Functionsの設定のみを行うLambda関数を作成し、AWS Lambda-backed カスタムリソースとして対応しました。

概要

以下の事をCloudFormationのテンプレートで実現しています。

CloudFront Functions (Basic認証部分)

  • Basic認証を行うCloudFront Functions(CF2)を作成する
  • Basic認証のユーザ名、パスワードをスタックのパラメータ更新で変更可能にする
  • スタックのパラメータ更新で有効化/無効化できるようにする
  • CloudFrontのビヘイビアへの紐づけは手動でやりたい。Basic認証を行うCloud Functions だけ欲しいという場合もパラメータで対応しています

CloudFormation カスタムリソース(CloudFront への設定部分)

  • 既存のCloudFrontのWebサイトに対してすべてのパス(ビヘイビア)にBasic認証を設定する
  • 既存のCloudFrontのWebサイトに対して指定したパス(ビヘイビア)にBasic認証を設定する
  • 設定したBasic認証をCloudFormatonのスタックのパラメータの更新で削除する
  • 設定対象の CloudFront Functions ビューアリクエスト 以外のCloudFrontの設定を引き継ぐ(既存設定に影響を出さない)
  • CloudFormatonのスタックの更新に対応
  • CloudFormatonのスタックの削除に対応

注意事項(制限)

また、使用に当たり以下の注意事項(制限)があります

Basic認証となりますので、既存のサイトのHTTPS通信の有効化を行ってください。 既存のCloudFrontにすでにビューアリクエストにCloudFront Functionsが設定されている場合、今回作成するBasic認証の関数にて上書きされますのでご注意ください 別途 AWS WAFを使用してBasic認証をかける方法 を近日、ご案内する予定ですのでそちらの方法での対応をご検討ください

別途 AWS WAFを使用してBasic認証をかける方法はこちら

AWS WAFだけで、Basic認証を設定してみた

作成にあたり検証をはしていますが、様々な環境での実行また、将来の使用にあたっての動作保証はしていませんので、 テンプレートの内容、Lambda関数の処理を確認頂き、ステージング環境等にて検証のうえでご使用いただけたければと思います。 (不具合等あればコメントいただければと思います、出来る限り対応します)

CloudFrontの設定反映は即時反映ではありませんので、速く有効/無効にしたい場合はバリデーション等を行ってください

利点など

CloudFormationの処理内でユーザ名、パスワードの変換処理を実施しているので、Basic認証を行うCloudFront Functionsをさくっと作成可能です また、簡単にスタックの更新、削除でBasic認証の設定変更、削除ができるというのが今回の対応の肝です。

1つのパス(ビヘイビア)なら良いですが、一般公開前にBasic認証をサイト全体にかけたいが、設定するパス(ビヘイビア)はめっちゃあるみたいな場合
また、設定を削除する際、一部外し忘れみたいな時もつらいので、Infrastructure as Code(IaC)化することは利点があるかと思います。

使用方法

設定したいCloudFrontのディストリビューションIDを取得する

AWSコンソール等で設定したいCloudFrontのディストリビューションIDを取得(メモ)してください

https://console.aws.amazon.com/cloudfront/v3/home?region=ap-northeast-1#/distributions

CloudFormationの実行

S3にテンプレートを置いていますので、AWSコンソールにログイン後、下記URLをポチっとしてください。

https://ap-northeast-1.console.aws.amazon.com/cloudformation/home?region=ap-northeast-1#/stacks/quickcreate?templateUrl=https%3A%2F%2Fpub-devio-blog-qrgebosd.s3.ap-northeast-1.amazonaws.com%2Ftemplate%2Fcfn_cf2_basic_auth.yml&stackName=cf2-basic-auth&param_BasicAuthEnable=false&param_CachebehaviorPathPattern=all&param_DistributionId=&param_User=user

Basic認証を行う際のユーザー名、パスワードを設定してください

CloudFront Functionのみ作成したい場合は、BasciAuthEnableはfalseのままで、関連付けを行う場合は、trueに変更してください   関連付けを行う場合は、ディストリビューションIDまた、特定のパスに設定したい場合はallから特定のパス(ビヘイビアのパスパターンと一致)を設定してください

[スタックの作成] を押下するとスタックが作成され、各リソースが作成されます。

### リソース確認

CloudFront Functions

basic-auth-xxxxxのCloudFront Functionが作成されていることを確認します

https://console.aws.amazon.com/cloudfront/v3/home?region=ap-northeast-1#/functions

ビヘイビアの関数の関連付け確認

ビューワーリクエストに作成した関数が設定されていることを確認します

動作確認

設定を実施したCloudFrontのURLへアクセスし、Basic認証が設定されていることを確認します。

Basic認証を一時的に外す場合

スタックの更新にて、[BasicAuthEnable]をfalseにしてください。 (CloudFront Functionsは残ります)

※CloudFrontの設定反映は即時反映ではありませんので、速く有効/無効にしたい場合はバリデーション等を行ってください

Basic認証を削除する場合

スタックの削除を実施してください。 カスタムリソース含め、作成したリソースが削除されます。

※CloudFrontの設定反映は即時反映ではありませんので、速く有効/無効にしたい場合はバリデーション等を行ってください

CloudFormation テンプレート説明

テンプレートの全体は、本ページ下部に記載します。

パラメータ部

Parameters:
  DistributionId:
    Type: String
  CachebehaviorPathPattern:
    Type: String
    Default: all
  User:
    Type: String
    Default: user
  Password:
    NoEcho: true
    Type: String
  BasicAuthEnable:
    Type: String
    Default: no
    AllowedValues: [yes, no]
  • DistributionId : 設定を行いたいCloudFrontのDistributionIdを設定してください
  • CachebehaviorPathPattern: 設定を行いたいCloudFrontのビヘイビアのパスパターンを指定してください。Defalutのallはすべてのビヘイビアに対して設定を行います
  • User: Basic認証のユーザ名を指定してください
  • Passowrd : Basic認証のパスワードを指定してください
  • BasicAuthEnable : yes の場合は指定したCloudFrontに対してBasic認証を設定します。
    noの場合はBasic認証を行うCloudFrontのCloudFront Functions のみ作成します。必要に応じて、手動でCloudFrontへの紐づけを行ってください

CloudFront Functions

  MyCFFunction:
    Type: AWS::CloudFront::Function
    Properties: 
      Name: !Sub
        - basic-auth-cf2-${UniqueId}
        - UniqueId: !Select [0, !Split ['-', !Select [2, !Split [/, !Ref 'AWS::StackId']]]]
      FunctionConfig:
        Comment: ''
        Runtime: cloudfront-js-1.0
      FunctionCode: !Sub 
        - |
          function handler(event) {
            var request = event.request;
            var headers = request.headers;

            // echo -n user:pass | base64
            var authString = "Basic ${authString}";

            if (
              typeof headers.authorization === "undefined" ||
              headers.authorization.value !== authString
            ) {
              return {
                statusCode: 401,
                statusDescription: "Unauthorized",
                headers: { "www-authenticate": { value: "Basic" } }
              };
            }

            return request;
          }
        - authString : !Base64
            Fn::Join: [ ":", [ !Ref User,  !Ref Password ] ]
      AutoPublish: true

AWS Lambda

CloudFormaion のカスタムリソースの実装部分となります。

  CRLambdaFunction:
    Condition: BasicAuthEnable
    Type: 'AWS::Lambda::Function'
    省略

概略ですが以下の処理を行っています。

  1. DistributionId, CachebehaviorPathPattern, FunctionARN を受け取り
  2. 新規作成の場合
    既存の設定を取得(GetDistributionConfig)
    CloudFront Functionsの設定を追加(更新)
    CloudFrontの更新を行う(UpdateDistribution)
  3. 更新の場合
    既存の設定を取得(GetDistributionConfig)
    更新前のCloudFront Functionsの設定を削除
    CloudFrontの更新を行う(UpdateDistribution)
    既存の設定を取得(GetDistributionConfig)
    CloudFront Functionsの設定を追加(更新)
    CloudFrontの更新を行う(UpdateDistribution)
  4. 削除の場合
    既存の設定を取得(GetDistributionConfig)
    CloudFront Functionsの設定を削除
    CloudFrontの更新を行う(UpdateDistribution)

all がパラメータに指定された場合は、デフォルトを含め、全ビヘイビアのビューアリクエストにCloudFront Functionを設定
all以外のパスパターンがパラメータに指定された場合は、該当するパスパターンのビヘイビアのビューアリクエストにCloudFront Functionを設定しています。

公開にあたり、指定したパスに一致する複数ビヘイビア(パス)への対応はしていませんが下記部分のコードを正規表現等の一致に置き換えれば対応可能かと思いますのでご自由にカスタマイズ頂ければと思います。

   e['PathPattern'] == pathPattern]

AWS Lambda のRole

  LambdaIAMRole:
    Condition: BasicAuthEnable
    Type: 'AWS::IAM::Role'
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - 'sts:AssumeRole'
      Path: /
      Policies:
        - PolicyName: root
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - 'cloudfront:GetDistributionConfig'
                  - 'cloudfront:UpdateDistribution'
                  - 'lambda:GetFunction'
                Resource: '*'
              - Effect: Allow
                Action:
                  - 'logs:CreateLogGroup'
                  - 'logs:CreateLogStream'
                  - 'logs:PutLogEvents'
                Resource: 'arn:aws:logs:*:*:*'
  • 通常のLambdaの権限
  • CloudFrontの操作を行う権限

を付与しています。

AWS Lambda-backed カスタムリソース

CloudFormaion のカスタムリソースの実装部分となります。

  CFFunctionAssociation:
    Condition: BasicAuthEnable
    Type: 'Custom::CFFunctionAssociation'
    Properties:
      ServiceToken: !GetAtt CRLambdaFunction.Arn
      Id: !Sub
        - CFUpdateDistribution-${UniqueId}
        - UniqueId: !Select [0, !Split ['-', !Select [2, !Split [/, !Ref 'AWS::StackId']]]]
      DistributionId: !Ref DistributionId
      CachebehaviorPathPattern: !Ref CachebehaviorPathPattern
      FunctionARN: !GetAtt MyCFFunction.FunctionMetadata.FunctionARN
  • DistributionId, CachebehaviorPathPattern, FunctionARN をLabmda関数へのパラメータとして呼び出しています

まとめ

CloudFront の UpdateDistribution APIのサンプル欲しい!みたいな人の役にもたつといいかなと思います。

参考

https://zenn.dev/mallowlabs/articles/cloudfront-functions-basic-auth

CloudFront FunctionsでBasic認証のパスワードをかける

AWS > Documentation > Amazon CloudFront API Reference > GetDistributionConfig

AWS > Documentation > Amazon CloudFront API Reference > UpdateDistribution

テンプレート全体

AWSTemplateFormatVersion: '2010-09-09'

Parameters:
  DistributionId:
    Type: String
  CachebehaviorPathPattern:
    Type: String
    Default: all
  User:
    Type: String
    Default: user
  Password:
    NoEcho: true
    Type: String
  BasicAuthEnable:
    Type: String
    Default: no
    AllowedValues: [yes, no]

Conditions:
  BasicAuthEnable: !Equals [ !Ref BasicAuthEnable, yes ]

Resources:
  MyCFFunction:
    Type: AWS::CloudFront::Function
    Properties: 
      Name: !Sub
        - basic-auth-cf2-${UniqueId}
        - UniqueId: !Select [0, !Split ['-', !Select [2, !Split [/, !Ref 'AWS::StackId']]]]
      FunctionConfig:
        Comment: ''
        Runtime: cloudfront-js-1.0
      FunctionCode: !Sub 
        - |
          function handler(event) {
            var request = event.request;
            var headers = request.headers;

            // echo -n user:pass | base64
            var authString = "Basic ${authString}";

            if (
              typeof headers.authorization === "undefined" ||
              headers.authorization.value !== authString
            ) {
              return {
                statusCode: 401,
                statusDescription: "Unauthorized",
                headers: { "www-authenticate": { value: "Basic" } }
              };
            }

            return request;
          }
        - authString : !Base64
            Fn::Join: [ ":", [ !Ref User,  !Ref Password ] ]
      AutoPublish: true

  CRLambdaFunction:
    Condition: BasicAuthEnable
    Type: 'AWS::Lambda::Function'
    Properties:
      Handler: index.lambda_handler
      Role: !GetAtt LambdaIAMRole.Arn
      Code:
        ZipFile: |
          import json
          import boto3
          import cfnresponse

          print('Loading function')
          client = boto3.client('cloudfront')

          def lambda_handler(event, context):
              print("Received event: " + json.dumps(event, indent=2))
              responseData={}
              try:
                  print("Request Type:", event['RequestType'])
                  distributionId = event['ResourceProperties']['DistributionId']
                  pathPattern = event['ResourceProperties']['CachebehaviorPathPattern']
                  functionARN = event['ResourceProperties']['FunctionARN']
                  eventType = 'viewer-request'
                  if event['RequestType'] == 'Create':
                      create_function_associations(distributionId, pathPattern, eventType, functionARN)
                      print("Sending response to custom resource")

                  elif event['RequestType'] == 'Update':
                      oldDistributionId = event['OldResourceProperties']['DistributionId']
                      oldPathPattern = event['OldResourceProperties']['CachebehaviorPathPattern']
                      oldFunctionARN = event['OldResourceProperties']['FunctionARN']

                      delete_function_associations(oldDistributionId, oldPathPattern, eventType, oldFunctionARN)
                      create_function_associations(distributionId, pathPattern, eventType, functionARN)
                      print("Sending response to custom resource")

                  elif event['RequestType'] == 'Delete':
                      delete_function_associations(distributionId, pathPattern, eventType, functionARN)
                      print("Sending response to custom resource after Delete")

                  responseStatus = 'SUCCESS'
              except Exception as e:
                  print('Failed to process:', e)
                  responseStatus = 'FAILED'
                  responseData = {'Failure': 'Something bad happened.'}
              cfnresponse.send(event, context, responseStatus, responseData)

          def get_distribution_config(distributionId):
              distributionConfig = client.get_distribution_config(Id = distributionId)
              # print(distributionConfig)
              return distributionConfig

          def create_function_associations(distributionId, pathPattern, eventType, functionARN):
              config = get_distribution_config(distributionId)

              distributionConfig = config['DistributionConfig']
              print(distributionConfig)

              if pathPattern == 'all':
                  functionAssociations = distributionConfig['DefaultCacheBehavior']['FunctionAssociations']
                  append_function(functionAssociations, eventType, functionARN)

              if 'Items' in distributionConfig['CacheBehaviors']:
                  if pathPattern == 'all':
                      cacheBehaviorsItems = distributionConfig['CacheBehaviors']['Items']
                  else:
                      cacheBehaviorsItems = [e for e in distributionConfig['CacheBehaviors']['Items'] if e['PathPattern'] == pathPattern]

                  print(cacheBehaviorsItems)
                  for cacheBehaviorsItem in cacheBehaviorsItems:
                      functionAssociations = cacheBehaviorsItem['FunctionAssociations']
                      append_function(functionAssociations, eventType, functionARN)

              print(distributionConfig)

              update_distribution(distributionId, config)            

              return config

          def append_function(functionAssociations, eventType, functionARN):

              print(functionAssociations)

              if functionAssociations['Quantity'] == 0:
                  functionAssociationsItems = []
              else:
                  functionAssociationsItems = [e for e in functionAssociations['Items'] if e['EventType'] != eventType]

              functionAssociation = {'EventType': eventType, 'FunctionARN': functionARN}  
              functionAssociationsItems.append(functionAssociation)
              
              functionAssociations['Quantity'] = len(functionAssociationsItems)
              functionAssociations['Items'] = functionAssociationsItems

              print(functionAssociations)

          def delete_function_associations(distributionId, pathPattern, eventType, functionARN):
              config = get_distribution_config(distributionId)

              distributionConfig = config['DistributionConfig']
              print(distributionConfig)

              if pathPattern == 'all':
                  functionAssociations = distributionConfig['DefaultCacheBehavior']['FunctionAssociations']
                  delete_function(functionAssociations, eventType, functionARN)

              if 'Items' in distributionConfig['CacheBehaviors']:
                  if pathPattern == 'all':
                      cacheBehaviorsItems = distributionConfig['CacheBehaviors']['Items']
                  else:
                      cacheBehaviorsItems = [e for e in distributionConfig['CacheBehaviors']['Items'] if e['PathPattern'] == pathPattern]

                  for cacheBehaviorsItem in cacheBehaviorsItems:
                      functionAssociations = cacheBehaviorsItem['FunctionAssociations']
                      delete_function(functionAssociations, eventType, functionARN)

              print(distributionConfig)

              update_distribution(distributionId, config)            

              return config

          def delete_function(functionAssociations, eventType, functionARN):

              print(functionAssociations)

              if 'Items' in functionAssociations:
                  functionAssociationsItems = [e for e in functionAssociations['Items'] if e['EventType'] != eventType and e['FunctionARN'] != functionARN ]

                  if len(functionAssociationsItems) == 0:
                      functionAssociations['Quantity'] = 0
                      functionAssociations.pop('Items')
                  else:
                      functionAssociations['Quantity'] = len(functionAssociationsItems)
                      functionAssociations['Items'] = functionAssociationsItems

              print(functionAssociations)

          def update_distribution(distributionId, config):
              response = client.update_distribution(
                  Id = distributionId,
                  DistributionConfig = config['DistributionConfig'],
                  IfMatch = config['ETag']
                  )
              print(response)
              return response
      Runtime: python3.9
      Timeout: 60

  LambdaIAMRole:
    Condition: BasicAuthEnable
    Type: 'AWS::IAM::Role'
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - 'sts:AssumeRole'
      Path: /
      Policies:
        - PolicyName: root
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - 'cloudfront:GetDistributionConfig'
                  - 'cloudfront:UpdateDistribution'
                  - 'lambda:GetFunction'
                Resource: '*'
              - Effect: Allow
                Action:
                  - 'logs:CreateLogGroup'
                  - 'logs:CreateLogStream'
                  - 'logs:PutLogEvents'
                Resource: 'arn:aws:logs:*:*:*'

  CFFunctionAssociation:
    Condition: BasicAuthEnable
    Type: 'Custom::CFFunctionAssociation'
    Properties:
      ServiceToken: !GetAtt CRLambdaFunction.Arn
      Id: !Sub
        - CFUpdateDistribution-${UniqueId}
        - UniqueId: !Select [0, !Split ['-', !Select [2, !Split [/, !Ref 'AWS::StackId']]]]
      DistributionId: !Ref DistributionId
      CachebehaviorPathPattern: !Ref CachebehaviorPathPattern
      FunctionARN: !GetAtt MyCFFunction.FunctionMetadata.FunctionARN