0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

今回は、Amazon CloudFrontの署名付きCookie機能を実際に使ってみたので、その内容をまとめてみました。
少しでも参考になれば幸いです。

導入の背景:
スタンプラリー機能のあるLIFFアプリを実装したが、コンプリート後にダウンロードできるオリジナルGIF画像がS3に配置されており、特定のURL: https://example.com/images/completion-gift-*に直接アクセスすることで第三者にもダウンロードされてしまうおそれがあった

CloudFront署名付きCookieとは

Amazon CloudFrontの制限付きコンテンツ(Restricted Content) 機能の一つです。

アプリケーションサーバー(バックエンド)で、ユーザーに対して以下のCookieを発行します。

  • CloudFront-Policy
  • CloudFront-Signature
  • CloudFront-Key-Pair-Id

※CloudFrontが認識するCookie名は上記で固定である必要があります(でないと認識/検証されない)

ブラウザは以後のリクエストでこれらのCookieを自動送信し、CloudFrontは署名を検証してアクセスを許可します。

「検証の流れ」

ユーザー
   ↓ ログイン
アプリケーションサーバー
   ↓
署名付きCookie発行
   ↓
ブラウザに保存

(その後)

ブラウザ
   ↓ 自動でCookie送信
CloudFront
   ↓ 自動で検証
S3の保護コンテンツへのアクセスを許可

以降、Cookieを持っていれば、ポリシーに合致するすべてのファイルへアクセスできます。

CloudFront署名付きURLとの違い

似た機能に署名付きURLがありますが、こちらは特定の1ファイルを保護する方式になります。
以下の例のように、URL自体に認証情報を埋め込みます。

https://example.com/movie.mp4?
Expires=1780000000&
Signature=xxxx&
Key-Pair-Id=K123456789

アクセスを制限したいファイルが複数ファイルある場合、すべてに対して署名付きURLを発行する必要があります。

比較表

署名付きURL 署名付きCookie
対象 単一ファイル 複数ファイル可
URL 長くなる 通常のまま
実装 簡単 やや複雑
使う場面 一時的な共有リンク 会員向けコンテンツ

実装の流れ

  1. RSA鍵ペアを作る
  2. AWS上で署名付きCookieを設定する
  3. アプリのバックエンドの実装

1. RSA鍵ペアを作る

CloudFrontの署名付きCookieは、「CloudFrontが検証できるデジタル署名」を利用する必要があります。
公開鍵暗号方式を使用します(共通鍵暗号方式を使うと偽造できてしまうため)
署名を作る側(アプリ)は秘密鍵を保持し、CloudFrontは公開鍵を保持し検証します。
CloudFrontは秘密鍵を持たないため、RSA鍵ペアが必要になります。

RSA鍵ペアとは

「公開鍵暗号方式」で使う2つの鍵の組み合わせで、「公開鍵」と「秘密鍵」のペアで構成されます。
一方の鍵で暗号化したデータは、もう一方の鍵でしか復号できません。


ターミナルで以下のコマンドを実行し、RSA鍵ペアを作成します。

zsh
# 秘密鍵(コミット/公開厳禁)
openssl genrsa -out gif_private_key.pem 2048
# 公開鍵(CloudFrontに登録用/公開可)
openssl rsa -pubout -in gif_private_key.pem -out gif_public_key.pem

openssl: OpenSSLコマンドを実行
genrsa: RSA秘密鍵を生成
-out: 出力ファイル名を指定
gif_private_key.pem: 生成される秘密鍵ファイル
2048: 鍵長(2048bit)

→ コマンドを実行したディレクトリに、gif_private_key.pemというファイルが作られます。このファイルには「秘密鍵」と、「公開鍵を生成するための情報」が含まれています。

openssl: OpenSSLコマンドを実行
rsa: RSA鍵を扱う
-pubout: 公開鍵を出力
-in: 入力ファイルを指定
gif_private_key.pem: 秘密鍵ファイル
-out: 出力ファイルを指定
gif_public_key.pem: 公開鍵ファイル

→ 「公開鍵」を含むファイルが作成されます。

2. AWS上で署名付きCookieを設定する

AWSコンソールで手動設定する場合の手順:

AWS CDKを使用して設定する場合の手順:

3. アプリのバックエンドの実装

実際にアプリのサーバー(バックエンド)で署名付きCookieを作成する部分を実装します。

CloudFront署名付きCookieを生成するユーティリティ新規ファイルgifDownloadCookie.tsを作成します。

server/utils/gifDownloadCookie.ts(ファイル全体)
// ============================================================
// GIF 実体への到達を絞る CloudFront 署名付き Cookie
// ------------------------------------------------------------
// /gif-download ページのチケット検証(gifDownloadTicket.ts)は「ページ」を守るが、
// GIF 実体(/images/completion-gift-*)は S3 + CloudFront の公開静的配信のままで、
// URL が漏れれば誰でも落とせてしまう。そこで CloudFront の Trusted Key Group を使い、
// 「署名付き Cookie を持つリクエストだけが GIF を取得できる」ように配信レイヤーで認可する。
//
// 仕組み(CloudFront 署名付き Cookie / 非対称鍵 RSA):
//   - CDK が CloudFront に公開鍵(PublicKey)を登録し、KeyGroup を保護パスの
//     behavior に trustedKeyGroups として紐付ける(cdk/lib/constructs/static-site.ts)。
//   - 本ユーティリティが「対応する秘密鍵」でカスタムポリシーに署名し、CloudFront が
//     検証できる 3 つの Cookie(CloudFront-Policy / -Signature / -Key-Pair-Id)を作る。
//   - redeem.post.ts がチケット検証成功後にこの Cookie を Set-Cookie で返し、以降の
//     同一オリジンの GIF 取得(<img> / fetch)に自動付与される。
//
// 設計上のポイント:
//   - HMAC チケット(HS256)の鍵とは別物。チケットは「ページ閲覧の本人性」、本 Cookie は
//     「オブジェクト到達の認可」を担う。秘密鍵は Lambda env 専用で、クライアントへ出さない。
//   - Resource はホスト名をワイルドカードにして CloudFront デフォルトドメイン・独自ドメインの
//     双方で同じポリシーが効くようにする(パスは completion-gift- 配下に限定)。
//   - TTL はチケット(120s)より長く取る。ページは sessionStorage で会話を引き継ぎ、
//     チケットを URL から消すため、redeem は基本 1 回だけ。Cookie が閲覧/保存セッション全体を
//     賄えるよう数十分単位にする(失効後はアプリ内 CTA から再発行で再取得できる)。
// ============================================================
import { getSignedCookies } from '@aws-sdk/cloudfront-signer'

/**
 * 署名 Cookie の有効期間(秒)。
 * redeem は基本 1 回しか走らない(チケットは URL から除去し、リロードは sessionStorage で
 * 通す)ため、Cookie 単独で「ページを開いてから 3 点を保存し終えるまで」を賄う必要がある。
 * チケット(120s)より十分長い 1 時間にする。失効した場合はアプリ内の CTA から
 * 再度チケット発行 → redeem で Cookie を取り直せる(コンプリートは恒久的なので再入場可)。
 */
export const GIF_COOKIE_TTL_SECONDS = 60 * 60

/**
 * 署名対象リソース(CloudFront カスタムポリシーの Resource)。
 * ホスト名を `*` にして、CloudFront デフォルトドメイン / 独自ドメインのどちらでも
 * 同じ署名で通す。パスは保護対象(completion-gift-*)に限定し、他アセットには波及させない。
 * CDK 側の behavior パスパターン(/images/completion-gift-*)と対で運用する。
 */
const SIGNED_RESOURCE = 'https://*/images/completion-gift-*'

/** CloudFront が要求する署名 Cookie の固定名(この 3 つを Set-Cookie する)。 */
export const CLOUDFRONT_COOKIE_NAMES = [
  'CloudFront-Policy',
  'CloudFront-Signature',
  'CloudFront-Key-Pair-Id',
] as const

/** Cookie を Path で絞る対象。GIF 実体(/images/ 配下)にだけ送られれば十分。 */
export const GIF_COOKIE_PATH = '/images/'

export interface GifDownloadCookie {
  name: string
  value: string
}

/**
 * CloudFront 署名付き Cookie(3 点)を生成する。
 *
 * @param keyPairId  CloudFront PublicKey の ID(CDK の CfnOutput → GitHub Variable 経由で env 注入)。
 * @param privateKey 公開鍵に対応する RSA 秘密鍵 PEM(env では base64 で受け渡すため呼び出し側でデコード済みを渡す)。
 * @param nowMs      現在時刻(ミリ秒)。テスト用に差し替え可能。本番では Date.now()。
 * @returns Set-Cookie する name/value の配列。
 */
export function issueGifDownloadCookies(
  keyPairId: string,
  privateKey: string,
  nowMs: number = Date.now(),
): GifDownloadCookie[] {
  // CloudFront カスタムポリシー: 保護リソースへ TTL 内だけアクセスを許可する。
  const expiresEpoch = Math.floor(nowMs / 1000) + GIF_COOKIE_TTL_SECONDS
  const policy = JSON.stringify({
    Statement: [
      {
        Resource: SIGNED_RESOURCE,
        Condition: { DateLessThan: { 'AWS:EpochTime': expiresEpoch } },
      },
    ],
  })

  // getSignedCookies は { 'CloudFront-Policy': ..., 'CloudFront-Signature': ...,
  // 'CloudFront-Key-Pair-Id': ... } を返す。値は CloudFront 仕様の URL セーフ
  // 文字(A-Za-z0-9-_~)のみで構成されるため、Cookie 値としてそのまま使える。
  const signed = getSignedCookies({ keyPairId, privateKey, policy })

  return CLOUDFRONT_COOKIE_NAMES.map((name) => {
    const value = signed[name]
    // 署名器が 3 点すべてを返す契約。欠けるのは鍵・ポリシー不正等の異常系なので即エラーにする
    // (呼び出し側が catch し、Cookie 未設定のまま全体は落とさずログに残す)。
    if (typeof value !== 'string') {
      throw new Error(`getSignedCookies did not return a value for ${name}`)
    }
    return { name, value }
  })
}

@aws-sdk/cloudfront-signerを使用して、CloudFrontが検証する3つのCookie

  • CloudFront-Policy
  • CloudFront-Signature
  • CloudFront-Key-Pair-Id

に値を入れていきます。

JSON.stringify()を使用して、ポリシー(アクセス可能なリソースを制御)を設定します。


既存の公開エンドポイントAPI: redeem.post.tsを以下の内容で上書きします。
※過去に書いた記事「【JWT】共通鍵を使って、簡易的な認可システムを実装してみた」で実装していますので、ぜひご合わせてご参照ください。

server/api/public/gif-download/redeem.post.ts(ファイル全体)
// ============================================================
// POST /api/public/gif-download/redeem  (公開エンドポイント)
// ------------------------------------------------------------
// 外部ブラウザで開いた /gif-download が、URL フラグメントで受け取ったチケット
// (download-ticket.post.ts が発行)の正当性をサーバーに確認するためのエンドポイント。
//
// 外部ブラウザは LINE ログインを持たず、署名シークレットも持てないため、ここは
// ID Token 認証ミドルウェアの対象外(/api/public/ 配下)にしてある。正当性は
// チケットの署名+有効期限の検証で担保する(middleware/auth.ts の除外と対)。
//
// 署名不一致・期限切れ・aud 不一致はすべて 401 にまとめる(失敗理由を返すと oracle に
// なりうるため)。検証が通れば、GIF 実体(/images/completion-gift-*)への到達を絞る
// CloudFront 署名付き Cookie を Set-Cookie で払い出す(gifDownloadCookie.ts)。これにより
// 「ページのチケット検証」だけでなく「オブジェクトへの到達」も配信レイヤーで認可され、
// GIF の URL が漏れても Cookie の無いリクエストは CloudFront が弾く。
// ============================================================
import { verifyGifDownloadTicket } from '../../../utils/gifDownloadTicket'
import {
  issueGifDownloadCookies,
  GIF_COOKIE_TTL_SECONDS,
  GIF_COOKIE_PATH,
} from '../../../utils/gifDownloadCookie'

export default defineEventHandler(async (event) => {
  const { gifTicketSecret, gifKeyPairId, gifSigningPrivateKey } = useRuntimeConfig(event)
  if (!gifTicketSecret) {
    console.error('[gif-ticket] NUXT_GIF_TICKET_SECRET is not configured')
    throw createError({
      statusCode: 500,
      statusMessage: 'Internal Server Error',
      message: 'gif ticket misconfigured',
    })
  }

  const body = await readBody<{ ticket?: unknown }>(event)
  const ticket = typeof body?.ticket === 'string' ? body.ticket : null
  if (!ticket) {
    throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: 'missing ticket' })
  }

  try {
    await verifyGifDownloadTicket(ticket, String(gifTicketSecret))
  } catch {
    // 署名不一致 / 期限切れ / aud 不一致をまとめて 401(理由は返さない)。
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized', message: 'invalid ticket' })
  }

  // ----------------------------------------------------------
  // CloudFront 署名付き Cookie の発行(GIF 実体への到達を認可)
  // ----------------------------------------------------------
  // 鍵が未設定の段階(保護 behavior 未配備)では Cookie を発行しない。GIF はまだ
  // 公開配信なのでページ側のソフトガードのみで動作し、鍵 + behavior を入れた時点で
  // 自動的にハードガードへ切り替わる(デプロイ順序に強いフェイルソフト)。
  if (gifKeyPairId && gifSigningPrivateKey) {
    // 秘密鍵は PEM の改行が env 経由で壊れないよう base64 で受け渡し、ここでデコードする。
    const privateKey = Buffer.from(String(gifSigningPrivateKey), 'base64').toString('utf8')
    try {
      for (const cookie of issueGifDownloadCookies(String(gifKeyPairId), privateKey)) {
        setCookie(event, cookie.name, cookie.value, {
          // GIF 実体(/images/ 配下)にだけ送れば十分。API 等には付与しない。
          path: GIF_COOKIE_PATH,
          httpOnly: true,
          secure: true,
          // GIF は /gif-download と同一オリジンのサブリソース取得なので lax で送られる。
          sameSite: 'lax',
          maxAge: GIF_COOKIE_TTL_SECONDS,
          // CloudFront の Cookie 値は URL セーフ文字のみ。再エンコードで壊さないよう素通しする。
          encode: (value) => value,
        })
      }
    } catch (err) {
      // 鍵の形式不正など。Cookie 発行に失敗しても 500 で全体を落とさず、ページ表示自体は
      // 通す(GIF 取得時に CloudFront が 403 を返すので情報漏洩はしない)。サーバーログで気付く。
      console.error('[gif-download] failed to issue signed cookies', err)
    }
  }

  return { ok: true as const }
})

保存時にbase64化した秘密鍵をデコードします。

setCookie()を使用して、3つのCookieそれぞれの扱い方を設定し、ブラウザに保存します。
以降、有効なCookieを持っているブラウザは、ポリシーに合致するすべてのファイルへアクセスできます。


クッキー保存時の設定まわりに関しては、こちらの記事をぜひご参照ください。

今回は以上になります!
最後までお読みいただきありがとうございました。

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?