ActiveStorageでアップロードしたファイルに認証をかける
初投稿です。
最近業務にてActiveStorageでアップロードしたファイルをログインしたユーザーのみ閲覧できるようにしたいという要件をいただいたため、技術的なアウトプットの練習も兼ねて実装方法を記事にまとめました。
ActiveStorage
公式ドキュメントにもある通り、ActiveStorageでアップロードしたファイルに認証をかけるには自前で認証機構を実装する必要があります。
開発環境
- ruby 3.3.0 (2023-12-25 revision 5124f9ac75)
- Rails 7.1.3.4
DevContainerを使用して開発しています。
準備
ActiveStorageの初期設定、Deviseなどの基本的な認証機構は実装済みである前提で進めます。
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable
+ has_one_attached :resume # 履歴書 PDF
<%= 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>
<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
URLを開くと、このようにPDFを閲覧することができます。
URLをコピーしてシークレットウィンドウで再度開きなおしても閲覧できることを確認してください。
動作確認用PDF
認証機構の実装
ActiveStorage::Blobにis_private
というカラムを追加して、PDFにアクセスした際に該当カラムの値がtrueの場合は認証処理を挟むアプローチを取ります。
class AddIsPrivateToActiveStorageBlob < ActiveRecord::Migration[7.1]
def change
add_column :active_storage_blobs, :is_private, :boolean, default: false, null: false
end
end
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
+ 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.rb
のbefore_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をオーバーライドして、認証処理を追加します。
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
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
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
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
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
+ # MEMO: Active Storageがデフォルトでプロキシを利用するように設定
+ Rails.application.config.active_storage.resolve_model_to_route = :rails_storage_proxy
動作確認
ここまでの実装の結果、今後新規にアップロードしたファイルはログインしていない状態でアクセスできなくなっているはずです。
Chromeのシークレットウィンドウで確認
今後の改善点
今回記事を書いている途中で気が付いたのですが、このやり方だとファイル本体はともかくpdfのサムネイルのようにActiveStorage側でよしなに?生成される画像ファイルなどはis_privateとは別に何かしらの方法で認証をかけてあげる必要があります。(私が業務で担当した案件ではサムネイルを生成していなかったため、考慮が漏れていました。)
より良い方法を思いついた場合は改めて追記します。
あとがき
最後まで読んでくださりありがとうございました。
記事中のコードに対するご指摘、疑問などあればお気軽にコメントしていただけますと幸いです。
参考記事
Discussion