User confirmation with the Rails auth generator

By Exequiel Rozas

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 the has_secure_password already validates for password presence.
  • Add a RegistrationsController with new and create 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 and password_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

If you're following REST to the letter, you might notice that the show action, which responds to a GET request, is performing a mutation which is a no-go when applying REST to the letter. If you want to avoid this, you can create separate controllers to handle the actual confirmation from the email sending.

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!

Build your next rails app 10x faster with Avo

Avo dashboard showcasing data visualizations through area charts, scatterplot, bar chart, pie charts, custom cards, and others.

Find out how Avo can help you build admin experiences with Rails faster, easier and better.