Securing Rails routes using constraints with the authentication generator
With Rails 8 introducing the new authentication generator, some apps might benefit from using a simpler authentication mechanism instead of resorting to libraries, thereby reducing dependencies and making Rails even more batteries-included than before.
However, the Rails authentication generator was built with simplicity in mind, leaving it to the developer to implement more complex features.
One of these missing features is the ability to protect routes directly during declaration. Normally, you would use a before_action
method inside a controller to authenticate users, but sometimes, you need to mount an engine in the Rails routing declaration instead, requiring a different method of protecting those routes.
An example of this, using devise, would be like so:
Rails.application.routes.draw do
# some defined routes...
authenticate :user, ->(user) {user.admin?} do
mount ExternalLibrary::Engine, at: "/external-library"
end
end
or
Rails.application.routes.draw do
# some defined routes...
authenticated do
mount ExternalLibrary::Engine, at: "/external-library"
end
end
See the authenticate
function? That comes from devise, implementing a function that allows you to protect routes at the routes.rb
file, as opposed to a traditional controller before_action
approach. However, the rails authentication generator does not provide a similar function out-of-the-box. So let’s implement something similar!
For that, we will need to use Rails routing constraints. There are a lot of ways to use advanced constraints. One way is to define a class with a self method named matches?
:
class SomeConstraint
def self.matches?(request)
# constraint logic
end
end
I use a folder under /app/constraints to manager my constraints, but feel free to put where you think it fits better.
and then we will use it in block format inside the Rails routes like so:
class Rails.application.routes.draw do
constraints SomeConstraint do
mount ExternalLibrary::Engine, at: "/external-library"
end
end
Now, all you need is to add the rails authentication logic into your constraints.
For example, at one application of mine, I use it like so:
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
has_many :houses, dependent: :destroy, foreign_key: :host_id
has_many :projects, dependent: :destroy, foreign_key: :cleaner_id
enum :role, {host: "host", manager: "manager", cleaner: "cleaner"}
normalizes :email_address, with: ->(e) { e.strip.downcase }
validates :name, presence: true
validates :email_address, presence: true, uniqueness: true
end
# /app/constraints/manager_contraint
class ManagerConstraint
def self.matches?(request)
session_id = request.cookie_jar.signed[:session_id]
return false unless session_id
session = Session.find_by(id: session_id)
user = session&.user
user&.manager?
end
end
Rails.application.routes.draw do
# some defined routes...
# only allow managers to access litestream dashboard
constraints ManagerConstraint do
mount Litestream::Engine, at: "/litestream"
end
end