Migrating Away from Devise Part 2: Sign-in
Published: December 27, 2024
This is the second part of a multi-part series about moving from devise to the Rails generated authentication system
- Part 1 - Setup and Sessions
- Part 2 - User Sign-in
- Part 3 - Password Recovery
- Part 4 - Email Confirmation Setup
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.