Email verification with Rails 8 auth: Part 2

by Kim Laplume

Summary

Pick up from last lesson, make Email verification workin the controller

Lesson

Picking up

This is the second part of "How to verify Emails in Rails by yourself", part 1 can be found here.

Email verification link

We want to send out an Email with a special link that is not guessable and tamperproof. Luckily, Rails can help us with this, we can take advantage of a useful ActiveRecord class method that generates tokens for us that meet those criteria. Let's bring it into out EmailChange model:

class EmailChange < ApplicationRecord
  belongs_to :user

  generates_token_for :email_change, expires_in: 24.hours do
    user.email_address
  end
end

I think it's worth it to bring the documentation for the method in, because it describes very well what it does

ActiveRecord::TokenFor::ClassMethods
public method #generates_token_for(purpose, expires_in: nil, &block)

Defines the behavior of tokens generated for a specific purpose. A token can be generated by calling ActiveRecord::TokenFor#generate_token_for on a record. Later, that record can be fetched by calling #find_by_token_for (or #find_by_token_for!) with the same purpose and token.  

Tokens are signed so that they are tamper-proof. Thus they can be exposed to outside world as, for example, password reset tokens.  

By default, tokens do not expire. They can be configured to expire by specifying a duration via the expires_in option. The duration becomes part of the token's signature, so changing the value of expires_in will automatically invalidate previously generated tokens.  

A block may also be specified. When generating a token with ActiveRecord::TokenFor#generate_token_for, the block will be evaluated in the context of the record, and its return value will be embedded in the token as JSON. Later, when fetching the record with #find_by_token_for, the block will be evaluated again in the context of the fetched record. If the two JSON values do not match, the token will be treated as invalid. Note that the value returned by the block should not contain sensitive information because it will be embedded in the token as human-readable plaintext JSON.

Exactly what we need! Whether the token should expire is up to you, I added an example to illustrate, but I personally don't see why it would be required. Let's use the token in a method in our User model

# app/models/user.rb
class User < ApplicationRecord
  # ...
  def initiate_email_verification
    email_change = EmailChange.create(user: self, from: nil, to: email_address)
    token = email_change.generate_token_for :email_change
    NotificationMailer.verify_email(email_change, token).deliver_later
  end
  # ...
end

Where you'd call this method also depends on your application logic, directly after the user registers on the site, after he upgrades his account, could be anytime.
I'll not go into the mailer itself, just place a link to the token somewhere in the template:

You can verify your email within the next 24 hours on
<%= link_to "this email verification page", edit_email_verification_url(@token) %>.

Follow the flow back into the app, let's add a route

# config/routes.rb
# ...
resources :email_verifications, only: [ :create, :edit ], param: :token

The create action could be used to request another verification mail to be send, I'll show the code, but not where the user could initiate this, consider it a nice exercise. The edit action is where we do the verification. A bit unusual compared to standard controller actions, but considering that we can't do POST requests from the Email client, and we might not want to put another screen between the user click and the handling, it will have to do.

class EmailVerificationsController < ApplicationController
  before_action :set_email_change_by_token, only: %i[ edit ]

  def create
    if EmailChange.where(user: Current.user, confirmed_at: nil, created_at: 1.day.ago..).exists?
      redirect_to settings_info_path, alert: "Please wait a little longer before requesting another email verification. If the issue persists, please contact support at support@example.com."
    else
      Current.user.initiate_email_verification
      redirect_to settings_info_path, notice: "Email verification instructions have been sent, please check your inbox for the new address."
    end
  end

  def edit
    return redirect_to root_path, alert: "You are not authorized to confirm this email." if Current.user != @email_change.user
    return redirect_to root_path, alert: "Email has already been confirmed." if @email_change.confirmed_at?

    if Current.user.update email_verified: true
      @email_change.update confirmed_at: Time.zone.now
      redirect_to edit_settings_info_path, notice: "Email has been confirmed."
    else
      redirect_to edit_settings_info_path, alert: "Could not verify email."
    end
  end

  private

  def set_email_change_by_token
    @email_change = EmailChange.find_by_token_for!(:email_change, params[:token])
  rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
    redirect_to new_settings_email_path, alert: "Email change link is invalid or has expired."
  end
end

I think the code reads pretty clearly by itself, the most interesting bit is how to retrieve the EmailChange instance by token: EmailChange.find_by_token_for!(:email_change, params[:token]). Looks so easy, right?

And with this, we have gone through the whole process!
Next up, how to use EmailChange for the other use case, an Email change initiated by the user from the settings for example.