Active StorageのURLを期限付きにすることでセキュアに利用する

## はじめに

ActiveStorageにデフォルトで用意されているURLヘルパでは、何も認証が掛かっていない永続的なURLが生成されます。
具体的にはバックエンドがs3の場合、以下のような2種類のURLが生成されます。

1. https://example.test/rails/active_storage/blobs/redirect/SIGNED_ID/sample.jpg
2. https://BUCKET.s3.ap-northeast-1.amazonaws.com/KEY?QUERY

この場合、1のrailsのURLは認証なし（期限なしのSigned Id）の永続的なURLで、そこからリダイレクトされる2のs3のURLが期限付きという挙動になります。
そのため万が一、1の永続的URL漏洩時に備えて何かしらの認証処理を挟もうとすると一手間必要となります。

ログインユーザーなら誰でも全てのファイルを閲覧可能みたいな簡易的な制限で良ければ比較的簡単に対応可能ですが、現実的にはそれで済むケースは少ないのではないかと思います。
また「ActiveStorage 認証」といったワードで検索すると以下のようなコードがよく出てきます。

```ruby
class ActiveStorage::BlobsController < ActiveStorage::BaseController
  include ActiveStorage::SetBlob

  def show
    if access_allowed?
      expires_in ActiveStorage.service_urls_expire_in
      redirect_to @blob.service_url(disposition: params[:disposition])
    else
      head :forbidden
    end
  end
end
```

しかしこのコードではBlobの種類が増えれば増えるほど、`access_allowed?`の中身が苦しいことになっていくのが目に見えています。
またRails公式のスタンスとしても、権限チェックを挟みたい場合は各モデルに対応したコントローラーを用意することが[推奨](https://github.com/rails/rails/pull/38843#issuecomment-619083761)されています。

しかしモデルの数が多かったり、CarrierWaveからActiveStorageに移行するとなった場合、各モデル毎に認証用のコントローラーを用意するというのはなかなか大変かと思います。
また`ActiveStorage::Blob#service_url`を直接利用するといった方法を見かけることもありますが、その場合はサムネイル表示のケースで問題が出てきます。[^1]
そこで今回の記事では比較的簡単な方法で、（デフォルト設定よりも）セキュアに利用する方法を紹介します。

## 設定方法

```ruby:config/routes.rb
Rails.application.routes.draw do
  # https://github.com/rails/rails/blob/v6.1.3.2/activestorage/config/routes.rb#L60-L81
  # を元に`expires_in`オプションを受け付けるように定義しなおしています
  direct :app_blob do |model, options|
    # 1.hourはデフォルトの有効期限
    expires_in = options.delete(:expires_in) { 1.hour }

    if model.respond_to?(:signed_id)
      route_for(
        :rails_service_blob,
        model.signed_id(expires_in: expires_in),
        model.filename,
        options
      )
    else
      signed_blob_id = model.blob.signed_id(expires_in: expires_in)
      variation_key  = model.variation.key
      filename       = model.blob.filename

      route_for(
        :rails_blob_representation,
        signed_blob_id,
        variation_key,
        filename,
        options
      )
    end
  end
end
```

```ruby:config/initializers/activestorage.rb
Rails.application.config.active_storage.resolve_model_to_route = :app_blob

if Rails::VERSION::MAJOR < 7
  ActiveSupport.on_load(:active_storage_blob) do
    module ActiveStorageBlobPatched
      def signed_id(**options)
        self.class.superclass.superclass.instance_method(:signed_id).bind(self).call(purpose: :blob_id, **options)
      end
    end

    ActiveStorage::Blob.prepend(ActiveStorageBlobPatched)
  end
end
```

設定内容の解説です。
まず`ActiveStorage::Blob#signed_id`で`:expires_in`キーワード引数を受け付けるようにモンキーパッチを当てています。
また`url_for(blob)`といった指定があったときに解決されるルーティングをデフォルトの`rails_blob`から`app_blob`に変更しています。
そして`app_blob`のルーティング定義ではデフォルトで定義されている [rails_storage_redirect](https://github.com/rails/rails/blob/v6.1.3.2/activestorage/config/routes.rb#L60-L81) のルーティングをベースに、`expires_in`オプションを受け付けるように定義しています。
受け取った`expires_in`オプションを先に書き換えた`ActiveStorage::Blob#signed_id`に渡すことで`signed_blob_id`が期限付きとなり、指定期間以降にアクセスしてもファイルが閲覧出来なくなるといった挙動になります。

## 利用例

以下のように`app_blob_path`ヘルパを呼ぶことでファイル毎に期限を指定可能となります。

```erb
<%= image_tag app_blob_path(user.avatar.variant(resize: "100x100"), expires_in: 30.minutes) %>
```

生成されるURLのフォーマットは一見以前と同じなのですが、Signed Idが毎回固定ではなく期限付きとなっています。
https://example.test/rails/active_storage/blobs/redirect/SIGNED_ID/sample.jpg

## Rails7では

今回紹介した期限付きURL生成のための変更箇所については、Rails本体にもプルリクエストを送ってあり無事マージされています。
何も問題がなければRails7以降は標準機能で期限付きURLが簡単に生成できるようになる予定です。🎉

またRails7では以下のような設定を追加することで、アプリケーション全体でActive Storageのデフォルト挙動が認証付きURLを生成するようになります。

```ruby
config.active_storage.urls_expire_in = 30.minutes
```

- [Allow expires\_in for ActiveStorage signed ids by aki77 · Pull Request \#42059 · rails/rails](https://github.com/rails/rails/pull/42059)
- [Add support for ActiveStorage expiring URLs by aki77 · Pull Request \#42410 · rails/rails](https://github.com/rails/rails/pull/42410)

[^1]: サムネイル未生成の場合、Viewのレンダリング時に同期的にサムネイル生成やアップロード処理が走ることになり、レスポンス速度に悪影響がでます。