Migrating Away from Devise Part 2: Sign-in

Published: December 27, 2024
Part 2 of a multi-part series for moving away from devise to Rails' authentication generator
This is the second part of a multi-part series about moving from devise to the Rails generated authentication system

From this point forward, all work is being done in a separate branch. Once merged, devise will be completely removed from the app.

To start things off, I commented out the devise declaration in the User model. This frees the model from devise while still keeping a checklist of the functionality that was used. I also enabled the has_secure_password feature for the model. A validation and normalizer for email is also added as devise originally handled it via "validatable" module.

class User < ApplicationRecord
  # ...
  # Include default devise modules. Others available are:
  # :timeoutable, :lockable, :recoverable, and :omniauthable
  # devise :database_authenticatable, :registerable,
  #        :rememberable, :trackable, :validatable, :confirmable

  has_secure_password

  validates :email, presence: true, uniqueness: true, format: { with: /\A[^@\s]+@[^@\s]+\z/ }
  normalizes :email, with: -> (email) { email.strip.downcase }
  # ...

Devise uses an encrypted_password column which is different to what Rails' has_secure_password field uses. Since they are both using bcrypt, all that's really needed to do is change the column name. This is done through a migration.

class RenameUserPasswordField < ActiveRecord::Migration[8.0]
  def change
    rename_column :users, :encrypted_password, :password_digest
  end
end

A new SessionsController is made which is a mostly copy/paste from the Rails authentication generator. I have a Rails cache store set up for production already, so the rate limiting helper will just work once deployed.

class SessionsController < ApplicationController
  allow_unauthenticated_access only: %i[ new create ]
  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." }

  def new
  end

  def create
    user = User.authenticate_by(params.expect(user: [:username, :password]))
    if user&.active_for_authentication?
      start_new_session_for user
      redirect_to after_authentication_url
    else
      redirect_to new_session_path, alert: "Try another username or password."
    end
  end

  def destroy
    terminate_session
    redirect_to new_session_path
  end
end

The largest change from the Rails default is the if-statement in the create action. This app uses the devise active_for_authentication? "hook method" on the User model to further check if the user can sign in. In this app's case, the method checks for the value of the active? method which performs some business logic. It also now needs to check that confirmed_at is set since devise used to do that by default. After removing devise from the model, the method looks like this:

  def active_for_authentication?
-    super && active?
+    active? && confirmed_at.present?    
  end

Also, because the app uses a username/password combo to sign in, the params passed into authenticate_by is changed from email to username.

To expose the new controller and attempt to (mostly) maintain compatibility with existing routes, these routes are placed before the devise_for helper in routes.rb:

resource :session, only: [:new, :create, :destroy]
get "users/sign_in", to: "sessions#new", as: :new_user_session

app/views/devise/sessions/new.html.erb is moved to app/views/sessions/new.html.erb. The only real changes to the view is the call to form_with and removing the "remember me" checkbox:

- <%= form_with model: resource, as: resource_name, url: session_path(resource_name), html: { class: "space-y-4", data: { turbo_frame: "_top" } } do |f| %>
+ <%= form_with scope: :user, url: session_path, html: { class: "space-y-4", data: { turbo_frame: "_top" } } do |f| %>

...
- <% if devise_mapping.rememberable? %>
-   <div class="field">
-     <%= f.check_box :remember_me %>
-     <%= f.label :remember_me %>
-   </div>
- <% end %>

The set_current method in ApplicationController needs a slight tweak since User is no longer under devise. The Current class can also directly link to user through session.

  def set_current
    start_new_session_for(Current.user) unless resume_session
  end
  class Current < ActiveSupport::CurrentAttributes
-  attribute :user
    attribute :session
+  delegate :user, to: :session, allow_nil: true
  end

Additionally, the Authentication module will get two helper methods to replace the ones devise provided...

def current_user
  Current.user
end

def user_signed_in?
  authenticated?
end

Lastly, a quick find/replace of ":authenticate_user!" for ":require_authentication" swaps out the user of the devise before action. Alternatively, I could have aliased the method.

Next time: Part 3 - Supporting password reset