This post is from the considered harmful series where we talk about wrong way of doing things. I mean the way of doing programming tasks that is highly popular, looks beneficial at first sight and even recommended by certain guides and tutorials but leads to unwanted results that you'll have either to deal with, or to live with.
And today we will talk about Rails authentication routing constraints.
When developing a Rails application it is often required to restrict access to some parts of it. The way to say: "This area is for signed in users only."
In a Rails world each controller level authentication library proudly presents routing constraints. Examples:
authenticated :user do resources :posts end
constraints Clearance::Constraints::SignedIn.new do resources :posts end
constraints Monban::Constraints::SignedIn.new do resources :posts end
You got it.
It works as follows – the Rails routing system allows you to set a condition on a route and if this condition is not met, the route will not work. An example of custom constraint from Rails guides:
class BlacklistConstraint def initialize @ips = Blacklist.retrieve_ips end def matches?(request) @ips.include?(request.remote_ip) end end Rails.application.routes.draw do get '*path', to: 'blacklist#index', constraints: BlacklistConstraint.new end
If the constraint rejects the request, the routing system skips this route and proceeds down the list. When the end of the file is reached, it throws
RoutingError, which results in the 404 error page: page not found.
You can read more at Rails Guides
Why it is bad
At first sight this approach seems to be a great alternative to writing in the controller something like
before_action :authenticate_user!. It is all the same but you do not have to split it into separate controller files or choose a suitable base controller. Everything is nicely declaret in the most suitable place – in the routes file. And you treat it as a direct substitution of
However, this is not the case for methods like
authenticate_user!. Non-logged-in users are not presented with a 404 error. Instead, they are redirected to the sign-in page with a message prompting them to sign in. Once they successfully sign in, they are often redirected to their intended destination.
On the other hand, routing constraints can only return a 404 error, which can be very inconvenient for users. If a user clears their browser session and attempts to access a familiar page, they will see a "Page Not Found" error. Moreover, the default "Page Not Found" page in Rails does not include navigation or layout, leaving the user confused about what has happened and where they are.
The client asks to fix this, you investigate the problem, remove routing constraints and specify
authenticate_user! in the controllers.
What to do?
There are two answers: the easy one and the correct one.
Easy answer is "do not use routing constraints and just write
authenticate_user!". This at least does not contradict with the common way of doing things and does not become a problem for the user. However there is a better way.
The correct answer
The point is that the check of access rights "guest or logged in user" – is just a particular case of access rights check in an app in general, i.e., authorization.
The solution is to use an authorization library, such as CanCan or Pundit. Everywhere and always do
authorize! from this library. Most of them allow to require
authorize! to be executed and throw exception if it does not happen.
Once you do it you also need to differentiate between situations of "the user can’t enter due to absence of a specific role" and "the user can’t enter because he is not logged in".
This way you have a smoothly working authentication system with good UX and in the meantime all access rights will be specified in a single file.
An example for CanCan:
In any controller:
class ArticlesController < ApplicationController load_and_authorize_resource def show end end
You can just specify the resource but don’t have to upload it:
class ArticlesController < ApplicationController authorize_resource :articles def show end end
Actually, in the single file with access rights:
class Ability include CanCan::Ability def initialize(user) if user # rights of logged in user can :manage, :articles # ... else # rights of unlogged user (guest) can :read, :home end end end
Obviously you may also use CanCan according to its intended purpose, by checking the specific roles of already logged in users.
Now the gist: in case of access rights inconsistency you should redirect non-logged-in users to the login page but when a user is logged in but fails permission check, you should show "Access denied", maintaining the default behavior. An example for Devise and CanCan:
class ApplicationController < ActionController::Base rescue_from CanCan::AccessDenied do |exception| if user_signed_in? redirect_to root_url, :alert => exception.message # access denied else authenticate_user! # redirect to login page end end end
And to force
authorize call in order not to forget it:
class ApplicationController < ActionController::Base check_authorization end
You should not use routing constraints for authentication with Rails. If you to detach authorization check from the specific controllers and pull it to a special permissions file, use an authorization library – it makes everything to fall into place. Which is no wonder, because this is an authorization.
- Routing constraints on their own are great. Use them, just don't do it for authentication.
- Authentication libraries that suggest routing constraints on their own are good. They suggest routing constraints because their competitors suggest them. Ignore it. This is not the reason you need this libraries – you want them for authentication.