Rails Routing: Advanced Constraints for User Authentication without Devise
Many times we mount engines and restrict access to admin users via Devise. In this post, I’ll show you how to do the same when using a different authentication mechanism.
Let's take for example the Sidekiq engine. According to their wiki, all we need to do is surround the mount using the authenticate method.
# config/routes.rb
authenticate :user, ->(user) { user.admin? } do
mount Sidekiq::Web => '/sidekiq'
end
But since this method is a Devise helper method, how can we achieve the same results when we use a different authentication mechanism?
Turns out it’s actually very simple. We can use a Rails’ advanced constraint.
# config/routes.rb
mount Sidekiq::Web, at: '/sidekiq', constraints: AdminConstraint
Not too shabby! It looks even better than the Devise helper method IMO. But let’s dive into this constraint.
For the sake of simplification, I will assume that our authentication mechanism consist of a JWT token which gets saved on a cookie and a service which takes care of verifying that token. This service will also return a user when successful or nil otherwise. Replace this behaviour for whatever mechanism you have instead.
# app/constraints/admin_contraint.rb
class AdminConstraint
class << self
def matches?(request)
user = TokenAuthenticationService.new(request.cookies['authToken']).call
user.present? && user.admin?
end
end
end
Yes, it's a bit more code, but not that much and it allows us to leave the routes file a bit cleaner and to have a single place where to define what access to admin means.
Let's finish the job by adding a test. I like RSpec, so I’ll write a request tests.
I'll also assume that you have a token generation service.
# spec/constraints/admin_constraint_spec.rb
require "rails_helper"
# we won't want to rely on sidekiq for our test, so we'll create a dummy Engine
module MyEngine
class Engine < ::Rails::Engine
isolate_namespace MyEngine
end
class LinksController < ::ActionController::Base
def index
render plain: 'hit_engine_route'
end
end
end
MyEngine::Engine.routes.draw do
resources :links, :only => [:index]
end
module MyEngine
RSpec.describe "Links", :type => :request do
include Engine.routes.url_helpers
before do
Rails.application.routes.draw do
mount MyEngine::Engine => "/my_engine", constraints: AdminConstraint
end
cookies['authToken'] = token
end
after do
Rails.application.routes_reloader.reload!
end
let(:token) { TokenGeneratorService.new(user).call }
context 'with an admin token cookie' do
let(:user) { create(:user, admin: true) }
it "is found" do
get links_url
expect(response).to have_http_status(:ok)
expect(response.body).to eq('hit_engine_route')
end
end
context 'with a non-admin user' do
let(:user) { create(:user, admin: false) }
it "is not found" do
expect {
get links_url
}.to raise_error(ActionController::RoutingError)
end
end
end
end
Et voila! We’re sure that our constraint behaves as expected.
All the code in this post was based on the documentation from the following projects: