Secure active storage with devise - ruby-on-rails

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.

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

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

Related

Facing the NameError when trying to include concerns in my rails 6 controller

I am trying to create a small project for practising the API Key Authentication using the this tutorial. I have created a concern in following directory of the rails project.
app/controllers/concerns/api_key_authenticable.rb
module ApiKeyAuthenticatable
extend ActiveSupport::Concern
include ActionController::HttpAuthentication::Basic::ControllerMethods
include ActionController::HttpAuthentication::Token::ControllerMethods
attr_reader :current_api_key
attr_reader :current_bearer
# Use this to raise an error and automatically respond with a 401 HTTP status
# code when API key authentication fails
def authenticate_with_api_key!
#current_bearer = authenticate_or_request_with_http_token &method(:authenticator)
end
# Use this for optional API key authentication
def authenticate_with_api_key
#current_bearer = authenticate_with_http_token &method(:authenticator)
end
private
attr_writer :current_api_key
attr_writer :current_bearer
def authenticator(http_token, options)
#current_api_key = ApiKey.find_by token: http_token
current_api_key&.bearer
end
end
In my Controller, I am trying to include the concern like this
app/controllers/ApiKeysController.rb
class ApiKeysController < ApplicationController
include ApiKeyAuthenticatable
# Require token authentication for index
prepend_before_action :authenticate_with_api_key!, only: [:index]
# Optional token authentication for logout
prepend_before_action :authenticate_with_api_key, only: [:destroy]
def index
end
def create
end
def destroy
end
end
But when I run the server and visit the index action of the controller, I get the following error
NameError (uninitialized constant ApiKeysController::ApiKeyAuthenticatable):
app/controllers/ApiKeysController.rb:10:in `<class:ApiKeysController>'
app/controllers/ApiKeysController.rb:2:in `<main>'
Can anybody help me out to fix this issue?
Author here. Your concern’s filename is authenticable but your module is authenticatable. You’ll need to correct the typo in the concern filename.

Allow unconfirmed users to access certain pages which require authentication

I use the Rails Stack with
devise
warden
confirmable
Now I have a certain requirement related to email confirmation and access provision to unverified users. Let's say there are 3 categories of pages:
case 1 - requires no authentication.
case 2 - requires authentication and also require the user to be confirmed.
case 3 - requires authentication (correct username and password combination) but user need not be confirmed to access them.
With devise and confirmable, implementing case 1 and case 2 is a breeze. Once the user does login/signup, I redirect to "confirm your email page".
My problem is with case 3. Suppose the user does his login/signup. He is yet to confirm his email address but should be allowed to visit certain case 3 routes. When he visits a case 3 routes, I expect:
no redirection
valid session
Devise with confirmable either allows all the pages to be visited by confirmed users or none. It does not allow access to certain pages with authentication but without confirmation.
I tried overriding the devise confirmed? by implementing this logic:
class ApplicationController < ActionController::Base
before_filter :verify_user
def verify_user
$flag = true if CERTAIN_ROUTES.include?(action_class)
end
end
class User < ActiveRecord::Base
def confirmed?
$flag || !!confirmed_at
end
end
This barely works for sign in but not for sign up. Also, this is an extremely bad way to achieve it. How should I approach this problem? Other functionalities work fine.
Instead of overwriting confirmed? you could just overwrite the confirmation_required? model method (docs):
# user.rb
class User < ActiveRecord::Base
protected
def confirmation_required?
false
end
end
After that you can handle the confirmation logic yourself, by redirecting all unconfirmed users in a before_action when the controllers require confirmation, or you can pull this into your authorization logic (e.g. with pundit).
class ApplicationController < ActionController::Base
def needs_confirmation
redirect_to root_path unless current_user.confirmed?
end
end
class SomeController < ApplicationController
before_action :needs_confirmation
end
You should take a look at the gem 'pundit' - it works well with devise.
https://github.com/varvet/pundit
Rather than writing controller before_actions etc, you write policies which will cover each of your authorization requirements, and then use those policies inside your controllers.
For example, in a controller:
class ExampleController < ApplicationController
before_action { authorize :example }
def case_one
# action
end
def case_two
# action
end
def case_three
# action
end
end
Then your policy would be kept under app/policies/example_policy.rb
class ExamplePolicy < ApplicationPolicy
attr_reader :user
def initialize(user, _)
#user = user
end
def case_one?
true
end
def case_two?
user.present? && user.confirmed_at.present?
end
def case_three?
user.present?
end
end
It works really well, especially in other cases where you are determining authorization against a type of resource.

How to add a custom header to doorkeper token response

The project that I'm working on requires me to add custom headers based on generated response body to all responses generated by my app. This works fine with after_action in my application controller, but I also need to add the custom header to the token responses generated with Doorkeeper. I setting base_controller to ApplicationController in doorkeeper configuration, but this did not cause my after_actions to be called. Are there some possible workarounds?
Turns out that it takes one to define a custom Doorkeeper::TokensController class and add an filter to it.
app/controllers/access_tokens_controller.rb:
class AccessTokensController < Doorkeeper::TokensController
include AbstractController::Callbacks
after_action :add_signature_to_response, only: [:create]
def add_signature_to_response
application = strategy.client.application
# ...
# response_based_on_application = ...
# ...
response.headers['custom-header'] = response_based_on_application
end
end
Next one needs to register that controller in doorkeeper configuration in config/initializers/doorkeeper.rb
# ...
use_doorkeeper scope: 'oauth2' do
# ...
controllers tokens: 'access_tokens'
# ...
end
# ...

Converting Mongoid to ActiveRecord for Devise-Basecamp setup

I am setting up Devise-Basecamper to enable subdomain scoped authentication to extend Devise's usefulness even more. The gem might be a bit old but it seems like an ideal solution if I get everything up and running. The README.md is super clear but the only thing that threw me off is a bit of code relating to Mongoid even though I am using ActiveRecord. If you could help me write this code according to ActiveRecord I would be very grateful. I have a model called Account, which is like the company or organization.
Here's the necessary excerpt from Devise-Basecamper's readme. I have put the Mongoid code in >>> and <<<
class ApplicationController < ActionController::Base
protect_from_forgery
helper_method :subdomain, :current_account
before_filter :validate_subdomain, :authenticate_user!
private # ----------------------------------------------------
def current_acount
# The where clause is assuming you are using Mongoid, change appropriately
# for ActiveRecord or a different supported ORM.
>>>#current_account ||= Association.where(subdomain: subdomain).first<<<
end
def subdomain
request.subdomain
end
# This will redirect the user to your 404 page if the account can not be found
# based on the subdomain. You can change this to whatever best fits your
# application.
def validate_subdomain
redirect_to '/404.html' if current_account.nil?
end
end
First of all, there is a typo in current_account method. It's current_account not current_acount.
so replace this line
def current_acount
with
def current_account
Second, You have change Association to Account because Account is actually Model.
Replace this line
#current_account ||= Association.where(subdomain: subdomain).first
with
#current_account ||= Account.where(subdomain: subdomain).first
Third, If you're not using mongoid then you have change where clause. e.g.
replace where clause
#current_account ||= Association.where(subdomain: subdomain).first
with
#current_account ||= Association.where("subdomain = ?", subdomain).first
Final code is here..
class ApplicationController < ActionController::Base
protect_from_forgery
helper_method :subdomain, :current_account
before_filter :validate_subdomain, :authenticate_user!
private # ----------------------------------------------------
def current_account
# The where clause is assuming you are using Mongoid, change appropriately
# for ActiveRecord or a different supported ORM.
#current_account ||= Account.where("subdomain = ?", subdomain).first
end
def subdomain
request.subdomain
end
# This will redirect the user to your 404 page if the account can not be found
# based on the subdomain. You can change this to whatever best fits your
# application.
def validate_subdomain
redirect_to '/404.html' if current_account.nil?
end
end

Extending Devise to use remote login

I'm trying to follow this blog post to allow remote authentication with devise, but I can't figure out a few things.
What do I call my new files, and where do I put them?
The first one, Devise::Models::RemoteAuthenticatable I assume I call remote_authenticatable.rb and put in a devise folder in my models folder?
The second file, "Warden strategy for Devise" I have no idea what to call it, or where to put it.
Any ideas? I found the tutorial pretty incomplete. It's linked to from the Devise instructions as the way to do this.
EDIT:
I've been doing more reading, and I'm not sure I need to do what it says in that blog post. I've been trying to simply PUT data to my Rails app, but can't get anything to work. Doing a PUT request causes devise to loose authentication.
I know this question is years old, but I also found the tutorial to be incomplete, and I recently spent a couple days trying to get remote authentication to work. So I will provide my solution in case it helps someone in the future. I am using Ruby 2.2.2 and Rails 4.2.5.1.
First of all, this gist was an EXTREMELY helpful reference for me.
I also used the gem fakeweb to mock API calls.
Here are what my files look like:
app/models/user.rb
class User < include ActiveModel::Model
# required because some before_validations are defined in devise
include ActiveModel::Validations
# required to define callbacks
extend ActiveModel::Callbacks
# taken from http://stackoverflow.com/questions/8936906/whats-the-correct-way-to-make-before-validation-etc-work-in-an-activemodel
include ActiveModel::Validations::Callbacks
extend Devise::Models
# create getter and setter methods internally for the fields below
attr_accessor :email, :auth_token
#required by Devise
define_model_callbacks :validation
devise :remote_authenticatable, :timeoutable
# Latest devise tries to initialize this class with values
# ignore it for now
def initialize(options={})
end
end
lib/models/remote_authenticatable.rb
require 'fakeweb' #used for mocking API calls
module Devise
module Models
module RemoteAuthenticatable
extend ActiveSupport::Concern
#
# Here you do the request to the external webservice
#
# If the authentication is successful you should return
# a resource instance
#
# If the authentication fails you should return false
#
def remote_authentication(authentication_hash)
FakeWeb.register_uri(:get, "http://localhost:3000/webservice/login.json",
:body => "{ \"success\": \"true\", \"auth_token\": \"secure_token_123\", \"email\": \"bob#1123.com\"}")
# Your logic to authenticate with the external webservice
response = Net::HTTP.get(URI.parse("http://localhost:3000/webservice/login.json"))
self.email = JSON.parse(response)["email"]
self.auth_token = JSON.parse(response)["auth_token"]
return self
end
module ClassMethods
####################################
# Overriden methods from Devise::Models::Authenticatable
####################################
#
# This method is called from:
# Warden::SessionSerializer in devise
#
# It takes as many params as elements had the array
# returned in serialize_into_session
#
# Recreates a resource from session data
#
def serialize_from_session(data, salt)
resource = self.new
resource.email = data['email']
resource.auth_token = data['auth_token']
resource
end
#
# Here you have to return and array with the data of your resource
# that you want to serialize into the session
#
# You might want to include some authentication data
#
def serialize_into_session(record)
[
{
:email => record.email,
:auth_token => record.auth_token
},
nil
]
end
end
end
end
end
config/initializers/remote_authenticatable.rb
module Devise
module Strategies
class RemoteAuthenticatable < Authenticatable
#
# For an example check : https://github.com/plataformatec/devise/blob/master/lib/devise/strategies/database_authenticatable.rb
#
# Method called by warden to authenticate a resource.
#
def authenticate!
#
# authentication_hash doesn't include the password
#
auth_params = authentication_hash
auth_params[:password] = password
#
# mapping.to is a wrapper over the resource model
#
resource = mapping.to.new
return fail! unless resource
# remote_authentication method is defined in Devise::Models::RemoteAuthenticatable
#
# validate is a method defined in Devise::Strategies::Authenticatable. It takes
#a block which must return a boolean value.
#
# If the block returns true the resource will be logged in
# If the block returns false the authentication will fail!
#
# resource = resource.remote_authentication(auth_params)
if validate(resource){ resource = resource.remote_authentication(auth_params) }
success!(resource)
end
end
end
end
end
config/initializers/devise.rb
Devise.setup do |config|
# ...
# ...
# OTHER CONFIGURATION CODE HERE
# ...
# ...
# ==> Warden configuration
# If you want to use other strategies, that are not supported by Devise, or
# change the failure app, you can configure them inside the config.warden block.
#
# config.warden do |manager|
# manager.intercept_401 = false
# manager.default_strategies(scope: :user).unshift :some_external_strategy
# end
# BEGIN code that was added to this file
config.warden do |manager|
manager.strategies.add(:remote_authenticatable, Devise::Strategies::RemoteAuthenticatable)
manager.default_strategies(:scope => :user).unshift :remote_authenticatable
end
Devise.add_module :remote_authenticatable, :controller => :sessions, :route => { :session => :routes }
# END code that was added to this file
# ...
# ...
# OTHER CONFIGURATION CODE HERE
# ...
# ...
end
config/application.rb
# ...
# ...
# OTHER CODE HERE
# ...
# ...
module RemoteAuth
class Application < Rails::Application
# ...
# OTHER CODE HERE
# ...
# BEGIN code that was added to this file
config.autoload_paths += Dir["#{config.root}/lib/**/"]
config.autoload_paths += Dir["#{config.root}/app/models/**/"]
# END code that was added to this file
end
end
This Gist shows you exactly how to do this. There are a few extra steps, like activating the :token_authenticatable module in your user model, and in config/devise.rb

Resources