CloudFormationを使用して、既存のCloudFrontにCloudFront Functions(CF2)でBasic認証を設定する
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