ActiveStorageで管理するファイルをCloudFront署名付きCookieで限定配信

## この記事について

[以前の投稿](https://qiita.com/zagvym/items/a4f0524c94007317ac95) で、ActiveStorage-S3によるダイレクトアップロードについて調査しました。
その際、併せて「S3にアップロードされた画像ファイルをCDN経由で配信する方法」を調査・検討したので、その記録を記事にしました。

なお、アップロードされたファイルは一般に公開せず配信先を限定する必要があったため、AWS CloudFrontの[署名付きCookie](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-cookies.html)を利用して配信するようにしました。
そのシステム構成の概要と、Rails app側への改修例を記載します。

https://qiita.com/zagvym/items/a4f0524c94007317ac95

## 前提条件

まず、ユーザがプロフィール画像を任意にアップロードして設定できるようなサービスを考えます。
この時、アップロードされるファイルを、サービスの外部からアクセスできない状態にしたい、といった需要を想定します。

ActiveStorage-S3を利用する場合、デフォルト設定では、アップロードされた画像ファイルへのアクセスリクエストをAWS S3の署名付きURLにリダイレクトします。

参考: [S3 署名付きURLを使用したオブジェクトの共有](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html)

### 懸案事項

S3の署名付きURLは、「そのURLを知っている人あれば誰でも」アクセス可能です。
URLの有効期限を短く設定すれば漏出時のリスクを低減することは可能とはいえ、限定公開という要件を満たせているかは判断に迷うところでした。

また、画像へのアクセスの度に Rails app側にリクエストが来るので、その度に署名付きURL発行やリダイレクト処理を行う必要があります。そのため、画像一覧できるようなページへのアクセス増加するとRails側の負荷が必要以上に増加する可能性があります。

加えて、S3からのファイルを直接配信することになるので、AWSのネットワーク（下り）費用の増加も気になるところです。

## CloudFront 署名付きCookieを利用したファイル配信

上記の懸案事項を踏まえて、アップロード画像ファイルの限定公開方式を模索した結果、[CloudFront 署名付きCookie](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-cookies.html) を利用した方法に行きつきました。

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-cookies.html

署名付きCookie方式であれば、サービス未ログイン状（Cookieがない）状態ではアクセスできないので、画像配信用のCDN URLの漏出を気にする必要がありません。
また、Cookieは一度設定してしまえば有効期限内は再発行不要なため、都度URLに署名したりRails appからリダイレクトするといった余計な処理が発生せず、他方式に比べて高頻度なアクセスへの耐性を高められる見込みです。

### Rails appの処理フローとシステム構成概要

CloudFront署名付きCookie (Signed Cookie)を用いた、ActiveStorage-S3管理の画像ファイルの限定配信のフロー概要は下記の通りです。

1. アップロードされた画像含むページへのリクエスト
1. Rails app がActiveStorage管理ファイル情報をDBから取得
1. 画像取得用のCDN URLと署名付きCookie含むレスポンスを返す
1. ブラウザ側が画像取得のためにCDNにCookie情報含むリクエストを発行
1. CDNがS3から取得した画像を配信


また、この時のシステム構成の概要は下図の通りです。

![arch-RailsActiveStorage-CloudFront-CDN_Cookie.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/434222/0b2a281f-b599-37fb-3120-191e98ca5902.png)

### 実装例

CloudFrontの署名付きCookieを設定するために、 ApplicationControllerに処理を追記しました。
また、画像取得がCDN経由となるように、config/routes.rb に direct routing ルールを追記しています。

なお、認証周りの管理には [devise](https://github.com/heartcombo/devise) ライブラリを利用していたため、ログイン状態の判定などは [devise](https://github.com/heartcombo/devise) の機能に依存しています。

#### ApplicationController

ApplicationController に、署名付きCookie設定機能を付与するコード例（抜粋）です。
インスタンス初期化時に、`Aws::CloudFront::CookieSigner` も併せて初期化しています。

また、 `set_cdn_cookies()` メソッドで、ログインユーザに対して署名付きCookieを設定しています。
なお、ログイン中は常に署名付きCookieが有効となるように、有効期限後は自動的に署名付きCookieを再発行するようにしています。

```ruby

CDN_COOKIE_KEYS = ['CloudFront-Key-Pair-Id', 'CloudFront-Signature', 'CloudFront-Policy']

class ApplicationController < ActionController::Base
  ...
  before_action :set_cdn_cookies # 各ページ遷移時に set_cdn_cookies() を呼び出し
  ...

  def initialize
    # Cookieに署名するため CookieSigner の初期化
    @cdn_signer =  Aws::CloudFront::CookieSigner.new(
      key_pair_id: "${CloudFront側に事前に登録した署名用のKeyGroupのID}",
      private_key: "${署名用の秘密鍵（RSA PRIVATE KEY）文字列}",
    )
    @cdn_cookie_domain = 'example.com'
    @cdn_resource = File.join("http*://*.#{@cdn_cookie_domain}", "/*")

    super
  end
  ...
 
  protected
 
    # ログインユーザに対してCookieの設定もしくは延長（再発行）
    def set_cdn_cookies
      # 未ログインもしくはログアウト後
      if !current_user
        CDN_COOKIE_KEYS.each { |k| cookies.delete(k) }
        return # => skip setting CDN cookie
      end

      # 署名付きCookie設定済み
      if CDN_COOKIE_KEYS.all? { |k| cookies.key?(k) }
        return # nothing to do
      end

      expires_in = 60 # 署名の有効期限
      policy = {
        "Statement" => [
          {
            "Resource" => @cdn_resource,
            "Condition" => {
              "DateLessThan":
                {
                  "AWS:EpochTime" => expires_in.from_now.to_i
                }
            }
          }
        ]
      }.to_json

      # 署名付きCookie情報作成
      signed = @cdn_signer.signed_cookie(nil,
        policy: policy 
      )

      # 署名付きCookie情報をCookieにセット（マージ）
      signed.each {|k, v| cookies[k] = {
        value: v,
        domain: @cdn_cookie_domain,
        expires: expires_in * 0.9, # 念の為、署名の有効期限よりも短くする※
        httponly: true, # optional
        secure: true # optional
        #same_site: 'None' # optional (CDNとサービスのドメインを分けたい場合など？）
      }}
    end
```

※ https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-cookies.html#private-content-signed-cookie-misuse では、Session Cookie の利用が推奨されています。今回はログイン中はpCookieの署名有効を更新する」という方針の実現のため、あえて Cookie に有効期限（Max-Age）を設定しています

### routes.rb

[Rails Guide: ActiveStorage](https://guides.rubyonrails.org/v7.0/active_storage_overview.html#putting-a-cdn-in-front-of-active-storage) のCDN配信用 direct routing のサンプルを参考に、ActiveStorage管理の画像ファイル取得が、CloudFront経由になるように調整しました。


```ruby
Rails.application.routes.draw do
  ...

  # for delivering uploaded files from CDN
  # See: https://guides.rubyonrails.org/v7.0/active_storage_overview.html#putting-a-cdn-in-front-of-active-storage
  direct :cdn do |model, options|
    obj = model.respond_to?(:blob) ? model.blob : model # Variantファイルよりもオリジナルファイルを優先
    uri = URI.parse(root_url)
    uri.host = 'cdn.example.com'
    uri.path = File.join("/", obj.key)
    uri.port = nil

    uri.to_s # e.g. "https://#{CDN_HOST}/#{obj.key}"
  end
  ...
```

また、このroutingの有効化のため、設定ファイル（`production.rb` など）に以下のような設定を追記します。
（参考: https://guides.rubyonrails.org/v7.0/configuring.html#config-active-storage-resolve-model-to-route ）

```ruby
config.active_storage.resolve_model_to_route = :cdn
```

## 補足

### aws-sdk-ruby の署名付きCookie生成処理

念の為、 Aws::CloudFront::CookieSigner の`signed_cookie()` の動作確認を行いました。

ソースコードを斜め読みしたところ、CookieSigner初期化時に渡すRSA秘密鍵 (`private_key`) を使ってpolicyに対して署名操作を行う、という操作に見えます。
ですので、署名付きCookie発行時点ではAWS API呼び出しなどのネットワークアクセスは発生しないと思われます。

#### 確認した箇所

- https://github.com/aws/aws-sdk-ruby/blob/a82c8981c95a8296ffb6269c3c06a4f551d87f7d/gems/aws-sdk-cloudfront/lib/aws-sdk-cloudfront/cookie_signer.rb#L40
- https://github.com/aws/aws-sdk-ruby/blob/0de0594a523b9808b4cb0df84c47cea3fd4ba195/gems/aws-sdk-cloudfront/lib/aws-sdk-cloudfront/signer.rb#L93
- https://github.com/aws/aws-sdk-ruby/blob/0de0594a523b9808b4cb0df84c47cea3fd4ba195/gems/aws-sdk-cloudfront/lib/aws-sdk-cloudfront/signer.rb#L99-L102

### CloudFront署名用キーペア登録

`Aws::CloudFront::CookieSigner` 初期化時に、 `key_pair_id` と `private_key` (もしくは `private_key_path`) を渡す必要があります。

これらは、CloudFront側に事前に登録しておく必要があります。

[AWSの開発者ガイド](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-trusted-signers.html#private-content-creating-cloudfront-key-pairs) に従い、

- キーペア（秘密鍵、公開鍵）の作成
- CloudFrontへの公開鍵の登録, KeyGroupの作成
- CloudFrontの限定配信用BehabviorにKeyGroupを設定

といった操作を行いました。詳細手順は割愛します。