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:
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
root_path. This laid the groundwork for two days of troubleshooting down the road.
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
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
# ==> 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.
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
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
- Implement the first
- Remove checks for
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:
Authorscan't log in as
Readerswithout signing out first
Readerscan't log in as
Authorswithout signing out first
- When a person signs in to either an
Readeraccount, they will then be redirected to the last page they were viewing before signing in
- Turbolinks are still working.