CloudFront KeyValueStore で複数組のユーザー名/パスワードを管理して、CloudFront Functions により構成したベーシック認証で使ってみた

2023.11.22

こんにちは、CX 事業本部 Delivery 部の若槻です。

re:Invent 2023 を数日前に控える中で、Amazon CloudFront が稼働するエッジロケーションでデータ領域を使用可能とする「Amazon CloudFront KeyValueStore」が発表され、界隈はにわかにざわつきました。

以前まではコンテンツへのリクエストに応じたデータの処理を行いたい場合は、CloudFront Functions のコード内にデータを埋め込む必要がありましたが、今後は簡単なキーバリューペアのデータであれば CloudFront KeyValueStore に格納して、コードと独立して管理することが可能となります。

今回は CloudFront KeyValueStore よくありそうなユースケースとして、CloudFront KeyValueStore で複数組のユーザー名/パスワードを管理して、CloudFront Functions により構成したベーシック認証で使用する実装を行ってみました。

やってみた

CloudFront Distribution + S3 Bucket の作成

まず準備として、ベーシック認証の対象とする CloudFront Distribution と S3 Bucket を AWS CDK であらかじめ作成しておきます。

lib/cdk-sample-stack.ts

import {
  aws_cloudfront,
  aws_s3,
  aws_s3_deployment,
  aws_iam,
  aws_cloudfront_origins,
  Stack,
  StackProps,
  RemovalPolicy,
  Duration,
} from 'aws-cdk-lib';
import { Construct } from 'constructs';

export class CdkSampleStack extends Stack {
  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, props);

    const websiteBucket = new aws_s3.Bucket(this, 'WebsiteBucket', {
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    const originAccessIdentity = new aws_cloudfront.OriginAccessIdentity(
      this,
      'OriginAccessIdentity',
      {
        comment: 'website-distribution-originAccessIdentity',
      }
    );

    const webSiteBucketPolicyStatement = new aws_iam.PolicyStatement({
      actions: ['s3:GetObject'],
      effect: aws_iam.Effect.ALLOW,
      principals: [
        new aws_iam.CanonicalUserPrincipal(
          originAccessIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId
        ),
      ],
      resources: [websiteBucket.arnForObjects('*')],
    });

    websiteBucket.addToResourcePolicy(webSiteBucketPolicyStatement);

    // cloudfront-js-2.0 ランタイムの CloudFront Function の作成は CDK では未対応
    // const basicAuthFunction = new aws_cloudfront.Function(
    //   this,
    //   'BasicAuthFunction',
    //   {
    //     functionName: `basic-authentication`,
    //     code: aws_cloudfront.FunctionCode.fromFile({
    //       filePath: 'src/cloudfront-fn/basic-auth/index.js',
    //     }),
    //   }
    // );

    const distribution = new aws_cloudfront.Distribution(this, 'Distribution', {
      comment: 'website-distribution',
      defaultRootObject: 'index.html',
      errorResponses: [
        {
          ttl: Duration.seconds(300),
          httpStatus: 403,
          responseHttpStatus: 403,
          responsePagePath: '/error.html',
        },
        {
          ttl: Duration.seconds(300),
          httpStatus: 404,
          responseHttpStatus: 404,
          responsePagePath: '/error.html',
        },
      ],
      defaultBehavior: {
        allowedMethods: aws_cloudfront.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: aws_cloudfront.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: aws_cloudfront.CachePolicy.CACHING_OPTIMIZED,
        viewerProtocolPolicy:
          aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        origin: new aws_cloudfront_origins.S3Origin(websiteBucket, {
          originAccessIdentity,
        }),
        // CloudFront Function と Distribution の関連付けは手動で行う
        // functionAssociations: [
        //   {
        //     function: basicAuthFunction,
        //     eventType: aws_cloudfront.FunctionEventType.VIEWER_REQUEST,
        //   },
        // ],
      },
      priceClass: aws_cloudfront.PriceClass.PRICE_CLASS_ALL,
    });

    new aws_s3_deployment.BucketDeployment(this, 'WebsiteDeploy', {
      sources: [
        aws_s3_deployment.Source.data(
          '/index.html',
          '<html><body><h1>Hello, World!</h1></body></html>'
        ),
        aws_s3_deployment.Source.data(
          '/error.html',
          '<html><body><h1>Error!!!</h1></body></html>'
        ),
        aws_s3_deployment.Source.data('/favicon.ico', ''),
      ],
      destinationBucket: websiteBucket,
      distribution: distribution,
      distributionPaths: ['/*'],
    });
  }
}

現時点の最新の CDK ライブライバージョンである v2.110.1 の時点では L2 Construct が提供されていない KeyValueStore は CDK で作成できないのはもちろんなのですが、それと関連付けを行う CloudFront Functions も作成することはできません。なぜなら KeyValueStore と関連付ける CloudFront Functions は cloudfront-js-2.0 のランタイムを使用する必要があるのですが現時点ではそのランタイムを設定するためのプロパティが L2 Construct で提供されていないためです。よって以降の手順で手動で作成および関連付けを行います。

CloudFront KeyValueStore の作成

アップデートにより Amazon CloudFront のマネジメントコンソールの Functions メニューに KeyValueStores タブが追加されており、ここから KeyValueStore 管理を行うことができます。作成をしてみます。

適当な名前をつけて作成します。

作成された KeyValueStore の ID は後ほど使うので控えておきます。

KeyValueStore に ID/パスワード の組を作成する

作成した KeyValueStore に ID/パスワードの組をデータとして追加します。

Add key values pairs をクリックします。

Add pair をクリックします。

Key にユーザー名、Value にパスワードを指定したペアを必要分追加します。

Save changes をクリックして変更を保存します。

CloudFront Functions の作成

次に CloudFront Functions を作成するのですが、ここで重要なのは先程も触れたように cloudfront-js-2.0 ランタイムで関数を作成することです。

CloudFront Functions から KeyValueStore にアクセスするためには、cloudfront モジュールをインポートや、async/await の利用が必要ですが、これらは cloudfront-js-2.0 ランタイムでのみサポートされています。

そして Functions のコードとして次のスクリプトを指定します。

index.js

import cf from 'cloudfront';

const kvsId = '<KVS ID>'; // KeyValueStore の ID を記述

const kvsHandle = cf.kvs(kvsId);

async function handler(event) {
  // cloudfront-js-2.0 では const が使用可能
  const request = event.request;
  const headers = request.headers;

  if (
    typeof headers.authorization === 'undefined' ||
    typeof headers.authorization.value === 'undefined'
  ) {
    return return401();
  }

  const encoded = headers.authorization.value.split(' ')[1];
  const decoded = Buffer.from(encoded, 'base64').toString(); // cloudfront-js-2.0 では Buffer が使用可能
  const userRequest = decoded.split(':')[0];
  const passRequest = decoded.split(':')[1];

  // KVS にユーザー名のキーが存在するかチェック
  const exist = await kvsHandle.exists(userRequest);
  if (!exist) {
    return return401();
  }

  // ユーザー名に対応するパスワードを KVS から取得し、リクエストのパスワードと一致するかチェック
  const passStore = await kvsHandle.get(userRequest);
  if (passStore !== passRequest) {
    return return401();
  }

  return request;
}

// cloudfront-js-2.0 では アロー関数が使用可能
const return401 = () => {
  return {
    statusCode: 401,
    statusDescription: 'Unauthorized',
    headers: { 'www-authenticate': { value: 'Basic' } },
  };
};

処理の流れは以下のようになります。

  • kvsId には先程控えた KeyValueStore の ID を指定します。この ID を使用して KVS のクライアントを初期化します。
  • リクエストされた Authorization ヘッダーの値をデコードしてユーザー名とパスワードを取得します。
  • リクエストのユーザー名をキーにして KeyValueStore に存在するかチェックします。
  • さらにユーザー名をキーにして KeyValueStore からパスワードであるペアの値を取得し、取得したパスワードとリクエストのパスワードが一致するかチェックします。

スクリプトから分かる通り、cloudfront-js-2.0 ランタイムでは、前述の async/await の他にも constBuffer、アロー関数など 1.0 では使用できなかった構文が使用できるようになっています。これにより処理の記述のしやすさが向上しているのは嬉しいですね。

コンソールでスクリプトを指定したら、Save changes をクリックして変更を保存します。

CloudFront Functions と KeyValueStore の関連付け

続いて、ここまでで作成した CloudFront Functions が KeyValueStore を関連付けます。Function は関連付けられた KeyValueStore のみにアクセスすることができるようになります。

Function のメニューで Associate existing KeyValueStore をクリックします。

関連付けたい KeyValueStore を選択して Associate KeyValueStore をクリックします。

この時、次のようなエラーが発生した場合は、KeyValueStore がプロビジョン中なので、完了するまで待つ必要があります。

The parameter KeyValueStoreAssociationArn is invalid; XXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX cannot be associated before the resource is provisioned.

また、ランタイム cloudfront-js-1.0 の Function に KeyValueStore を関連付けようとすると、以下のようなエラーが発生します。

The parameter KeyValueStoreAssociations is invalid; 'cloudfront-js-2.0' runtime and later support KeyValueStore associations.

Functions のランタイムの作成後の変更は可能なので、必要に応じて変更を行いましょう。

マネジメントコンソールからの動作確認

CloudFront Functions は公開する前にマネジメントコンソール上で動作確認を行うことができます。

まずは authorization ヘッダーに正しいユーザー名とパスワードの組(user1:pass1のエンコード)を指定したリクエストを送信してみます。

リクエストがそのまま返却され、期待通りベーシック認証が成功したことが確認できました。

続いて不正なユーザー名とパスワードの組(user1:pass2のエンコード)を指定したリクエストを送信してみます。

今度は 401 レスポンスが返却されました。期待通りベーシック認証が失敗したことが確認できました。

CloudFront Functions はデプロイに時間が掛かるため、デプロイを待っているとコードのデバッグに時間が掛かってしまいます。そこで CloudFront Functions ではこのようにマネジメントコンソールにテストツールが用意されており、デプロイ前の Functions を簡単にデバッグすることができるので、開発時には活用するようにしましょう。

CloudFront Functions の公開

CloudFront Functions はパブリッシュされていない変更は Development ステージとなります。実際に CloudFront Distribution で使用するには公開(パブリッシュ)をして変更を LIVE ステージに移行する必要があります。

Function のメニューで Publish function をクリックします。

これで変更が LIVE ステージに移行し、CloudFront Distribution で使用することができるようになりました。

CloudFront Distribution への Function の関連付け

CloudFront Distribution を Function へ関連付けて、クライアントのコンテンツへのアクセス時にベーシック認証を要求するようにします。

Distribution の Function associations メニューで Viewer request に先程作成した Function を関連付けます。

ここで、Function がデプロイ中の場合は、関連付け操作が次のようなエラーとなります。デプロイが完了するまで待ちましょう。

The parameter FunctionAssociationArn is invalid; basicAuthFunc was not found or is not published.

実際のブラウザからの動作確認

Cloudfront Distrobution のドメインにアクセスすると、ベーシック認証が要求されるようになりました。

認証が成功したら、コンテンツにアクセスできました。

認証が失敗したら、繰り返し認証を要求されます。

ベーシック認証が期待通りに動作していることが確認できました。

おわりに

CloudFront KeyValueStore で複数組のユーザー名/パスワードを管理して、CloudFront Functions により構成したベーシック認証で使ってみました。

KeyValueStore で認証情報を管理することにより、アプリケーションコードのデプロイを行うこと無く、認証情報の追加や変更を行うことができるようになります。また複数組の認証情報の管理も容易になるので、プロジェクトメンバーごとに異なる認証情報を付与することも可能になります。

マネジメントコンソールにアクセスさえできればユーザー名とパスワードが平文で見えてしまうというデメリットはありますが、それを上回るメリットはあるかと思うので、総合的な観点では KeyValueStore を利用したベーシック認証の実装はありではないでしょうか。

他にもレスポンスヘッダーの値やアクセス制限をしたい IP アドレスを管理したりと、KeyValueStore が役に立つユースケースは多そうです。皆さんも是非活用してみてください。

参考

以上