🤖

CloudFrontの署名付きURLでS3のデータを配信する by AWS CDK

2025/01/03に公開

CloudFront の署名付き URL + S3 によるファイルの配信は調べると、少し古いものもありますがいくつか記事を見つけることができます。この記事では AWS CDK での実装にフォーカスして、CloudFront の署名付き URL の生成と CloudFront の署名付き URL + S3 によるファイルの配信を作成します。

S3の署名付き URL との違い

許可されたユーザーだけが指定したオブジェクト(ファイル)にアクセスできるようにするための一時的な URL を発行する、という点が共通していますがいくつか違いがあります。

S3 の署名付き URL と比較して、CloudFront の署名付き URL は下記のような特徴があるため必要に応じて使い分けるとよさそうです。(この記事では S3 の署名付き URL は扱いません。)

  • カスタムドメインを指定できる
  • 有効期限の開始・終了を Timestamp で指定できる
  • エッジサーバーにキャッシュすることができる
  • IPアドレス制限ができる
  • エラーのカスタマイズ

CDKで作成するもの

細かいリソースは省略しますが、ざっくり下記を作成します。

  • Cloudfront Distribution
    • 署名付き URL を検証し、リクエストを S3 に流す。
    • カスタムドメインを設定(ACM, Route53 も CDK から操作)
  • S3
    • 配信するファイルを格納する
    • カスタムのエラーページを設置
  • Secret Manager
    • 署名付き URL を作成するときに使用する秘密鍵
  • Lambda
    • 署名付き URL を取得する簡易 API

ソースコード全体を見たい方はリポジトリを参照してください。また、この記事内では CDK やスタックの分割、デプロイなどの説明は省いています。

https://github.com/aGFydWtp/cloudfront-signed-url-sample

秘密鍵と公開鍵の作成

URL の署名に使用する秘密鍵と公開鍵を作成します。 CDK で作成する方法は下記の記事を参考にしました。
https://zenn.dev/akring/articles/e9dbd9a59ea32a

詳細は割愛しますが、L3 コントラクトで公開鍵と秘密鍵を提供する Lambda 関数を定義して呼び出すことで使用できます。

// lib/signed-url-stack.ts
import { KeyPairProvider } from "./KeyPairProvider";

const keyPairProvider = new KeyPairProvider(this, "KeyPairProvider");

作成された値はリソース内に保持しないため、秘密鍵は SecretManager に、公開鍵は Cloudfront の PublicKey に格納します。

// lib/signed-url-stack.ts
import { Secret } from "aws-cdk-lib/aws-secretsmanager";
import { PublicKey } from "aws-cdk-lib/aws-cloudfront";

const privateSecret = new Secret(this, "PrivateSecret", {
  secretName: "CloudFrontSignedUrlSecret",
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  secretStringValue: keyPairProvider.privateKeyAsJsonString,
});

const publicKey = new PublicKey(this, "PublicKey", {
  encodedKey: keyPairProvider.publicKey,
});

S3 と Cloudfront Distribution の作成

次にメインのリソースである S3 と Cloudfront Distribution を作成します。S3 は OAC による Cloudfront からのアクセスしか受け付けないため、 BlockPublicAccess.BLOCK_ALL を設定します。バケットポリシーも設定しますがこちらは後述します。

import { BlockPublicAccess, Bucket } from "aws-cdk-lib/aws-s3";

const bucket = new Bucket(this, "Bucket", {
  blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
});

Cloudfront には、公開鍵を使用して署名付き URL を検証する設定をします。先ほど作成した PublicKey を KeyGroup に紐づけ、オリジンを S3 にした defaultBehavior に設定します。

import { Distribution, KeyGroup, ViewerProtocolPolicy } from "aws-cdk-lib/aws-cloudfront";
import { S3BucketOrigin } from "aws-cdk-lib/aws-cloudfront-origins";

const keyGroup = new KeyGroup(this, "KeyGroup", {
  items: [publicKey],
});

const distribution = new Distribution(this, "Distribution", {
  defaultBehavior: {
    origin: S3BucketOrigin.withOriginAccessControl(bucket),
    trustedKeyGroups: [keyGroup],
    viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
  }
});

カスタムドメインの追加

AWS Certificate Manager(ACM) で SSL 証明書を発行し、先ほど作成した Distribution に紐づけます。この際、パブリックホストゾーンだけは事前に必要になるので注意してください。

// lib/cloud-front-cert-stack.ts
import { HostedZone } from "aws-cdk-lib/aws-route53";
import { Certificate, CertificateValidation } from "aws-cdk-lib/aws-certificatemanager";

const hostedZone = HostedZone.fromHostedZoneAttributes(this, "HostedZone", {
  hostedZoneId: props.hostedZoneId,
  zoneName: props.domainName,
});
const cert = new Certificate(this, "Cert", {
  domainName: `${props.hostName}.${props.domainName}`,
  validation: CertificateValidation.fromDns(hostedZone),
});

// lib/signed-url-stack.ts
// 先に作成した Distribution に追記
const distribution = new Distribution(this, "Distribution", {
  certificate: cert,
  domainNames: [`${props.hostName}.${props.domainName}`],
  // ... その他の設定
});

props から変数を受け取っていますが、それぞれ実例で説明すると example.com のホストゾーンがすでにある状態で demo.example.com というカスタムドメインを設定したい場合、

  • hostedZoneId: example.com のパブリックホストゾーン ID
  • domainName: example.com
  • hostName: demo

の値をそれぞれの変数に渡します。

Route53 に A レコードを追加し、作成した Distribution をターゲットに指定します。

import { ARecord, RecordTarget } from "aws-cdk-lib/aws-route53";
import { CloudFrontTarget } from "aws-cdk-lib/aws-route53-targets";

new ARecord(this, "ARecord", {
  zone: hostedZone,
  recordName: props.hostName,
  target: RecordTarget.fromAlias(new CloudFrontTarget(distribution)),
});

バケットポリシーの追加

S3 で Cloudfront からのリクエストを許可するためにバケットポリシーを追加します。基本的にはファイルの取得ができればよいので s3:GetObject のみ許可しています。

import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";

const contentsBucketPolicyStatement = new PolicyStatement({
  actions: ["s3:GetObject"],
  effect: Effect.ALLOW,
  principals: [new ServicePrincipal("cloudfront.amazonaws.com")],
  resources: [`${bucket.bucketArn}/*`],
});
contentsBucketPolicyStatement.addCondition("StringEquals", {
  "AWS:SourceArn": `arn:aws:cloudfront::${this.account}:distribution/${distribution.distributionId}`,
});
bucket.addToResourcePolicy(contentsBucketPolicyStatement);

ここまでのコードをデプロイすると、署名付き URL で S3 からオブジェクトの取得ができるようになります。

署名付き URL の発行

署名付き URL の発行ができないと先ほど作成した仕組みが動いているのか確認することができないため Lambda Funciton URL で簡易 API を作成します。

lib/signed-url-stack.ts
import { CfnOutput } from "aws-cdk-lib";
import { FunctionUrlAuthType, Runtime } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";

const fn = new NodejsFunction(this, "Lambda", {
  entry: "lambda/index.tsx",
  handler: "handler",
  runtime: Runtime.NODEJS_20_X,
  environment: {
    BUCKET: bucket.bucketName,
    HOST_NAME: `${props.hostName}.${props.domainName}`,
    PRIVATE_SECRET_NAME: privateSecret.secretName,
    CF_KEY_PAIR_ID: publicKey.publicKeyId,
  },
});
fn.addFunctionUrl({
  authType: FunctionUrlAuthType.NONE,
});

// Output
new CfnOutput(this, "FunctionUrl", {
  value: functionUrl.url,
});

署名付き URL の発行に必要な情報は環境変数として渡しています。

  • BUCKET: 取得したいオブジェクトが格納されている S3 のバケット名
  • HOST_NAME: CloudFront Distribotion のドメイン
  • PRIVATE_SECRET_NAME: 秘密鍵を格納している Secret Manager の Secret 名
  • CF_KEY_PAIR_ID: CloudFront PublicKey の ID

S3 バケットと Secret は AWS SDK 経由で Lambda から参照するため、Read 権限を付与します。

bucket.grantRead(fn);
privateSecret.grantRead(fn);

Lambda の実装

Lambda でやることは、オブジェクトのキーを受け取って署名付き URL を返却することです。今回は Hono で API を作成し、aws-cloudfront-sign パッケージで署名付き URL を生成します。

// lambda/index.tsx
import { Hono } from "hono";
import { type LambdaEvent, handle } from "hono/aws-lambda";

type Bindings = {
  event: LambdaEvent;
};

const app = new Hono<{ Bindings: Bindings }>();

app.get("/api/signed-url", async (c) => {
  // 署名付き URL を生成するロジック
});

export const handler = handle(app);

/api/signed-url の実装

環境変数と Search Params から必要な値を取得します。ここでは、key というパラメーターで S3 のオブジェクトパスを受け取ります。(例:/api/signed-url?key=data/foo.png

const { HOST_NAME, PRIVATE_SECRET_NAME, CF_KEY_PAIR_IDN } = process.env;
if (!HOST_NAME || !PRIVATE_SECRET_NAME || !CF_KEY_PAIR_ID) {
  return c.text("Internal Server Error", 500);
}

const searchParams = c.env.event?.queryStringParameters;
const key = searchParams?.key;
if (!key) {
  return c.text("Bad Request", 400);
}

署名するために Secret Manager から秘密鍵を取得します。

import {
  GetSecretValueCommand,
  SecretsManagerClient,
} from "@aws-sdk/client-secrets-manager";

const secret = new SecretsManagerClient();
const secretValue = await secret.send(
  new GetSecretValueCommand({ SecretId: PRIVATE_SECRET_NAME }),
);
const privateKey = secretValue.SecretString?.replace(/\\n/g, "\n");

if (!privateKey) {
  return c.text("Internal Server Error", 500);
}

getSignedUrl() で署名付き URL を生成します。署名する URL はカスタムドメインを使用している場合、カスタムドメイン + オブジェクトキーになります。また、今回は有効期限を1時間にしています。

import { getSignedUrl } from "aws-cloudfront-sign";

const expireTime = Date.now() + 3600 * 1000; // 1時間有効
const url = `https://${HOST_NAME}/${encodeURIComponent(key)}`;

const signedUrl = getSignedUrl(url, {
  keypairId: CF_KEY_PAIR_ID,
  privateKeyString: privateKey,
  expireTime,
});

// 署名付き URL を返却
return c.text(signedUrl);

動作確認

Lambda Function URL はデプロイした際に URL が出力されるので控えておきます。または AWS コンソールから該当 Lambda の関数 URL で確認できます。

# ...
 ✅  CloudFrontSignedUrlStack

✨  Deployment time: 76.09s

Outputs:
devCloudFrontSignedUrlStack.FunctionUrl = https://*************.lambda-url.us-east-1.on.aws/
Stack ARN:
arn:aws:cloudformation:us-east-1:************:stack/CloudFrontSignedUrlStack/ec119140-c529-11ef-9ec3-0affea0998f3

✨  Total time: 84.65s
# ...

S3 に確認用のテキストファイルをアップロードします。

echo 'hello world!' > sample.txt
aws s3 cp sample.txt s3://$BUCKET_NAME/

関数 URL の /api/signed-url を叩いて署名付き URL が取得できることを確認します。

curl "https://*************.lambda-url.us-east-1.on.aws/api/signed-url?key=sample.txt"
https://$CUSTOM_DOMAIN/sample.txt?Expires=1735874179&Policy=&Signature=p0q8NKhM9CJMdMAbqg4cbY80Z9CCM7YBODY4J31VMa8Ns3L-3r3yWUH-Wl4yC5pTWUD8f87jRS9CkNig%7EwLEECcqdwd%7EYcfmKtj5Gp3PDj9HfQZNHRolrong1yqU0GF6LZOhniPF0rSqux72tlCLioHbQCGYUYmHlCDqkPJnN0p%7E7nVjyaVfD9xWNmE656lbnZ4a6sA30s%7EKz-F9mUoTcXZgkU8wvCq5wCr3bFmzsdDvlPdm8yvWuH2LNSbsD1SjcNbA6RxSh%7EDZvBMyHI5IhYpsHf509E%7EnhnuUbkGIq2K-vVu8HksWHqVgQ1IO-BNlrqhFgsB0DOAY4OUGhgsKrA__&Key-Pair-Id=K1YZ6MO1U95TVW

この URL にアクセスするとファイルが取得でき、1時間以上経ってからアクセスするとアクセスがブロックされます。

curl "https://$CUSTOM_DOMAIN/sample.txt?Expires=1735874179&Policy=&Signature=p0q8NKhM9CJMdMAbqg4cbY80Z9CCM7YBODY4J31VMa8Ns3L-3r3yWUH-Wl4yC5pTWUD8f87jRS9CkNig%7EwLEECcqdwd%7EYcfmKtj5Gp3PDj9HfQZNHRolrong1yqU0GF6LZOhniPF0rSqux72tlCLioHbQCGYUYmHlCDqkPJnN0p%7E7nVjyaVfD9xWNmE656lbnZ4a6sA30s%7EKz-F9mUoTcXZgkU8wvCq5wCr3bFmzsdDvlPdm8yvWuH2LNSbsD1SjcNbA6RxSh%7EDZvBMyHI5IhYpsHf509E%7EnhnuUbkGIq2K-vVu8HksWHqVgQ1IO-BNlrqhFgsB0DOAY4OUGhgsKrA__&Key-Pair-Id=K1YZ6MO1U95TVW"
hello world!

エラーをカスタマイズする

署名付き URL の有効期限が切れていたり、不正な URL だった場合のエラーをカスタマイズすることもできます。S3 にエラー時の HTML をアップロードしておき Cloudfront Distribution のエラーページ設定を追加することで実現できます。

リポジトリに assets ディレクトリを追加し、エラー用の HTML ファイルを設置します。今回は assets/error403.html を追加しました。

// lib/signed-url-stack.ts
import { BucketDeployment } from "aws-cdk-lib/aws-s3-deployment";
import { Source } from "aws-cdk-lib/aws-s3-deployment";

new BucketDeployment(this, "S3Assets", {
  sources: [Source.asset("assets")],
  destinationBucket: bucket,
});

distribution = new Distribution(this, "Distribution", {
  // ... 他の設定
  errorResponses: [
    {
      httpStatus: 403,
      responseHttpStatus: 403,
      responsePagePath: "/error403.html",
      ttl: Duration.seconds(30),
    },
  ],
});
<!-- assets/error403.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>403 - アクセス拒否</title>
  <style>
    body {
      font-family: sans-serif;
      background-color: #f9f9f9;
      color: #333;
      margin: 0;
      padding: 0;
    }

    .container {
      text-align: center;
      padding: 100px 20px;
    }

    h1 {
      font-size: 2rem;
      margin-bottom: 20px;
    }

    p {
      font-size: 1rem;
      margin: 10px 0;
    }
  </style>
</head>
<body>
  <div class="container">
    <h1>403 Forbidden</h1>
    <p>申し訳ありませんが、このページにアクセスする権限がありません。</p>
    <p>何か問題がある場合は、サイト管理者にお問い合わせください。</p>
  </div>
</body>
</html>

不正なリクエストだった場合のデフォルトのエラー表示はそっけないですが、エラーページ設定で解消できます。

参考

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-urls.html#private-content-choosing-canned-custom-policy

https://zenn.dev/akring/articles/e9dbd9a59ea32a

https://zenn.dev/thyt_lab/articles/385f00cc1ea503

https://hono.dev/docs/getting-started/aws-lambda#access-aws-lambda-object

Discussion