If you're like me then you probably take Devise for granted because you're too intimidated to roll your own authentication system. As powerful as Devise is, it's not perfect. There are plenty of cases where I've reached for it only to end up constrained by its features and design, and wished I could customize it exactly to my liking.
Fortunately, Rails gives you all the tools you need to roll your own authentication system from scratch without needing to depend on a gem. The challenge is just knowing how to account for edge cases while being cognizant of security and best practices.
Previous Versions
This guide is continuously updated to account for best practices. You can view previous releases here.
Step 1: Build User Model
- Generate User model.
rails g model User email:string
# db/migrate/[timestamp]_create_users.rb
class CreateUsers < ActiveRecord::Migration[6.1]
def change
create_table :users do |t|
t.string :email, null: false
t.timestamps
end
add_index :users, :email, unique: true
end
end
- Run migrations.
rails db:migrate
- Add validations and callbacks.
# app/models/user.rb
class User < ApplicationRecord
before_save :downcase_email
validates :email, format: {with: URI::MailTo::EMAIL_REGEXP}, presence: true, uniqueness: true
private
def downcase_email
self.email = email.downcase
end
end
What's Going On Here?
- We prevent empty values from being saved into the email column through a
null: falseconstraint in addition to the presence validation.- We enforce unique email addresses at the database level through
add_index :users, :email, unique: truein addition to a uniqueness validation.- We ensure all emails are valid through a format validation.
- We save all emails to the database in a downcase format via a before_save callback such that the values are saved in a consistent format.
- We use URI::MailTo::EMAIL_REGEXP that comes with Ruby to validate that the email address is properly formatted.
Step 2: Add Confirmation and Password Columns to Users Table
- Create migration.
rails g migration add_confirmation_and_password_columns_to_users confirmed_at:datetime password_digest:string
- Update the migration.
# db/migrate/[timestamp]_add_confirmation_and_password_columns_to_users.rb
class AddConfirmationAndPasswordColumnsToUsers < ActiveRecord::Migration[6.1]
def change
add_column :users, :confirmed_at, :datetime
add_column :users, :password_digest, :string, null: false
end
end
What's Going On Here?
- The
confirmed_atcolumn will be set when a user confirms their account. This will help us determine who has confirmed their account and who has not.- The
password_digestcolumn will store a hashed version of the user's password. This is provided by the has_secure_password method.
- Run migrations.
rails db:migrate
- Enable and install BCrypt.
This is needed to use has_secure_password.
# Gemfile
gem 'bcrypt', '~> 3.1.7'
bundle install
- Update the User Model.
# app/models/user.rb
class User < ApplicationRecord
CONFIRMATION_TOKEN_EXPIRATION = 10.minutes
has_secure_password
before_save :downcase_email
validates :email, format: {with: URI::MailTo::EMAIL_REGEXP}, presence: true, uniqueness: true
def confirm!
update_columns(confirmed_at: Time.current)
end
def confirmed?
confirmed_at.present?
end
def generate_confirmation_token
signed_id expires_in: CONFIRMATION_TOKEN_EXPIRATION, purpose: :confirm_email
end
def unconfirmed?
!confirmed?
end
private
def downcase_email
self.email = email.downcase
end
end
What's Going On Here?
- The
has_secure_passwordmethod is added to give us an API to work with thepassword_digestcolumn.- The
confirm!method will be called when a user confirms their email address. We still need to build this feature.- The
confirmed?andunconfirmed?methods allow us to tell whether a user has confirmed their email address or not.- The
generate_confirmation_tokenmethod creates a signed_id that will be used to securely identify the user. For added security, we ensure that this ID will expire in 10 minutes (this can be controlled with theCONFIRMATION_TOKEN_EXPIRATIONconstant) and give it an explicit purpose of:confirm_email. This will be useful when we build the confirmation mailer.
Step 3: Create Sign Up Pages
- Create a simple home page since we'll need a place to redirect users to after they sign up.
rails g controller StaticPages home
- Create Users Controller.
rails g controller Users
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
redirect_to root_path, notice: "Please check your email for confirmation instructions."
else
render :new, status: :unprocessable_entity
end
end
def new
@user = User.new
end
private
def user_params
params.require(:user).permit(:email, :password, :password_confirmation)
end
end
- Build sign-up form.
<!-- app/views/shared/_form_errors.html.erb -->
<% if object.errors.any? %>
<ul>
<% object.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
<% end %>
<!-- app/views/users/new.html.erb -->
<%= form_with model: @user, url: sign_up_path do |form| %>
<%= render partial: "shared/form_errors", locals: { object: form.object } %>
<div>
<%= form.label :email %>
<%= form.email_field :email, required: true %>
</div>
<div>
<%= form.label :password %>
<%= form.password_field :password, required: true %>
</div>
<div>
<%= form.label :password_confirmation %>
<%= form.password_field :password_confirmation, required: true %>
</div>
<%= form.submit "Sign Up" %>
<% end %>
- Update routes.
# config/routes.rb
Rails.application.routes.draw do
root "static_pages#home"
post "sign_up", to: "users#create"
get "sign_up", to: "users#new"
end
Step 4: Create Confirmation Pages
Users now have a way to sign up, but we need to verify their email address to prevent SPAM.
- Create Confirmations Controller.
rails g controller Confirmations
# app/controllers/confirmations_controller.rb
class ConfirmationsController < ApplicationController
def create
@user = User.find_by(email: params[:user][:email].downcase)
if @user.present? && @user.unconfirmed?
redirect_to root_path, notice: "Check your email for confirmation instructions."
else
redirect_to new_confirmation_path, alert: "We could not find a user with that email or that email has already been confirmed."
end
end
def edit
@user = User.find_signed(params[:confirmation_token], purpose: :confirm_email)
if @user.present?
@user.confirm!
redirect_to root_path, notice: "Your account has been confirmed."
else
redirect_to new_confirmation_path, alert: "Invalid token."
end
end
def new
@user = User.new
end
end
- Build confirmation pages.
This page will be used in the case where a user did not receive their confirmation instructions and needs to have them resent.
<!-- app/views/confirmations/new.html.erb -->
<%= form_with model: @user, url: confirmations_path do |form| %>
<%= form.email_field :email, required: true %>
<%= form.submit "Confirm Email" %>
<% end %>
- Update routes.
# config/routes.rb
Rails.application.routes.draw do
...
resources :confirmations, only: [:create, :edit, :new], param: :confirmation_token
end
What's Going On Here?
- The
createaction will be used to resend confirmation instructions to an unconfirmed user. We still need to build this mailer, and we still need to send this mailer when a user initially signs up. This action will be requested via the form onapp/views/confirmations/new.html.erb. Note that we calldowncaseon the email to account for case sensitivity when searching.- The
editaction is used to confirm a user's email. This will be the page that a user lands on when they click the confirmation link in their email. We still need to build this. Note that we're looking up a user through the find_signed method and not their email or ID. This is because Theconfirmation_tokenis randomly generated and can't be guessed or tampered with unlike an email or numeric ID. This is also why we addedparam: :confirmation_tokenas a named route parameter.
- You'll remember that the
confirmation_tokenis a signed_id, and is set to expire in 10 minutes. You'll also note that we need to pass the methodpurpose: :confirm_emailto be consistent with the purpose that was set in thegenerate_confirmation_tokenmethod.
Step 5: Create Confirmation Mailer
Now we need a way to send a confirmation email to our users for them to actually confirm their accounts.
- Create a confirmation mailer.
rails g mailer User confirmation
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
default from: User::MAILER_FROM_EMAIL
def confirmation(user, confirmation_token)
@user = user
@confirmation_token = confirmation_token
mail to: @user.email, subject: "Confirmation Instructions"
end
end
<!-- app/views/user_mailer/confirmation.html.erb -->
<h1>Confirmation Instructions</h1>
<%= link_to "Click here to confirm your email.", edit_confirmation_url(@confirmation_token) %>
<!-- app/views/user_mailer/confirmation.text.erb -->
Confirmation Instructions
<%= edit_confirmation_url(@confirmation_token) %>
- Update User Model.
# app/models/user.rb
class User < ApplicationRecord
...
MAILER_FROM_EMAIL = "no-reply@example.com"
...
def send_confirmation_email!
confirmation_token = generate_confirmation_token
UserMailer.confirmation(self, confirmation_token).deliver_now
end
end
What's Going On Here?
- The
MAILER_FROM_EMAILconstant is a way for us to set the email used in theUserMailer. This is optional.- The
send_confirmation_email!method will create a newconfirmation_token. This is to ensure confirmation links expire and cannot be reused. It will also send the confirmation email to the user.- We call update_columns so that the
updated_at/updated_oncolumns are not updated. This is personal preference, but those columns should typically only be updated when the user updates their email or password.- The links in the mailer will take the user to
ConfirmationsController#editat which point they'll be confirmed.
- Configure Action Mailer so that links work locally.
Add a host to the test and development (and later the production) environments so that urls will work in mailers.
# config/environments/test.rb
Rails.application.configure do
...
config.action_mailer.default_url_options = { host: "example.com" }
end
# config/environments/development.rb
Rails.application.configure do
...
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
end
- Update Controllers.
Now we can send a confirmation email when a user signs up or if they need to have it resent.
# app/controllers/confirmations_controller.rb
class ConfirmationsController < ApplicationController
def create
@user = User.find_by(email: params[:user][:email].downcase)
if @user.present? && @user.unconfirmed?
@user.send_confirmation_email!
...
end
end
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
@user.send_confirmation_email!
...
end
end
end
Step 6: Create Current Model and Authentication Concern
- Create a model to store the current user.
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :user
end
- Create a Concern to store helper methods that will be shared across the application.
# app/controllers/concerns/authentication.rb
module Authentication
extend ActiveSupport::Concern
included do
before_action :current_user
helper_method :current_user
helper_method :user_signed_in?
end
def login(user)
reset_session
session[:current_user_id] = user.id
end
def logout
reset_session
end
def redirect_if_authenticated
redirect_to root_path, alert: "You are already logged in." if user_signed_in?
end
private
def current_user
Current.user ||= session[:current_user_id] && User.find_by(id: session[:current_user_id])
end
def user_signed_in?
Current.user.present?
end
end
- Load the Authentication Concern into the Application Controller.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Authentication
end
What's Going On Here?
- The
Currentclass inherits from ActiveSupport::CurrentAttributes which allows us to keep all per-request attributes easily available to the whole system. In essence, this will allow us to set a current user and have access to that user during each request to the server.- The
AuthenticationConcern provides an interface for logging the user in and out. We load it into theApplicationControllerso that it will be used across the whole application.
- The
loginmethod first resets the session to account for session fixation.- We set the user's ID in the session so that we can have access to the user across requests. The user's ID won't be stored in plain text. The cookie data is cryptographically signed to make it tamper-proof. And it is also encrypted so anyone with access to it can't read its contents.
- The
logoutmethod simply resets the session.- The
redirect_if_authenticatedmethod checks to see if the user is logged in. If they are, they'll be redirected to theroot_path. This will be useful on pages an authenticated user should not be able to access, such as the login page.- The
current_usermethod returns aUserand sets it as the user on theCurrentclass we created. We use memoization to avoid fetching the User each time we call the method. We call thebefore_actionfilter so that we have access to the current user before each request. We also add this as a helper_method so that we have access tocurrent_userin the views.- The
user_signed_in?method simply returns true or false depending on whether the user is signed in or not. This is helpful for conditionally rendering items in views.
Step 7: Create Login Page
- Generate Sessions Controller.
rails g controller Sessions
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
before_action :redirect_if_authenticated, only: [:create, :new]
def create
@user = User.find_by(email: params[:user][:email].downcase)
if @user
if @user.unconfirmed?
redirect_to new_confirmation_path, alert: "Incorrect email or password."
elsif @user.authenticate(params[:user][:password])
login @user
redirect_to root_path, notice: "Signed in."
else
flash.now[:alert] = "Incorrect email or password."
render :new, status: :unprocessable_entity
end
else
flash.now[:alert] = "Incorrect email or password."
render :new, status: :unprocessable_entity
end
end
def destroy
logout
redirect_to root_path, notice: "Signed out."
end
def new
end
end
- Update routes.
# config/routes.rb
Rails.application.routes.draw do
...
post "login", to: "sessions#create"
delete "logout", to: "sessions#destroy"
get "login", to: "sessions#new"
end
- Add sign-in form.
<!-- app/views/sessions/new.html.erb -->
<%= form_with url: login_path, scope: :user do |form| %>
<div>
<%= form.label :email %>
<%= form.email_field :email, required: true %>
</div>
<div>
<%= form.label :password %>
<%= form.password_field :password, required: true %>
</div>
<%= form.submit "Sign In" %>
<% end %>
What's Going On Here?
- The
createmethod simply checks if the user exists and is confirmed. If they are, then we check their password. If the password is correct, we log them in via theloginmethod we created in theAuthenticationConcern. Otherwise, we render an alert.
- We're able to call
user.authenticatebecause of has_secure_password- Note that we call
downcaseon the email to account for case sensitivity when searching.- Note that we set the flash to "Incorrect email or password." if the user is unconfirmed. This prevents leaking email addresses.
- The
destroymethod simply calls thelogoutmethod we created in theAuthenticationConcern.- The login form is passed a
scope: :useroption so that the params are namespaced asparams[:user][:some_value]. This is not required, but it helps keep things organized.
Step 8: Update Existing Controllers
- Update Controllers to prevent authenticated users from accessing pages intended for anonymous users.
# app/controllers/confirmations_controller.rb
class ConfirmationsController < ApplicationController
before_action :redirect_if_authenticated, only: [:create, :new]
def edit
...
if @user.present?
@user.confirm!
login @user
...
else
end
...
end
end
Note that we also call login @user once a user is confirmed. That way they'll be automatically logged in after confirming their email.
# app/controllers/users_controller.rb
class UsersController < ApplicationController
before_action :redirect_if_authenticated, only: [:create, :new]
...
end
Step 9: Add Password Reset Functionality
- Update User Model.
# app/models/user.rb
class User < ApplicationRecord
...
PASSWORD_RESET_TOKEN_EXPIRATION = 10.minutes
...
def generate_password_reset_token
signed_id expires_in: PASSWORD_RESET_TOKEN_EXPIRATION, purpose: :reset_password
end
...
def send_password_reset_email!
password_reset_token = generate_password_reset_token
UserMailer.password_reset(self, password_reset_token).deliver_now
end
...
end
- Update User Mailer.
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
...
def password_reset(user, password_reset_token)
@user = user
@password_reset_token = password_reset_token
mail to: @user.email, subject: "Password Reset Instructions"
end
end
<!-- app/views/user_mailer/password_reset.html.erb -->
<h1>Password Reset Instructions</h1>
<%= link_to "Click here to reset your password.", edit_password_url(@password_reset_token) %>
<!-- app/views/user_mailer/password_reset.text.erb -->
Password Reset Instructions
<%= edit_password_url(@password_reset_token) %>
What's Going On Here?
- The
generate_password_reset_tokenmethod creates a signed_id that will be used to securely identify the user. For added security, we ensure that this ID will expire in 10 minutes (this can be controlled with thePASSWORD_RESET_TOKEN_EXPIRATIONconstant) and give it an explicit purpose of:reset_password.- The
send_password_reset_email!method will create a newpassword_reset_token. This is to ensure password reset links expire and cannot be reused. It will also send the password reset email to the user.
Step 10: Build Password Reset Forms
- Create Passwords Controller.
rails g controller Passwords
# app/controllers/passwords_controller.rb
class PasswordsController < ApplicationController
before_action :redirect_if_authenticated
def create
@user = User.find_by(email: params[:user][:email].downcase)
if @user.present?
if @user.confirmed?
@user.send_password_reset_email!
redirect_to root_path, notice: "If that user exists we've sent instructions to their email."
else
redirect_to new_confirmation_path, alert: "Please confirm your email first."
end
else
redirect_to root_path, notice: "If that user exists we've sent instructions to their email."
end
end
def edit
@user = User.find_signed(params[:password_reset_token], purpose: :reset_password)
if @user.present? && @user.unconfirmed?
redirect_to new_confirmation_path, alert: "You must confirm your email before you can sign in."
elsif @user.nil?
redirect_to new_password_path, alert: "Invalid or expired token."
end
end
def new
end
def update
@user = User.find_signed(params[:password_reset_token], purpose: :reset_password)
if @user
if @user.unconfirmed?
redirect_to new_confirmation_path, alert: "You must confirm your email before you can sign in."
elsif @user.update(password_params)
redirect_to login_path, notice: "Sign in."
else
flash.now[:alert] = @user.errors.full_messages.to_sentence
render :edit, status: :unprocessable_entity
end
else
flash.now[:alert] = "Invalid or expired token."
render :new, status: :unprocessable_entity
end
end
private
def password_params
params.require(:user).permit(:password, :password_confirmation)
end
end
What's Going On Here?
- The
createaction will send an email to the user containing a link that will allow them to reset the password. The link will contain theirpassword_reset_tokenwhich is unique and expires. Note that we calldowncaseon the email to account for case sensitivity when searching.
- You'll remember that the
password_reset_tokenis a signed_id, and is set to expire in 10 minutes. You'll also note that we need to pass the methodpurpose: :reset_passwordto be consistent with the purpose that was set in thegenerate_password_reset_tokenmethod.- Note that we return
Invalid or expired token.if the user is not found. This makes it difficult for a bad actor to use the reset form to see which email accounts exist on the application.- The
editaction simply renders the form for the user to update their password. It attempts to find a user by theirpassword_reset_token. You can think of thepassword_reset_tokenas a way to identify the user much like how we normally identify records by their ID. However, thepassword_reset_tokenis randomly generated and will expire so it's more secure.- The
newaction simply renders a form for the user to put their email address in to receive the password reset email.- The
updatealso ensures the user is identified by theirpassword_reset_token. It's not enough to just do this on theeditaction since a bad actor could make aPUTrequest to the server and bypass the form.
- If the user exists and is confirmed we update their password to the one they will set in the form. Otherwise, we handle each failure case differently.
- Update Routes.
# config/routes.rb
Rails.application.routes.draw do
...
resources :passwords, only: [:create, :edit, :new, :update], param: :password_reset_token
end
What's Going On Here?
- We add
param: :password_reset_tokenas a named route parameter so that we can identify users by theirpassword_reset_tokenand notid. This is similar to what we did with the confirmations routes and ensures a user cannot be identified by their ID.
- Build forms.
<!-- app/views/passwords/new.html.erb -->
<%= form_with url: passwords_path, scope: :user do |form| %>
<%= form.email_field :email, required: true %>
<%= form.submit "Reset Password" %>
<% end %>
<!-- app/views/passwords/edit.html.erb -->
<%= form_with url: password_path(params[:password_reset_token]), scope: :user, method: :put do |form| %>
<div>
<%= form.label :password %>
<%= form.password_field :password, required: true %>
</div>
<div>
<%= form.label :password_confirmation %>
<%= form.password_field :password_confirmation, required: true %>
</div>
<%= form.submit "Update Password" %>
<% end %>
What's Going On Here?
- The password reset form is passed a
scope: :useroption so that the params are namespaced asparams[:user][:some_value]. This is not required, but it helps keep things organized.
Step 11: Add Unconfirmed Email Column To Users Table
- Create and run migration.
rails g migration add_unconfirmed_email_to_users unconfirmed_email:string
rails db:migrate
- Update User Model.
# app/models/user.rb
class User < ApplicationRecord
...
attr_accessor :current_password
...
before_save :downcase_unconfirmed_email
...
validates :unconfirmed_email, format: {with: URI::MailTo::EMAIL_REGEXP, allow_blank: true}
def confirm!
if unconfirmed_or_reconfirming?
if unconfirmed_email.present?
return false unless update(email: unconfirmed_email, unconfirmed_email: nil)
end
update_columns(confirmed_at: Time.current)
else
false
end
end
...
def confirmable_email
if unconfirmed_email.present?
unconfirmed_email
else
email
end
end
...
def reconfirming?
unconfirmed_email.present?
end
def unconfirmed_or_reconfirming?
unconfirmed? || reconfirming?
end
private
...
def downcase_unconfirmed_email
return if unconfirmed_email.nil?
self.unconfirmed_email = unconfirmed_email.downcase
end
end
What's Going On Here?
- We add a
unconfirmed_emailcolumn to theuserstable so that we have a place to store the email a user is trying to use after their account has been confirmed with their original email.- We add
attr_accessor :current_passwordso that we'll be able to usef.password_field :current_passwordin the user form (which doesn't exist yet). This will allow us to require the user to submit their current password before they can update their account.- We ensure to format the
unconfirmed_emailbefore saving it to the database. This ensures all data is saved consistently.- We add validations to the
unconfirmed_emailcolumn ensuring it's a valid email address.- We update the
confirm!method to set theunconfirmed_emailcolumn, and then clear out theunconfirmed_emailcolumn. This will only happen if a user is trying to confirm a new email address. Note that we returnfalseif updating the email address fails. This could happen if a user tries to confirm an email address that has already been confirmed.- We add the
confirmable_emailmethod so that we can call the correct email in the updatedUserMailer.- We add
reconfirming?andunconfirmed_or_reconfirming?to help us determine what state a user is in. This will come in handy later in our controllers.
- Update User Mailer.
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
def confirmation(user, confirmation_token)
...
mail to: @user.confirmable_email, subject: "Confirmation Instructions"
end
end
- Update Confirmations Controller.
# app/controllers/confirmations_controller.rb
class ConfirmationsController < ApplicationController
...
def edit
...
if @user.present?
if @user.confirm!
login @user
redirect_to root_path, notice: "Your account has been confirmed."
else
redirect_to new_confirmation_path, alert: "Something went wrong."
end
else
...
end
end
...
end
What's Going On Here?
- We update the
editmethod to account for the return value of@user.confirm!. If for some reason@user.confirm!returnsfalse(which would most likely happen if the email has already been taken) then we render a generic error. This prevents leaking email addresses.
Step 12: Update Users Controller
- Update Authentication Concern.
# app/controllers/concerns/authentication.rb
module Authentication
...
def authenticate_user!
redirect_to login_path, alert: "You need to login to access that page." unless user_signed_in?
end
...
end
What's Going On Here?
- The
authenticate_user!method can be called to ensure an anonymous user cannot access a page that requires a user to be logged in. We'll need this when we build the page allowing a user to edit or delete their profile.
- Add destroy, edit and update methods. Modify create method and user_params.
# app/controllers/users_controller.rb
class UsersController < ApplicationController
before_action :authenticate_user!, only: [:edit, :destroy, :update]
...
def create
@user = User.new(create_user_params)
...
end
def destroy
current_user.destroy
reset_session
redirect_to root_path, notice: "Your account has been deleted."
end
def edit
@user = current_user
end
...
def update
@user = current_user
if @user.authenticate(params[:user][:current_password])
if @user.update(update_user_params)
if params[:user][:unconfirmed_email].present?
@user.send_confirmation_email!
redirect_to root_path, notice: "Check your email for confirmation instructions."
else
redirect_to root_path, notice: "Account updated."
end
else
render :edit, status: :unprocessable_entity
end
else
flash.now[:error] = "Incorrect password"
render :edit, status: :unprocessable_entity
end
end
private
def create_user_params
params.require(:user).permit(:email, :password, :password_confirmation)
end
def update_user_params
params.require(:user).permit(:current_password, :password, :password_confirmation, :unconfirmed_email)
end
end
What's Going On Here?
- We call
authenticate_user!before editing, destroying, or updating a user since only an authenticated user should be able to do this.- We update the
createmethod to acceptcreate_user_params(formerlyuser_params). This is because we're going to require different parameters for creating an account vs. editing an account.- The
destroyaction simply deletes the user and logs them out. Note that we're callingcurrent_user, so this action can only be scoped to the user who is logged in.- The
editaction simply assigns@userto thecurrent_userso that we have access to the user in the edit form.- The
updateaction first checks if their password is correct. Note that we're passing this in ascurrent_passwordand notpassword. This is because we still want a user to be able to change their password and therefore we need another parameter to store this value. This is also why we have a privateupdate_user_paramsmethod.
- If the user is updating their email address (via
unconfirmed_email) we send a confirmation email to that new email address before setting it as the- We force a user to always put in their
current_passwordas an extra security measure in case someone leaves their browser open on a public computer.
- Update routes.
# config/routes.rb
Rails.application.routes.draw do
...
put "account", to: "users#update"
get "account", to: "users#edit"
delete "account", to: "users#destroy"
...
end
- Create an edit form.
<!-- app/views/users/edit.html.erb -->
<%= form_with model: @user, url: account_path, method: :put do |form| %>
<%= render partial: "shared/form_errors", locals: { object: form.object } %>
<div>
<%= form.label :email, "Current Email" %>
<%= form.email_field :email, disabled: true %>
</div>
<div>
<%= form.label :unconfirmed_email, "New Email" %>
<%= form.text_field :unconfirmed_email %>
</div>
<div>
<%= form.label :password, "Password (leave blank if you don't want to change it)" %>
<%= form.password_field :password %>
</div>
<div>
<%= form.label :password_confirmation %>
<%= form.password_field :password_confirmation %>
</div>
<hr/>
<div>
<%= form.label :current_password, "Current password (we need your current password to confirm your changes)" %>
<%= form.password_field :current_password, required: true %>
</div>
<%= form.submit "Update Account" %>
<% end %>
What's Going On Here?
- We
disablethe- We
requirethecurrent_passwordfield since we'll always want a user to confirm their password before making changes.- The
passwordandpassword_confirmationfields are there if a user wants to update their current password.
Step 13: Update Confirmations Controller
- Update edit action.
# app/controllers/confirmations_controller.rb
class ConfirmationsController < ApplicationController
...
def edit
...
if @user.present? && @user.unconfirmed_or_reconfirming?
...
end
end
...
end
What's Going On Here?
- We add
@user.unconfirmed_or_reconfirming?to the conditional to ensure only unconfirmed users or users who are reconfirming can access this page. This is necessary since we're now allowing users to confirm new email addresses.
Step 14: Add Remember Token Column to Users Table
- Create migration.
rails g migration add_remember_token_to_users remember_token:string
- Update migration.
# db/migrate/[timestamp]_add_remember_token_to_users.rb
class AddRememberTokenToUsers < ActiveRecord::Migration[6.1]
def change
add_column :users, :remember_token, :string, null: false
add_index :users, :remember_token, unique: true
end
end
What's Going On Here?
- We add
null: falseto ensure this column always has a value.- We add a unique index to ensure this column has unique data.
- Run migrations.
rails db:migrate
- Update the User model.
# app/models/user.rb
class User < ApplicationRecord
...
has_secure_token :remember_token
...
end
What's Going On Here?
- We call has_secure_token on the
remember_token. This ensures that the value for this column will be set when the record is created. This value will be used later to securely identify the user.
Step 15: Update Authentication Concern
- Add new helper methods.
# app/controllers/concerns/authentication.rb
module Authentication
extend ActiveSupport::Concern
...
def forget(user)
cookies.delete :remember_token
user.regenerate_remember_token
end
...
def remember(user)
user.regenerate_remember_token
cookies.permanent.encrypted[:remember_token] = user.remember_token
end
...
private
def current_user
Current.user ||= if session[:current_user_id].present?
User.find_by(id: session[:current_user_id])
elsif cookies.permanent.encrypted[:remember_token].present?
User.find_by(remember_token: cookies.permanent.encrypted[:remember_token])
end
end
...
end
What's Going On Here?
- The
remembermethod first regenerates a newremember_tokento ensure these values are being rotated and can't be used more than once. We get theregenerate_remember_tokenmethod from has_secure_token. Next, we assign this value to a cookie. The call to permanent ensures the cookie won't expire until 20 years from now. The call to encrypted ensures the value will be encrypted. This is vital since this value is used to identify the user and is being set in the browser.- The
forgetmethod deletes the cookie and regenerates a newremember_tokento ensure these values are being rotated and can't be used more than once.- We update the
current_usermethod by adding a conditional to first try and find the user by the session, and then fallback to finding the user by the cookie. This is the logic that allows a user to completely exit their browser and remain logged in when they return to the website since the cookie will still be set.
Step 16: Update Sessions Controller
- Update the
createanddestroymethods.
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
...
before_action :authenticate_user!, only: [:destroy]
def create
...
if @user
if @user.unconfirmed?
...
elsif @user.authenticate(params[:user][:password])
login @user
remember(@user) if params[:user][:remember_me] == "1"
...
else
...
end
else
...
end
end
def destroy
forget(current_user)
...
end
...
end
What's Going On Here?
- We conditionally call
remember(@user)in thecreatemethod if the user has checked the "Remember me" checkbox. We still need to add this to our form.- We call
forget(current_user)in thedestroymethod to ensure we delete theremember_mecookie and regenerate the user'sremember_tokentoken.- We also add a
before_actionto ensure only authenticated users can access thedestroyaction.
- Add the "Remember me" checkbox to the login form.
<!-- app/views/sessions/new.html.erb -->
<%= form_with url: login_path, scope: :user do |form| %>
...
<div>
<%= form.label :remember_me %>
<%= form.check_box :remember_me %>
</div>
<%= form.submit "Sign In" %>
<% end %>
Step 17: Add Friendly Redirects
- Update Authentication Concern.
# app/controllers/concerns/authentication.rb
module Authentication
...
def authenticate_user!
store_location
...
end
...
private
...
def store_location
session[:user_return_to] = request.original_url if request.get? && request.local?
end
end
What's Going On Here?
- The
store_locationmethod stores the request.original_url in the session so it can be retrieved later. We only do this if the request made was agetrequest. We also callrequest.local?to ensure it was a local request. This prevents redirecting to an external application.- We call
store_locationin theauthenticate_user!method so that we can save the path to the page the user was trying to visit before they were redirected to the login page. We need to do this before visiting the login page otherwise the call torequest.original_urlwill always return the url to the login page.
- Update Sessions Controller.
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
...
def create
...
if @user
if @user.unconfirmed?
...
elsif @user.authenticate(params[:user][:password])
after_login_path = session[:user_return_to] || root_path
login @user
remember(@user) if params[:user][:remember_me] == "1"
redirect_to after_login_path, notice: "Signed in."
else
...
end
else
...
end
end
...
end
What's Going On Here?
- The
after_login_pathvariable it set to be whatever is in thesession[:user_return_to]. If there's nothing insession[:user_return_to]then it defaults to theroot_path.- Note that we call this method before calling
login. This is becauselogincallsreset_sessionwhich would deleted thesession[:user_return_to].
Step 17: Account for Timing Attacks
- Update the User model.
Note that this class method will be available in Rails 7.1
# app/models/user.rb
class User < ApplicationRecord
...
def self.authenticate_by(attributes)
passwords, identifiers = attributes.to_h.partition do |name, value|
!has_attribute?(name) && has_attribute?("#{name}_digest")
end.map(&:to_h)
raise ArgumentError, "One or more password arguments are required" if passwords.empty?
raise ArgumentError, "One or more finder arguments are required" if identifiers.empty?
if (record = find_by(identifiers))
record if passwords.count { |name, value| record.public_send(:"authenticate_#{name}", value) } == passwords.size
else
new(passwords)
nil
end
end
...
end
What's Going On Here?
- This class method serves to find a user using the non-password attributes (such as email), and then authenticates that record using the password attributes. Regardless of whether a user is found or authentication succeeds,
authenticate_bywill take the same amount of time. This prevents timing-based enumeration attacks, wherein an attacker can determine if a password record exists even without knowing the password.
- Update the Sessions Controller.
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
...
def create
@user = User.authenticate_by(email: params[:user][:email].downcase, password: params[:user][:password])
if @user
if @user.unconfirmed?
redirect_to new_confirmation_path, alert: "Incorrect email or password."
else
after_login_path = session[:user_return_to] || root_path
login @user
remember(@user) if params[:user][:remember_me] == "1"
redirect_to after_login_path, notice: "Signed in."
end
else
flash.now[:alert] = "Incorrect email or password."
render :new, status: :unprocessable_entity
end
end
...
end
What's Going On Here?
- We refactor the
createmethod to always start by finding and authenticating the user. Not only does this prevent timing attacks, but it also prevents accidentally leaking email addresses. This is because we were originally checking if a user was confirmed before authenticating them. That means a bad actor could try and sign in with an email address to see if it exists on the system without needing to know the password.
Step 18: Store Session in the Database
We're currently setting the user's ID in the session. Even though that value is encrypted, the encrypted value doesn't change since it's based on the user id which doesn't change. This means that if a bad actor were to get a copy of the session they would have access to a victim's account in perpetuity. One solution is to rotate encrypted and signed cookie configurations. Another option is to configure the Rails session store to use mem_cache_store to store session data.
The solution we will implement is to set a rotating value to identify the user and store that value in the database.
- Generate ActiveSession model.
rails g model active_session user:references
- Update the migration.
class CreateActiveSessions < ActiveRecord::Migration[6.1]
def change
create_table :active_sessions do |t|
t.references :user, null: false, foreign_key: {on_delete: :cascade}
t.timestamps
end
end
end
What's Going On Here?
- We update the
foreign_keyoption fromtrueto{on_delete: :cascade}. The on_delete option will delete anyactive_sessionrecord if its associateduseris deleted from the database.
- Run migration.
rails db:migrate
- Update User model.
# app/models/user.rb
class User < ApplicationRecord
...
has_many :active_sessions, dependent: :destroy
...
end
- Update Authentication Concern
# app/controllers/concerns/authentication.rb
module Authentication
...
def login(user)
reset_session
active_session = user.active_sessions.create!
session[:current_active_session_id] = active_session.id
end
...
def logout
active_session = ActiveSession.find_by(id: session[:current_active_session_id])
reset_session
active_session.destroy! if active_session.present?
end
...
private
def current_user
Current.user = if session[:current_active_session_id].present?
ActiveSession.find_by(id: session[:current_active_session_id]).user
elsif cookies.permanent.encrypted[:remember_token].present?
User.find_by(remember_token: cookies.permanent.encrypted[:remember_token])
end
end
...
end
What's Going On Here?
- We update the
loginmethod by creating a newactive_sessionrecord and then storing it's ID in thesession. Note that we replacedsession[:current_user_id]withsession[:current_active_session_id].- We update the
logoutmethod by first finding theactive_sessionrecord from thesession. After we callreset_sessionwe then delete theactive_sessionrecord if it exists. We need to check if it exists because in a future section we will allow a user to log out all current active sessions.- We update the
current_usermethod by finding theactive_sessionrecord from thesession, and then returning its associateduser. Note that we've replaced all instances ofsession[:current_user_id]withsession[:current_active_session_id].
- Force SSL.
# config/environments/production.rb
Rails.application.configure do
...
config.force_ssl = true
end
What's Going On Here?
- We force SSL in production to prevent session hijacking. Even though the session is encrypted we want to prevent the cookie from being exposed through an insecure network. If it were exposed, a bad actor could sign in as the victim.
Step 19: Capture Request Details for Each New Session
- Add new columns to the active_sessions table.
rails g migration add_request_columns_to_active_sessions user_agent:string ip_address:string
rails db:migrate
- Update login method to capture request details.
# app/controllers/concerns/authentication.rb
module Authentication
...
def login(user)
reset_session
active_session = user.active_sessions.create!(user_agent: request.user_agent, ip_address: request.ip)
session[:current_active_session_id] = active_session.id
end
...
end
What's Going On Here?
- We add columns to the
active_sessionstable to store data about when and where these sessions are being created. We are able to do this by tapping into the request object and returning the ip and user agent. The user agent is simply the browser and device.
- Update Users Controller.
# app/controllers/users_controller.rb
class UsersController < ApplicationController
...
def edit
@user = current_user
@active_sessions = @user.active_sessions.order(created_at: :desc)
end
...
def update
@user = current_user
@active_sessions = @user.active_sessions.order(created_at: :desc)
...
end
end
- Create active session partial.
<!-- app/views/active_sessions/_active_session.html.erb -->
<tr>
<td><%= active_session.user_agent %></td>
<td><%= active_session.ip_address %></td>
<td><%= active_session.created_at %></td>
</tr>
- Update account page.
<!-- app/views/users/edit.html.erb -->
...
<h2>Current Logins</h2>
<% if @active_sessions.any? %>
<table>
<thead>
<tr>
<th>User Agent</th>
<th>IP Address</th>
<th>Signed In At</th>
</tr>
</thead>
<tbody>
<%= render @active_sessions %>
</tbody>
</table>
<% end %>
What's Going On Here?
- We're simply showing any
active_sessionassociated with thecurrent_user. By rendering theuser_agent,ip_address, andcreated_atvalues we're giving thecurrent_userall the information they need to know if there's any suspicious activity happening with their account. For example, if there's anactive_sessionwith a unfamiliar IP address or browser, this could indicate that the user's account has been compromised.- Note that we also instantiate
@active_sessionsin theupdatemethod. This is because theupdatemethod renders theeditmethod during failure cases.
Step 20: Allow User to Sign Out Specific Active Sessions
- Generate the Active Sessions Controller and update routes.
rails g controller active_sessions
# app/controllers/active_sessions_controller.rb
class ActiveSessionsController < ApplicationController
before_action :authenticate_user!
def destroy
@active_session = current_user.active_sessions.find(params[:id])
@active_session.destroy
if current_user
redirect_to account_path, notice: "Session deleted."
else
reset_session
redirect_to root_path, notice: "Signed out."
end
end
def destroy_all
current_user.active_sessions.destroy_all
reset_session
redirect_to root_path, notice: "Signed out."
end
end
# config/routes.rb
Rails.application.routes.draw do
...
resources :active_sessions, only: [:destroy] do
collection do
delete "destroy_all"
end
end
end
What's Going On Here?
- We ensure only users who are logged in can access these endpoints by calling
before_action :authenticate_user!.- The
destroymethod simply looks for anactive_sessionassociated with thecurrent_user. This ensures that a user can only delete sessions associated with their account.
- Once we destroy the
active_sessionwe then redirect back to the account page or to the homepage. This is because a user may not be deleting a session for the device or browser they're currently logged into. Note that we only call reset_session if the user has deleted a session for the device or browser they're currently logged into, as this is the same as logging out.- The
destroy_allmethod is a collection route that will destroy allactive_sessionrecords associated with thecurrent_user. Note that we callreset_sessionbecause we will be logging out thecurrent_userduring this request.
- Update views by adding buttons to destroy sessions.
<!-- app/views/users/edit.html.erb -->
...
<h2>Current Logins</h2>
<% if @active_sessions.any? %>
<%= button_to "Log out of all other sessions", destroy_all_active_sessions_path, method: :delete %>
<table>
<thead>
<tr>
<th>User Agent</th>
<th>IP Address</th>
<th>Signed In At</th>
<th>Sign Out</th>
</tr>
</thead>
<tbody>
<%= render @active_sessions %>
</tbody>
</table>
<% end %>
<!-- app/views/active_sessions/_active_session.html.erb -->
<tr>
<td><%= active_session.user_agent %></td>
<td><%= active_session.ip_address %></td>
<td><%= active_session.created_at %></td>
<td><%= button_to "Sign Out", active_session_path(active_session), method: :delete %></td>
</tr>
- Update Authentication Concern.
# app/controllers/concerns/authentication.rb
module Authentication
...
private
def current_user
Current.user = if session[:current_active_session_id].present?
ActiveSession.find_by(id: session[:current_active_session_id])&.user
elsif cookies.permanent.encrypted[:remember_token].present?
User.find_by(remember_token: cookies.permanent.encrypted[:remember_token])
end
end
...
end
What's Going On Here?
- This is a very subtle change, but we've added a safe navigation operator via the
&.usercall. This is becauseActiveSession.find_by(id: session[:current_active_session_id])can now returnnilsince we're able to delete otheractive_sessionrecords.
Step 21: Refactor Remember Logic
Since we're now associating our sessions with an active_session and not a user, we'll want to remove the remember_token token from the users table and onto the active_sessions.
- Move remember_token column from users to active_sessions table.
rails g migration move_remember_token_from_users_to_active_sessions
# db/migrate/[timestamp]_move_remember_token_from_users_to_active_sessions.rb
class MoveRememberTokenFromUsersToActiveSessions < ActiveRecord::Migration[6.1]
def change
remove_column :users, :remember_token
add_column :active_sessions, :remember_token, :string, null: false
add_index :active_sessions, :remember_token, unique: true
end
end
- Run migration.
rails db:migrate
What's Going On Here?
- We add
null: falseto ensure this column always has a value.- We add a unique index to ensure this column has unique data.
- Update User Model.
class User < ApplicationRecord
...
- has_secure_token :remember_token
...
end
- Update Active Session Model.
# app/models/active_session.rb
class ActiveSession < ApplicationRecord
...
has_secure_token :remember_token
end
What's Going On Here?
- We call has_secure_token on the
remember_token. This ensures that the value for this column will be set when the record is created. This value will be used later to securely identify the user.- Note that we remove this from the
usermodel.
- Refactor the Authentication Concern.
# app/controllers/concerns/authentication.rb
module Authentication
...
def login(user)
reset_session
active_session = user.active_sessions.create!(user_agent: request.user_agent, ip_address: request.ip)
session[:current_active_session_id] = active_session.id
active_session
end
def forget_active_session
cookies.delete :remember_token
end
...
def remember(active_session)
cookies.permanent.encrypted[:remember_token] = active_session.remember_token
end
...
private
def current_user
Current.user = if session[:current_active_session_id].present?
ActiveSession.find_by(id: session[:current_active_session_id])&.user
elsif cookies.permanent.encrypted[:remember_token].present?
ActiveSession.find_by(remember_token: cookies.permanent.encrypted[:remember_token])&.user
end
end
...
end
What's Going On Here?
- The
loginmethod now returns theactive_session. This will be used later when callingSessionsController#create.- The
forgetmethod has been renamed toforget_active_sessionand no longer takes any arguments. This method simply deletes thecookie. We don't need to callactive_session.regenerate_remember_tokensince theactive_sessionwill be deleted, and therefor cannot be referenced again.- The
remembermethod now accepts anactive_sessionand not auser. We do not need to callactive_session.regenerate_remember_tokensince a newactive_sessionrecord will be created each time a user logs in. Note that we now saveactive_session.remember_tokento the cookie.- The
current_usermethod now finds theactive_sessionrecord if theremember_tokenis present and returns the user via the safe navigation operator.
- Refactor the Sessions Controller.
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def create
...
if @user
if @user.unconfirmed?
...
else
...
active_session = login @user
remember(active_session) if params[:user][:remember_me] == "1"
end
else
...
end
end
def destroy
forget_active_session
...
end
end
What's Going On Here?
- Since the
loginmethod now returns anactive_session, we can take that value and pass it toremember.- We replace
forget(current_user)withforget_active_sessionto reflect changes to the method name and structure.
- Refactor Active Sessions Controller
# app/controllers/active_sessions_controller.rb
class ActiveSessionsController < ApplicationController
...
def destroy
...
if current_user
...
else
forget_active_session
...
end
end
def destroy_all
forget_active_session
current_user.active_sessions.destroy_all
...
end
end
Top comments (0)