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 explained in the article about magic links authentication, we can use the Rails SignedGlobalID
class to generate a unique and expiring link.
That class generates app wide URIs that can uniquely identify a model instance so, we just need to generate a signed global ID for the user we want to confirm:
user = User.first
sgid = user.to_sgid
# Returns a string like this
"eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2pvYmxpc3Rlci9Vc2VyLzI5IiwiZXhwIjoiMjAyNS0wMy0wM1QwMTowNzoxMy4yMTNaIiwicHVyIjoiZGVmYXVsdCJ9fQ==--24eaaf3f11518917bf8ad871f17bcd6c2e04f882"
Then, we can retrieve the user by passing that string to the SignedGlobalID
class:
sgid = "eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2pvYmxpc3Rlci9Vc2VyLzI5IiwiZXhwIjoiMjAyNS0wMy0wM1QwMTowNzoxMy4yMTNaIiwicHVyIjoiZGVmYXVsdCJ9fQ==--24eaaf3f11518917bf8ad871f17bcd6c2e04f882"
GlobalID::Locator.locate_signed(sgid)
# returns the User instance
To make the feature more secure, we will add an expiration to the sgid
:
expiring_sgid = User.first.to_sgid(expires_in: 1.hour, for: 'user_confirmation')
# => "eyJfcmFpbHMiOnsiZGF0YSI6ImdpZDovL2pvYmxpc3Rlci9Vc2VyLzI5P2V4cGlyZXNfaW49MzYwMCIsImV4cCI6IjIwMjUtMDItMDNUMDI6MjE6NDYuNzE1WiIsInB1ciI6ImVtYWlsX2NvbmZpcm1hdGlvbiJ9fQ==--75a38d1fa72d877a1086f92f667694e02551df80"
Then, as long as it hasn't surpassed the expiration date, we can retrieve it using:
# Returns the User instance or nil
user = GlobalID::Locator.locate_signed(expiring_sgid, for: 'user_confirmation')
With that in mind, let's setup everything we need in the User
class:
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 confirmed:boolean 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, :confirmed, :boolean, default: true
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
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 self.find_by_sgid(sgid, purpose: nil)
GlobalID::Locator.locate_signed(sgid, for: purpose)
end
def confirm!
return true if confirmed?
update!(confirmed: true, confirmed_at: Time.current)
end
def can_access_app?
confirmed? || (Time.current > confirmation_deadline)
end
def confirmation_deadline
confirmation_sent_at + ACCESS_BEFORE_CONFIRMATION_IN_HOURS
end
def expiring_token(expiration = 2.hours, purpose: 'user_confirmation')
to_sgid(expires_in: expiration, for: purpose).to_s
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
generates an expiring signed global ID that we will use to generate the link used to confirm user's accounts.
We also added the find_by_sgid
class method that we will use to validate the user confirmation from a token present in the URL that we send to the user in the confirmation email.
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 looks 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_sgid(params[:token], purpose: 'user_confirmation')
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
As you can see, 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 = 1.hour
included do
def confirm!
return true if confirmed?
update!(confirmed: true, confirmed_at: Time.current)
end
def can_access_app?
confirmed? || (Time.current < confirmation_deadline)
end
def confirmation_deadline
confirmation_sent_at + ACCESS_BEFORE_CONFIRMATION_IN_HOURS
end
def expiring_token(expiration = 2.hours, purpose: 'user_confirmation')
to_sgid(expires_in: expiration, for: purpose).to_s
end
def send_confirmation_email
transaction do
UsersMailer.account_confirmation(self).deliver_now
update!(confirmation_sent_at: Time.current)
end
end
end
module ClassMethods
def find_by_sgid(sgid, purpose: nil)
GlobalID::Locator.locate_signed(sgid, for: purpose)
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 add it, we need to add some fields to the User
model to keep track of the user confirmation: confirmed
a boolean, 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 a SignedGlobalID through the .to_sgid
method available to all Rails models.
user = User.first
sgid = user.to_sgid(expires_in: 1.hour, for: "email_confirmation").to_s
GlobalID::Locator.locate(sgid, for: "email_confirmation") # => Returns a User instance or nil
So we use that as a token, we pass through the URL sent in the confirmation email and if the user exists we then confirm their account.
Because the link expires, 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!