16
votes

Using devise gem to authenticate all users of an application. I'm trying to implement Active Storage.

Let's say that all users must be authenticated as soon as they reach the app:

class ApplicationController < ActionController::Base
  before_action :authenticate_user!

...
end

How to secure the Active Storage generated routes?

URL of an uploaded file can be accessed without having to authenticate first. The unauthenticated user can get the file url generated by Active Storage.

2
Great question, I didn't even think of that. There must be some way to put the ActiveStorage URLs under the devise_scope, is my thinking... – emptywalls
Did you manage to solve this problem? – Rcordoval
Sadly no I stick to shrine for the moment. I think it’s not mature enough for all projects. But I bet that they will improve as in future releases. – coding addicted

2 Answers

19
votes

This is not a full answer but a starting point:

The gist: You would need to override the redirect controller.

The docs for activestorage/app/controllers/active_storage/blobs_controller.rb say:

If you need to enforce access protection beyond the security-through-obscurity factor of the signed blob references, you'll need to implement your own authenticated redirection controller.

Also if you plan to use previews the docs for activestorage/app/models/active_storage/blob/representable.rb say

Active Storage provides one [controller action for previews], but you may want to create your own (for example, if you need authentication).

Also you might find some relevant information in this rails github issue

Update: Here is a minimal example that "should" work for preventing unauthorised access to the redirects when using the devise gem.

How the url, that the user will be redirected to if logged, is then secured is still another story I guess. By default they expire after 5 minutes but this could be set to a shorter period like 10 seconds (if you replace line 6 in example below with expires_in 10.seconds)

Create a file app/controllers/active_storage/blobs_controller.rb with the following code:

class ActiveStorage::BlobsController < ActiveStorage::BaseController
  before_action :authenticate_user!
  include ActiveStorage::SetBlob

  def show
    expires_in ActiveStorage::Blob.service.url_expires_in
    redirect_to @blob.service_url(disposition: params[:disposition])
  end
end

Please note that the only thing that changed from the original code is that the second line is added

before_action :authenticate_user!

Update 2:

Here is a concern that you can include in ActiveStorage::RepresentationsController and ActiveStorage::BlobsController to enable devise authentication for ActiveStorage

See gist is at https://gist.github.com/dommmel/4e41b204b97238e9aaf35939ae8e1666 also included here:

# Rails controller concern to enable Devise authentication for ActiveStorage.
# Put it in +app/controllers/concerns/blob_authenticatable.rb+ and include it when overriding
# +ActiveStorage::BlobsController+ and +ActiveStorage::RepresentationsController+.
# 
# Optional configuration:
# 
# Set the model that includes devise's database_authenticatable.
# Defaults to Devise.default_scope which defaults to the first
# devise role declared in your routes (usually :user)
#
#   blob_authenticatable resource: :admin
#   
# To specify how to determine if the current_user is allowed to access the 
# blob, override the can_access_blob? method
#   
# Minimal example:
# 
#   class ActiveStorage::BlobsController < ActiveStorage::BaseController
#     include ActiveStorage::SetBlob
#     include AdminOrUserAuthenticatable
#     
#     def show
#       expires_in ActiveStorage::Blob.service.url_expires_in
#       redirect_to @blob.service_url(disposition: params[:disposition])
#     end
#   end
# 
# Complete example:
# 
#   class ActiveStorage::RepresentationsController < ActiveStorage::BaseController
#     include ActiveStorage::SetBlob
#     include AdminOrUserAuthenticatable
# 
#     blob_authenticatable resource: :admin
#
#     def show
#       expires_in ActiveStorage::Blob.service.url_expires_in
#       redirect_to @blob.representation(params[:variation_key]).processed.service_url(disposition: params[:disposition])
#     end
#     
#     private
#
#       def can_access_blob?(current_user)
#         @blob.attachments.map(&:record).all? { |record| record.user == current_user }
#       end
#   end

module BlobAuthenticatable
  extend ActiveSupport::Concern

  included do
    around_action :wrap_in_authentication
  end

  module ClassMethods
    def auth_resource
      @auth_resource || Devise.default_scope
    end

    private

      def blob_authenticatable(resource:)
        @auth_resource = resource
      end
  end

  private

    def wrap_in_authentication
      is_signed_in_and_authorized = send("#{self.class.auth_resource}_signed_in?") \
        & can_access_blob?(send("current_#{self.class.auth_resource}"))

      if is_signed_in_and_authorized
        yield
      else
        head :unauthorized
      end
    end

    def can_access_blob?(_user)
      true
    end
end
9
votes

If you want to implement authentication for all endpoints provided by active storage, you can override the ActiveStorage::BaseController based on the original implementation:

# app/controllers/active_storage/base_controller.rb

# frozen_string_literal: true

# The base class for all Active Storage controllers.
class ActiveStorage::BaseController < ActionController::Base
  before_action :authenticate_user!
  include ActiveStorage::SetCurrent

  protect_from_forgery with: :exception
end