Since Rails 7, there’s more and more tooling that enables us, developers, to roll our own authentication. Devise is great and has been an amazing companion over the years. It also has this neat little feature - an authenticated
route constraint which “hides” certain routes from people that are not signed in.
If you’re using devise
, you might have seen something like this:
# routes.rb
authenticated { |user| user.admin? } do
get :dashboard
end
We’ll try to replicate this natively.
Constraint
We’ll leverage advanced routing constraints to make this happen. Honestly, it’s not as scary as it sounds. It basically means that we’ll extract the logic to a class.
Below is a barebones class for the constraint.
# app/constraints/authenticated_constraint.rb
class AuthenticatedConstraint
def matches?(request)
return true_if_authenticated_method
end
end
How does the user authentication work?
Web applications usually leverage cookies to facilitate user authentication. When a user signs in, a cookie with a unique identifier is set in the browser. This cookie is then used to identify the user on subsequent requests.
Therefore, the signing in logic in your application might look something like this:
def sign_in(user)
Current.user = user
cookies.encrypted.permanent[:autheticated_user_id] = user.id
end
Similarly, when a user signs out, the cookie is removed. Pretty simple.
Accessing the cookies
In order to figure out whether a user is signed in, we’ll use the same exact cookies in the constraint.
Rails usually encrypts the cookies for security reasons. Fortunately, there’s decryption tooling that helps to dip our fingers into the cookie jar.
def matches?(request)
@cookies = ActionDispatch::Cookies::CookieJar.build(request, request.cookies)
# And then the cookies are accessible, for example:
@cookies.encrypted[:authenticated_user_id]
# ...
end
Passing a block
A neat little thing that authenticated
also method does is that it allows passing a block. This block helps to add additional conditions to the constraint. For example, checking whether the user is an administrator.
In other words, we’ll recreate that { |user| user.admin? }
part.
def initialize(&block)
@block = block || lambda { |user| true }
end
def matches?(request)
# ...
# Here we could call the supplied block
@block.call(current_user)
# ...
end
Voilà!
Finally, we can mix everything together and add some finishing touches to make everything more readable.
The finished constraint class is as follows:
# app/constraints/authenticated_constraint.rb
class AuthenticatedConstraint
def initialize(&block)
@block = block || lambda { |user| true }
end
def matches?(request)
@cookies = ActionDispatch::Cookies::CookieJar.build(request, request.cookies)
return signed_in? && @block.call(current_user)
end
private
def current_user
@user ||= User.find(@cookies.encrypted[:authenticated_user_id])
end
def signed_in?
current_user.present?
end
end
Obviously, it might need some adjustment to fit your application.
Implementation
Adding this constraint into action is rather straight forward as authenticated
method was used as an inspiration.
# routes.rb
# Routes available to admins only
constraints AuthenticatedConstraint.new{ |user| user.admin? } do
mount Sidekiq::Web => "/sidekiq"
get :admin_dashboard
end
# Routes available to authenticated users
constraints AuthenticatedConstraint.new do
get :my_profile
root to: :dashboard, as: :user_root
end
That’s it! 🎉
Wrapping up
Hopefully, this article has shed some light on how to create a route constraint for authenticated users. It’s rather useful when trying to set a new root route for authenticated users. Also, limit the access to certain routes based on user roles.