
CloudFormationを使用して、既存のCloudFrontにCloudFront Functions(CF2)でBasic認証を設定する
この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
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認証をかける方法はこちら
作成にあたり検証をはしていますが、様々な環境での実行また、将来の使用にあたっての動作保証はしていませんので、 テンプレートの内容、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をポチっとしてください。
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
- CloudFront Functions を定義しています
- 名称の重複を避けるため、スタックIDを使用してユニークな名称をつけています
- 処理はこちらのページのコードを使用させていただきました。
 https://zenn.dev/mallowlabs/articles/cloudfront-functions-basic-auth
AWS Lambda
CloudFormaion のカスタムリソースの実装部分となります。
  CRLambdaFunction:
    Condition: BasicAuthEnable
    Type: 'AWS::Lambda::Function'
    省略
概略ですが以下の処理を行っています。
- DistributionId, CachebehaviorPathPattern, FunctionARN を受け取り
- 新規作成の場合
 既存の設定を取得(GetDistributionConfig)
 CloudFront Functionsの設定を追加(更新)
 CloudFrontの更新を行う(UpdateDistribution)
- 更新の場合
 既存の設定を取得(GetDistributionConfig)
 更新前のCloudFront Functionsの設定を削除
 CloudFrontの更新を行う(UpdateDistribution)
 既存の設定を取得(GetDistributionConfig)
 CloudFront Functionsの設定を追加(更新)
 CloudFrontの更新を行う(UpdateDistribution)
- 削除の場合
 既存の設定を取得(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
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














