Building an authentication flow usually implies that bots and malicious agents might attack us with fake user sign-ups.
They can be automatically triggered by crawlers and spambots, or manually set off by humans that are trying to exploit our systems.
Having a confirmation flow can mitigate these issues.
In this article, we will to learn how to apply one using the Rails auth generator so we can avoid one of the pitfalls of handling authentication on our own.
Let's start by seeing what we will build
What we will build
We will build a simple application with a dummy home page, a sign in and sign up flow.
The main goal is to avoid fake or malicious sign-ups so, in place of complex user validations, we will add a manual email verification between the sign-up and sign-in process.
Even though, a sufficiently motivated user can produce fake email accounts, the account creation has a cost, which means that generating a fake user in our app is harder and maybe even not worth it.
To comply with this premise, the app will have the following requirements:
- A user can sign up to our application using an email and password. For this purpose, we will use the Rails auth generator.
- After signing up, the user will receive an email with an expiring link that, when clicked, can confirm access to the email account, thus reducing the probability of the user being fake.
- Until confirmed, the user will be able to use the application for up to 1 hour since signing up. After that period, the user will be required to confirm the account before doing anything. If you want to be more strict regarding how you confirm users, you just need to ignore the logic around this confirmation deadline and everything should work correctly.
We will not be focusing on building the views or the app itself, but the result should look something like this:
Application set up
The first step is to create a new Rails application:
$ rails new user_auth_confirmation --css=tailwind
Now, we run the database setup command.
$ bin/rails db:setup db:migrate
Next, we will run the authentication generator:
$ bin/rails generate authentication
This will create both User
and Session
models, which, along with the Authentication
concern and the Current
model, will let us add authentication for our up.
But, remember that the auth generator only adds sign-in and password reset flows. Registration and user confirmation are up to us.
Since we already wrote about the Rails auth sign up flow, we'll just go over what we did in that tutorial so you can replicate it:
- Add an email validation to the
User
model. Remember that thehas_secure_password
already validates for password presence. - Add a
RegistrationsController
withnew
andcreate
actions, which are responsible for rendering the registration form and actually creating a user, respectively. - Add a registration endpoint with the corresponding view displaying a form with
email
,password
andpassword_confirmation
fields. - Validate the user against any possible errors, including password confirmation, and also add the
pwned
gem to check against password presence in data breaches. - If valid, persist the user and redirect to the desired path, else render the
new
action and populate the flash object with the proper errors. ## User confirmation flow Because we verify the user's identity by mapping an email with a password, we can confirm to a certain degree of certainty that the user is real by verifying access to the email account.
To achieve this, we just need to make sure that the user clicks on a unique, and expiring, link that's sent in an email after a user signs up for our application or makes an authentication attempt if the account is not confirmed.
Let's start by talking about the confirmation link:
Confirmation link
There are multiple ways to generate a secure confirmation link.
One of them is to persist a unique and expiring token and make the user confirmation depend on that token. However, for our purposes, we don't need to persist the token.
Like we did in the article about magic links authentication, we could use the Rails SignedGlobalID
class to generate the expiring link but, in this case, we will use Active Record's generates_token_for
method.
This method gives us the ability to generate expiring tokens associated to a model instance.
To generate it, we first pass the :user_confirmation
purpose and an expiration to the method:
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
generates_token_for :user_confirmation, expires_in: 2.hours
# Rest of the code
end
Then, we can use the generate_token_for
instance method to actually generate the token:
user = User.first
token = user.generate_token_for(:user_confirmation)
Then, using the find_by_token_for
we can retrieve the user who's associated with the token:
User.find_by_token_for(:user_confirmation, token) # => Returns the first user
User setup
To be sure that a user is confirmed, we need to persist whether the confirmation occurred.
We can do it by adding a boolean, a confirmation date time, or both, columns to the users
table:
$ bin/rails generate migration add_confirmation_attributes_to_users confirmation_sent_at:datetime confirmed_at:datetime
This will generate a migration file that looks like this:
class AddConfirmationAttributesToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :confirmation_sent_at, :datetime
add_column :users, :confirmed_at, :datetime
end
end
The reason we're adding the confirmation_sent_at
column is that we want to be able to allow the user to access the application for a set amount of time before we restrict access.
Next, we migrate the database to persist the changes:
$ bin/rails db:migrate
Now, we add the confirmation logic we will use later to the User
model:
class User < ApplicationRecord
ACCESS_BEFORE_CONFIRMATION_IN_HOURS = 1.hour
has_secure_password
has_many :sessions, dependent: :destroy
generates_token_for :user_confirmation, expires_in: ACCESS_BEFORE_CONFIRMATION_IN_HOURS
validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, not_pwned: true
normalizes :email_address, with: ->(e) { e.strip.downcase }
def confirm!
return true if confirmed?
update!(confirmed_at: Time.current)
end
def can_access_app?
confirmed? || (Time.current < confirmation_deadline)
end
def confirmed?
confirmed_at.present?
end
def confirmation_deadline
confirmation_sent_at + ACCESS_BEFORE_CONFIRMATION_IN_HOURS
end
def expiring_token
generate_token_for(:user_confirmation)
end
end
We added four instance methods: confirm!
persists the confirmation to the db, can_access_app?
returns a boolean whether the user should be able to test the application before confirmation, and the confirmation_deadline
gives us the exact moment where the application should reject the user if no confirmation took place.
Lastly, the expiring_token
returns the token for the :user_confirmation
purpose.
After adding everything to the User
model, we're ready to add the controller code:
Routes and controllers
We will handle the confirmation logic in the ConfirmationsController
so we add the required routes:
# config/routes.rb
resource :confirmations, only: [:show, :create]
We will use the create
method to send the confirmation email to the user and the show
action to actually confirm the user.
The controller should look something like this:
class ConfirmationsController < ApplicationController
def create
Current.user.send_confirmation_email
redirect_to root_path, notice: "Confirmation email resent"
end
def show
user = User.find_by_token_for(:user_confirmation, params[:token])
if user.present? && user.confirm!
redirect_to root_path, notice: "Your account has been confirmed. Enjoy the app!"
else
redirect_to root_path, alert: "Invalid or expired confirmation link"
end
end
end
The create
method is responsible for resending the confirmation email whenever the users requests for it, and the show
action is responsible for actually confirming the user after clicking on the email link.
Now, we just need a way to confirm a user has access to our application after signing up and before confirming the account. Remember that, per the requirements, we have a grace period between the account creation and confirmation.
To make this work, we have to execute some logic in the ApplicationController
:
class ApplicationController < ActionController::Base
include Authentication
before_action :verify_user_access
allow_browser versions: :modern
private
def verify_user_access
return nil if Current.user.nil?
if !Current.user.can_access_app?
terminate_session
redirect_to new_session_path, alert: "You need to confirm your account before using the app."
end
end
end
Confirmation email
We run a generator to create a UsersMailer
that will handle the confirmation link logic:
$ bin/rails generate mailer Users account_confirmation
Running this command will create the UsersMailer
class with an account_confirmation
method and the corresponding HTML and plain-text views.
We then modify the mailer by making sure we send it to a given user:
class UsersMailer < ApplicationMailer
def account_confirmation(user)
@user = user
mail to: user.email_address, subject: "Welcome to Confirmable! Please confirm your account."
end
end
Now, we add the confirmation link to the view:
<h1>Welcome to Confirmable, <%= @user.email_address %></h1>
<p>
Please, confirm your account by clicking the link below:
<%= link_to "Confirm my account", confirmations_url(token: @user.expiring_token) %>
</p>
As the token is generated on the fly, the user will have one hour to click on the link. When clicked, the link will trigger the show
action for the confirmations controller and, if the token is valid, the user will be then confirmed and redirected to the root path.
Extracting logic with a concern
We can reduce the visual pollution in the User
model by extracting the confirmation logic to a confirmable
concern.
In this case, and unless we foresee another model with auth capacities in the future, the code will probably not be reused, but it can help maintain the User
class cleaner.
We start by adding confirmable.rb
to app/concerns
and extracting the instance methods by declaring them inside the block passed to the included
method and the class methods inside a module conventionally called ClassMethods
:
# app/concerns/confirmable.rb
module Confirmable
extend ActiveSupport::Concern
ACCESS_BEFORE_CONFIRMATION_IN_HOURS = 2.hours
included do
generates_token_for :user_confirmation, expires_in: ACCESS_BEFORE_CONFIRMATION_IN_HOURS
end
def confirm!
return true if confirmed?
update!(confirmed_at: Time.current)
end
def can_access_app?
confirmed? || (Time.current < confirmation_deadline)
end
def confirmed?
confirmed_at.present?
end
def confirmation_deadline
confirmation_sent_at + ACCESS_BEFORE_CONFIRMATION_IN_HOURS
end
def expiring_token
generate_token_for(:user_confirmation)
end
def send_confirmation_email
transaction do
UsersMailer.account_confirmation(self).deliver_now
update!(confirmation_sent_at: Time.current)
end
end
end
Next, we add the concern to the User
class and remove the duplicate code:
class User < ApplicationRecord
include Confirmable
## The rest of the code ##
end
And everything should be working like before, but the User
class is now cleaner and more readable.
TL;DR
Adding user confirmation flow can help us reduce spam or malicious user creation in our application.
To achieve this, we need to add some fields to the User
model to keep track of the user confirmation: confirmation_sent_at
which is used to track the moment the user receive the confirmation email and the confirmed_at
date time field in case we need to allow access to unconfirmed users for a period of time.
To uniquely identify if the user has access to an email account, we use Active Record's generates_token_for
and find_by_token_for
methods, which allow us to generate an expiring token that's associated with a user
class User < ApplicationRecord
## Auth code
generates_token_for :user_confirmation, expires_in: 2.hours
end
Now, if we call the generate_token_for
on a User instance, we get an expiring token which we can use to retrieve the user instance:
user = User.first
token = user.generate_token_for(:user_confirmation) # The purpose has to match the one we previously declared
User.find_by_token_for(:user_confirmation, token) # => Returns the user as long as the token is not expired
Then we pass the token through the URL sent in the confirmation email, and if the user exists—and the token is not expired—we confirm the user's account.
We also provide a way for the user to receive the email again, which will always have a link with an expiration of one hour.
I hope this article was helpful or aided you including this feature in your applications.
Have a good one and happy coding!