Migrating Away from Devise Part 1: Setup and Sessions

Published: December 19, 2024
Part 1 of a multi-part series for moving away from devise to Rails' authentication generator
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/ and app/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.

Coming soon: Part 2 - Supporting users signing in