🍙

ActiveStorageでアップロードしたファイルに認証をかける

2024/12/15に公開

初投稿です。
最近業務にてActiveStorageでアップロードしたファイルをログインしたユーザーのみ閲覧できるようにしたいという要件をいただいたため、技術的なアウトプットの練習も兼ねて実装方法を記事にまとめました。

ActiveStorage

https://railsguides.jp/active_storage_overview.html

公式ドキュメントにもある通り、ActiveStorageでアップロードしたファイルに認証をかけるには自前で認証機構を実装する必要があります。

開発環境

  • ruby 3.3.0 (2023-12-25 revision 5124f9ac75)
  • Rails 7.1.3.4

DevContainerを使用して開発しています。

準備

ActiveStorageの初期設定、Deviseなどの基本的な認証機構は実装済みである前提で進めます。

models/user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable

+  has_one_attached :resume # 履歴書 PDF
views/users/_form.html.erb
 <%= form_with(model: user, class: "contents", url: update ? user_path(user) : users_path, method: update ? :put : :post) do |form| %>

+  <div class="my-5">
+    <%= form.label :resume %><br />
+    <%= form.file_field :resume %>
+  </div>
views/users/_user.html.erb
 <div id="<%= dom_id user %>">
+  <p class="my-5">
+    <%= link_to "PDF", url_for(user.resume), target: :_blank, rel: "noopener noreferrer" if user.resume.present? %>
+  </p>

うまく実装できていれば、このようにアップロードしたPDFのURLを表示できるはずです。

動作確認用PDFのURL
動作確認用PDFのURL

URLを開くと、このようにPDFを閲覧することができます。
URLをコピーしてシークレットウィンドウで再度開きなおしても閲覧できることを確認してください。

動作確認用PDF

動作確認用PDF

認証機構の実装

ActiveStorage::Blobにis_privateというカラムを追加して、PDFにアクセスした際に該当カラムの値がtrueの場合は認証処理を挟むアプローチを取ります。

db/migrate/20241215084908_add_is_private_to_active_storage_blob.rb
class AddIsPrivateToActiveStorageBlob < ActiveRecord::Migration[7.1]
  def change
    add_column :active_storage_blobs, :is_private, :boolean, default: false, null: false
  end
end
lib/active_storage/has_one_attached_extension.rb
module ActiveStorage
  module HasOneAttachedExtension
    module ClassMethods
      def has_one_attached(name, **options)
        is_private = options.delete(:is_private)

        super(name, **options)

        private_attachment(name, is_private: is_private)
      end

      private

      # MEMO: アップロードしたBlobのis_privateを更新する
      # @param [Symbol] name has_one_attachedで定義した疑似カラム名
      # @param [Boolean] is_private ファイルをログインユーザー以外に非公開にするかどうか
      def private_attachment(name, is_private: false)
        define_method("set_#{name}_is_private") do
          attachment = send(name)

          # MEMO: blobが存在しない場合は何もしない
          return unless attachment&.blob.present?

          if attachment.blob.persisted?
            attachment.blob.update_column(:is_private, is_private)
          else
            attachment.blob.is_private = is_private
          end
        end

        before_save :"set_#{name}_is_private"
      end
    end

    def self.included(base) = base.extend(ClassMethods)
  end
end
models/user.rb
+ require "active_storage/has_one_attached_extension"

class User < ApplicationRecord
+  include ActiveStorage::HasOneAttachedExtension

  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable

-  has_one_attached :resume # 履歴書 PDF
+  has_one_attached :resume, is_private: true # 履歴書 PDF

ここまで来たら、一度Rails Serverを再起動してエラーが発生しないことを確認してください。

筆者の環境では、lib/active_storage/has_one_attached_extension.rbbefore_saveメソッドにシンボルではなく文字列を渡してしまったせいで以下のエラーが発生しました。

/usr/local/bundle/gems/activesupport-7.1.3.4/lib/active_support/callbacks.rb:286:in `build': Passing string to define a callback is not supported. See the `.set_callback` documentation to see supported values. (ArgumentError)

この時点で、新しくアップロードしたPDFのis_privateはtrueになっているはずです。

次に、ActiveStorageのControllerをオーバーライドして、認証処理を追加します。

app/controllers/concerns/active_storage/private_file_protector.rb
module ActiveStorage::PrivateFileProtector
  extend ActiveSupport::Concern

  included do
    before_action :check_access_allowed
  end

  private

  def check_access_allowed
    head :forbidden unless access_allowed?(blob: @blob) #MEMO: 呼び出し元コントローラーで ActiveStorage::SetBlob をインクルードしている必要がある
  end

  # @param [ActiveStorage::Blob] blob アクセス対象のBlob
  # @return [Boolean] ファイルへのアクセスが許可されているかどうか
  def access_allowed?(blob:)
    # MEMO: is_privateがfalseの場合は常にアクセスを許可する
    return true unless blob.is_private

    return user_signed_in?
  end
end
app/controllers/active_storage/blobs/proxy_controller.rb
class ActiveStorage::Blobs::ProxyController < ActiveStorage::BaseController
  include ActiveStorage::SetBlob
  include ActiveStorage::Streaming
  include ActiveStorage::DisableSession
+  include ActiveStorage::PrivateFileProtector

  def show
    if request.headers["Range"].present?
      send_blob_byte_range_data @blob, request.headers["Range"]
    else
      http_cache_forever public: true do
        response.headers["Accept-Ranges"] = "bytes"
        response.headers["Content-Length"] = @blob.byte_size.to_s

        send_blob_stream @blob, disposition: params[:disposition]
      end
    end
  end
end
app/controllers/active_storage/blobs/redirect_controller.rb
class ActiveStorage::Blobs::RedirectController < ActiveStorage::BaseController
  include ActiveStorage::SetBlob
+  include ActiveStorage::PrivateFileProtector

  def show
    expires_in ActiveStorage.service_urls_expire_in
    redirect_to @blob.url(disposition: params[:disposition]), allow_other_host: true
  end
end
app/controllers/active_storage/representations/proxy_controller.rb
class ActiveStorage::Representations::ProxyController < ActiveStorage::Representations::BaseController
  include ActiveStorage::Streaming
  include ActiveStorage::DisableSession
+  include ActiveStorage::PrivateFileProtector

  def show
    http_cache_forever public: true do
      send_blob_stream @representation, disposition: params[:disposition]
    end
  end
end
app/controllers/active_storage/representations/redirect_controller.rb
class ActiveStorage::Representations::RedirectController < ActiveStorage::Representations::BaseController
+  include ActiveStorage::PrivateFileProtector

  def show
    expires_in ActiveStorage.service_urls_expire_in
    redirect_to @representation.url(disposition: params[:disposition]), allow_other_host: true
  end
end
config/initializers/active_storage.rb
+ # MEMO: Active Storageがデフォルトでプロキシを利用するように設定
+ Rails.application.config.active_storage.resolve_model_to_route = :rails_storage_proxy

動作確認

ここまでの実装の結果、今後新規にアップロードしたファイルはログインしていない状態でアクセスできなくなっているはずです。

未ログイン アクセス 403エラー
Chromeのシークレットウィンドウで確認

今後の改善点

今回記事を書いている途中で気が付いたのですが、このやり方だとファイル本体はともかくpdfのサムネイルのようにActiveStorage側でよしなに?生成される画像ファイルなどはis_privateとは別に何かしらの方法で認証をかけてあげる必要があります。(私が業務で担当した案件ではサムネイルを生成していなかったため、考慮が漏れていました。)
より良い方法を思いついた場合は改めて追記します。

あとがき

最後まで読んでくださりありがとうございました。
記事中のコードに対するご指摘、疑問などあればお気軽にコメントしていただけますと幸いです。

参考記事

https://blog.to-ko-s.com/activestorage-pdf/
https://qiita.com/k-yamauchi_/items/1cf83890ea1824374723

Discussion