Migrating Away from Devise Part 1: Setup and Sessions
Published: December 19, 2024
This is the first part of a multi-part series about moving from devise to the Rails generated authentication system
Rails 8.0 introduced a basic authentication generator. I have a moderately sized side project app that uses devise. I'm always looking for away to eliminate 3rd party dependencies and wanted to see what it would take to migrate to this new system.
The current setup is as follows:
- Rails 8.0 app with all dependencies updated.
- Devise using the database_authenticatable, registerable, rememberable, trackable, validatable, and confirmable modules.
- I want to maintain support for all those features devise provides with the exception of rememberable. I'm fine having users always "remembered" when they sign in.
- User accounts have an email, password, and username needed to sign up. Signing in requires a username and password combination.
- Controllers for devise as well as views were generated and live in
app/controllers/devise/
andapp/views/devise/
for customization. Routes were already modified to handle the non-default controllers and paths (if applicable).
My goal is the swap out devise with the Rails generated authentication module plus supporting all the features I was using with devise AND keep as many users signed in as possible when the switch happens.
For step one, we're going to tackle the part where I want as many existing users to remain signed in for the switch. The Rails authentication generator includes an Authentication
module and Session
model. The plan is to hook into the requests when the user is signed in and create a Session
record and set the session_id
cookie Rails uses. This cookie lives along side the cookie devise uses. The theory is when I remove devise, the new cookie and sign in logic will take over and maintain the user's existing signed in session.
First, from the Rails source code I created a Session
migration and model by copy and pasting the template that would normally be created with rails generate authentication
. I find copy, pasting, and modifying in an existing app easier than using a generator that will most likely collide with the current code. The User
model is also updated for this new relation.
class CreateSessions < ActiveRecord::Migration[8.0] def change create_table :sessions do |t| t.references :user, null: false, foreign_key: true t.string :ip_address t.string :user_agent t.timestamps end end end
class Session < ApplicationRecord belongs_to :user end
class User < ApplicationRecord # ... has_many :sessions, dependent: :destroy # ... end
Next, I do the same for the Authentication
controller module. The only change I need is to comment out line 5 so the authentication check doesn't run. To be honest, I probably will end up just deleting line anyway as only certain pages need authentication.
module Authentication extend ActiveSupport::Concern included do # before_action :require_authentication helper_method :authenticated? end class_methods do def allow_unauthenticated_access(**options) skip_before_action :require_authentication, **options before_action :resume_session end end private def authenticated? resume_session end def require_authentication resume_session || request_authentication end def resume_session Current.session ||= find_session_by_cookie end def find_session_by_cookie return unless (id = cookies.signed[:session_id]) Session.find_by(id: id) end def request_authentication session[:return_to_after_authenticating] = request.url redirect_to new_session_url end def after_authentication_url session.delete(:return_to_after_authenticating) || root_url end def start_new_session_for(user) user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| Current.session = session cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } end end def terminate_session Current.session&.destroy cookies.delete(:session_id) end end
Next, I include the module in ApplicationController
and add a session
in my Current
class.
class ApplicationController < ActionController::Base # ... include Authentication # ... end
class Current < ActiveSupport::CurrentAttributes # ... attribute :session # ... end
When a user makes a request and is signed in, a new Session
record should be created or an existing one is found if they have the signed session_id
cookie set. I already have a before_action
in ApplicationController
that runs if the user is signed in (using the devise user_signed_in?
helper). I can add to that method to do what I need to do. The addition is the second line in the method. start_new_session_for
will create a new Session
record and set it in Current.session
if resume_session
doesn't find a matching record using the cookie. This is a temporary measure. Devise is still in full control determining if a user is signed in. Once devise is removed, this code will changeto allow the session objects and the new cookies to take over and maintain the user's signed in session.
class ApplicationController < ActionController::Base # ... include Authentication # ... before_action :set_current, if: :user_signed_in? # ... private def set_current Current.user = current_user start_new_session_for(Current.user) unless resume_session end # ... end
Finally, when the user signs out, I want to remove the Session
record. I've already exported and modified the built-in devise controllers, so that work is mostly done. In the SessionsController
which eventually inherits up to ApplicationController
we'll call terminate_session
to remove the Session
record and let devise take it from there.
class Users::SessionsController < Devise::SessionsController # ... def destroy terminate_session super end # ... end
Lastly, the bcrypt
gem needs to be uncommented in the Gemfile if it's not already as we'll eventually remove the devise
gem which will remove bcrypt
if we don't make it a top level dependency..
After that, this code and be committed and deployed. Session
records will start to gather over time in preparation for the final switch.
From this point on, all other changes to remove devise will be done in a separate branch as that will result in devise being completely removed from the app in one go letting my own implementation take over.