Email verification with Rails 8 auth: Part 2
Summary
Pick up from last lesson, make Email verification workin the controllerLesson
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.