Using two Devise Models
I pretty much always default to using Devise for my Ruby on Rails authentication.
For my current side project built in Rails 6, I've got two authenticating models: Author
and Reader
. It makes sense for me to keep these two models entirely separate. It's made my life a lot easier, in general. Using multiple models is supported out of the box with Devise, with some custom configuration.
Preventing Cross-model Visits with Devise
Devise provides a sample solution to prevent a logged in Author
from concurrently logging in as a Reader
, and vice-versa.
I modified the sample code a little bit when I first implemented this. I didn't have anywhere to send them since I built it out early in the development process, so I had each model redirect_to
the root_path
. This laid the groundwork for two days of troubleshooting down the road.
My custom Accessible
module was initially written like this:
# ../controllers/concerns/accessible.rb
module Accessible
extend ActiveSupport::Concern
included do
before_action :check_user
end
protected
def check_user
if current_admin
flash.clear
# if you have rails_admin. You can redirect anywhere really
redirect_to(root_path) and return
elsif current_user
flash.clear
# The authenticated root path can be defined in your routes.rb in: devise_scope :user do...
redirect_to(root_path) and return
end
end
end
The Turbolinks Red Herring
As time went on, I built out most of the functionality of the application. I got the MVP working, built out some seed data, and deployed to a staging environment. I did some manual testing and played around with everything, and noticed that whenever I logged in as a sample Author
or Reader
, I got redirected to the home page. I assumed I had misconfigured something, so I followed this wiki article to redirect users back to their last visited page.
The wiki article provides two different implementations. I tried both and couldn't get it to work for the life of me. I dug in to the helper methods, hard coded returns, ran byebug sessions, and couldn't quite figure out waht wasn't working.
But I noticed that both of the methods provided by Devise checked if the request was an XHR. I thought I had identified the bug: I was using turbolinks out of the box with Rails 6. I don't know precisely what Turbolinks is doing under the hood, but my understanding is that it uses client-side requests to pre-render pages from the same origin, providing an SPA-like experience.
I figured Turbolinks navigation was breaking the expected Devise behavior. I found semi-recent blog posts about this phenomenon. I saw a few issues across the Turbolinks and Devise repositiories. I figured I was on to something.
Building a Reduced Test Case
So after I latched on to this idea, I decided I would write a blog post about it. To demonstrate the problem, I spun up a sample Rails app with Turbolinks and Devise. I duplicated my issue. As I went to turn off Turbolinks and demonstrate the root cause, I found this snippet in config/initializers/devise.rb
:
# ==> Turbolinks configuration
# If your app is using Turbolinks, Turbolinks::Controller needs to be included to make redirection work correctly:
#
# ActiveSupport.on_load(:devise_failure_app) do
# include Turbolinks::Controller
# end
So I un-commented the piece of configuration, but no luck. There was still a problem.
Removing the current_user
Check
In the first StoreLocation example, I noticed that store_location_for(:user, request.fullpath)
was only being triggered if current_user.present?
returned true.
But that doesn't track for me. I want to store the location for a User
(or Author
, or Reader
, or whomever) before they log in. So I removed that check. In my sample app, with just one User
Devise model, and with Turbolinks::Controller
turned on, I implemented the following:
# This example assumes that you have setup devise to authenticate a class named User.
class ApplicationController < ActionController::Base
before_action :store_user_location!, if: :storable_location?
# The callback which stores the current location must be added before you authenticate the user
# as `authenticate_user!` (or whatever your resource is) will halt the filter chain and redirect
# before the location can be stored.
before_action :authenticate_user!
private
# Its important that the location is NOT stored if:
# - The request method is not GET (non idempotent)
# - The request is handled by a Devise controller such as Devise::SessionsController as that could cause an
# infinite redirect loop.
# - The request is an Ajax request as this can lead to very unexpected behaviour.
def storable_location?
request.get? && is_navigational_format? && !devise_controller? && !request.xhr?
end
def store_user_location!
# :user is the scope we are authenticating
store_location_for(:user, request.fullpath)
end
end
And it worked! I figured I had solved my problem. All I needed to do to make it work in my actual application was:
- Turn on
Turbolinks::Controller
- Implement the first
store_user_location!
method - Remove checks for
current_author
andcurrent_reader
- ???
- Profit!
Hubris. Pure Hubris.
I made those changes to my application. No luck. I tried the second implementation of store_user_locaiton!
... no luck. I turned Turbolinks on and off again. No luck. I yelled a little bit into a pillow. No luck, but it felt good.
I started digging into the differences between the sample application and my real application. I figured the biggest difference was using one Devise model vs. using many. So I started looking at the generated controllers and views for my multiple Devise models. I wrote some experimental code, tried to override after_sign_in_path_for
from Devise, and still no luck.
But, if you'll recall, I was preventing cross-model visits with my Accessible
module. For laughs, I turned off the module. Things started working.
The Real Culprit: Hardcoded Redirects
Remember how I told my Accessible
module to redirect_to(root_path) and return
in check_user
? Yeah - me neither. I forgot I had written that method long ago before the application had taken shape. Forgot that I was hardcoding a redirect to the root path.
So I tapped into the stored_location_for
method from Devise. I rewrote the Accessible
module to look like:
module Accessible
extend ActiveSupport::Concern
included do
before_action :check_user
end
protected
def check_user
if current_author
flash.clear
redirect_to(stored_location_for(:author)) and return
elsif current_reader
flash.clear
redirect_to(stored_location_for(:reader)) and return
end
end
end
And everything started working. Now:
-
Authors
can't log in asReaders
without signing out first -
Readers
can't log in asAuthors
without signing out first - When a person signs in to either an
Author
or aReader
account, they will then be redirected to the last page they were viewing before signing in - Turbolinks are still working.
Top comments (2)
Great writeup. I commend you for not being one of the people that this tweet is targeting. I'm totally guilty of disabling turbolinks on new projects simply because I've always disabled turbolinks. I think I'm going to revisit some old side projects and re-enable turbolinks and see what kind of trouble I can get into.
Thanks for reading! Just tryna do my best and keep it conventional, hah.