Rails Authentication Routing Constraints Considered Harmful
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
But it's not. The thing is that methods like
authenticate_user! do not return 404 to non-logged-in users. Instead they redirect the user to the sign in page with the message "Please, sign in" and after a successful sign in in they often bring user to his original destination.
In the meantime the routing constraints can only return 404. And this is very inconvenient for the user. If the user cleans up the session in browser and tries to visit a familiar page, he sees "Page Not Found." Moreover, "Page Not Found" in Rails by default does not contain navigation and layout, so the user has to type something in the address bar again and has no clue what is going on and where he is.
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.