Skip to main content
  1. Posts/

Rails Routing: Advanced Constraints for User Authentication without Devise

·431 words·3 mins·
Application Development

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:

Reply by Email

Related

JSON Data Type
·450 words·3 mins
Application Development

Whenever we save data from one of our Rails models, each attribute is mapped one to one to a field in the database. These fields are generally of a simple type, like a string or an integer. However, it’s also possible to save an entire data object in JSON format in a field. Let’s see an example of how to do this from a Ruby on Rails application.

ActiveRecord Except
·375 words·2 mins
Application Development

August 19 was Whyday, and to commemorate it, I decided to write a gem called activerecord-except.