Adding Backup Codes Two-Factor Authentication to Ruby on Rails app
This is the second part of a post-series on two-factor authentication in Ruby on Rails. This article builds upon what was created in the previous article.
In this tutorial, I’m using dry-rb monads and result matcher, so if you want this code to work without additional customization, you’ll need to install dry-monads and dry-matcher
Why backup codes?
We should provide users with a secondary way of two-factor authentication in case they lose the primary device or it malfunctions. Backup codes are a good and easy solution to this problem.
Before writing any code, we should think about how the backup code generation flow should look. Do we want the user to reconfirm their password? Do we want the user to provide the OTP code first?
This tutorial will implement this flow
- User eneters valid OTP code
- Codes are generated
As usual, you should customize this flow according to your needs.
Let’s start building
We’ll base our implementation on the two_factor_backupable
Devise module provided by the devise-two-factor gem.
First, we have to add the Devise module to our model. In this step, we can also configure the length and number of backup codes.
# app/models/user.rb
# ...
devise :two_factor_backupable,
otp_backup_code_length: 10, otp_number_of_backup_codes: 10
# ...
Then we have to change the Devise configuration in config/initializers/devise.rb
.
# config/initializers/devise.rb
# ...
Devise.setup do |config|
# ...
config.warden do |manager|
# replace user with your model
manager.default_strategies(scope: :user).unshift :two_factor_backupable
manager.default_strategies(scope: :user).unshift :two_factor_authenticatable
end
# ...
end
And the only thing that needs to be added is migration.
# rails g migration AddDeviseTwoFactorBackupableToUsers
# this migration is PostgreSQL specific
# replace users with your model
class AddDeviseTwoFactorBackupableToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :otp_backup_codes, :string, array: true
end
end
Now we can try our implementation.
rails c
Loading development environment (Rails 7.1.3.4)
irb(main):001> u = User.first
User Load (0.9ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1 [["LIMIT", 1]]
=> #<User id: 1, first_name: "Admin", last_name: "Adminowski", created_at: "2024-09-13 14:49:47.354067000 +0200", updated_at: "2024-09-16 19:28:05.270613000 +0200", email: "admin@example.com", roles: ["admin"], phone_numb...
irb(main):002> u.generate_otp_backup_codes!
=> ["75ffbf8b36", "4319413b2d", "d21e0b8be6", "68a12d025d", "d3acd1b359", "3698966edb", "ad5f850307", "18d80e891d", "7ff35b1162", "c1908cfb8f"]
irb(main):003> u.save!
TRANSACTION (0.2ms) BEGIN
User Update (2.4ms) UPDATE "users" SET "updated_at" = $1, "otp_backup_codes" = $2 WHERE "users"."id" = $3 [["updated_at", "2024-09-17 07:44:51.011240"], ["otp_backup_codes", "{$2a$13$ZK6TqBopl7Hu0pyr32mGb.o70sXLXuDcItNMKR7UcjTKjSuE.EHZq,$2a$13$XlgLxXX3OCO.PytbE4uqc.KzP2s1lGlQSgge.5ntl9BIu2MqP.Ry2,$2a$13$r9WFYt4NEXd5.x5decLh3eB2Int5j8Q8AMIb4ZK5z35akKVgrTgMa,$2a$13$wdT.liV1.YvkGZ5HmHyjYOyRDnlRoYB85pShEv46mgyEcpFVvbnw.,$2a$13$dzcbyywPXPrCqHM/oPVVcuFNkeeFi2qeqH0efxLLxIMzorMj1VS4i,$2a$13$2l78aKuuBbgVeHl86sFnxOzr5dBHu.v7b9bi/.H5BNEWSgorRNhdC,$2a$13$kMR4PAErkjPXNk8fYpCIT.pmvGA/TbdQucBThMHVaQRNxQISEuMXG,$2a$13$xxF5jCM6BmZlRrvvMh.n2.Udk43PxjMV5dCTofqty9woznSCzJWxq,$2a$13$jFgjCXtzFSKX3c7vjOviqek9k8OPbRtNuizUZo6xOzDIVJN6QaYHK,$2a$13$1.LQqZA2PPKQW7F9bX3r5eqHL7Qrp041dAvxk3IBXGrrkVdjmGn8G}"], ["id", 1]]
TRANSACTION (6.5ms) COMMIT
=> true
Codes will be visible only when we call #generate_otp_backup_codes!
Later, they will be stored in the database as hashes, so there is no way to display them again. We can consume a code using the #invalidate_otp_backup_code!
method. Remember to save the user after calling it to remove the used backup code from the database.
irb(main):004> u.invalidate_otp_backup_code! "75ffbf8b36"
=> true
irb(main):005> u.otp_backup_codes.count
=> 9
irb(main):006> u.save!
TRANSACTION (0.2ms) BEGIN
User Update (0.4ms) UPDATE "users" SET "updated_at" = $1, "otp_backup_codes" = $2 WHERE "users"."id" = $3 [["updated_at", "2024-09-17 07:56:14.787430"], ["otp_backup_codes", "{$2a$13$XlgLxXX3OCO.PytbE4uqc.KzP2s1lGlQSgge.5ntl9BIu2MqP.Ry2,$2a$13$r9WFYt4NEXd5.x5decLh3eB2Int5j8Q8AMIb4ZK5z35akKVgrTgMa,$2a$13$wdT.liV1.YvkGZ5HmHyjYOyRDnlRoYB85pShEv46mgyEcpFVvbnw.,$2a$13$dzcbyywPXPrCqHM/oPVVcuFNkeeFi2qeqH0efxLLxIMzorMj1VS4i,$2a$13$2l78aKuuBbgVeHl86sFnxOzr5dBHu.v7b9bi/.H5BNEWSgorRNhdC,$2a$13$kMR4PAErkjPXNk8fYpCIT.pmvGA/TbdQucBThMHVaQRNxQISEuMXG,$2a$13$xxF5jCM6BmZlRrvvMh.n2.Udk43PxjMV5dCTofqty9woznSCzJWxq,$2a$13$jFgjCXtzFSKX3c7vjOviqek9k8OPbRtNuizUZo6xOzDIVJN6QaYHK,$2a$13$1.LQqZA2PPKQW7F9bX3r5eqHL7Qrp041dAvxk3IBXGrrkVdjmGn8G}"], ["id", 1]]
TRANSACTION (1.9ms) COMMIT
=> true
New option in account settings
We can begin by adding new methods to the SecondFactorController
.
# app/controllers/users/second_factor_controller.rb
# ...
class Users::SecondFactorController < ApplicationController
# ...
def new_codes
end
def create_codes
end
# ...
end
And updating config/routes.rb
.
# config/routes.rb
# ...
get "auth/edit/2fa/codes" => "users/second_factor#new_codes", :as => :new_user_backup_codes
post "auth/edit/2fa/codes" => "users/second_factor#create_codes", :as => :create_user_backup_codes
# ...
Now let’s add a new entry in the account settings.
<div class="mt-5">
<div class="rounded-md bg-gray-100 px-6 py-5 flex items-start justify-between">
<div class="flex items-start">
<svg class="h-10 w-10 text-red-700" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80ZM364-182l48-110q-42-15-72.5-46.5T292-412l-110 46q23 64 71 112t111 72Zm-72-366q17-42 47.5-73.5T412-668l-46-110q-64 24-112 72t-72 112l110 46Zm188 188q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35Zm116 178q63-24 110.5-71.5T778-364l-110-48q-15 42-46 72.5T550-292l46 110Zm72-368 110-46q-24-63-71.5-110.5T596-778l-46 112q41 15 71 45.5t47 70.5Z" /></svg>
<div class="ml-4 mt-0">
<div class="text-sm font-medium text-gray-900"><%= t("second_factor.backup_codes") %></div>
<div class="mt-1 text-sm text-gray-600 flex items-center">
<% if resource.otp_required_for_login %>
<% if resource.otp_backup_codes.nil? %>
<div><%= t("second_factor.not_generated_yet") %></div>
<% else %>
<div><%= t("second_factor.count1_10_left", count1: resource.otp_backup_codes.count) %></div>
<% end %>
<% else %>
<div><%= t("second_factor.disabled_fem") %></div>
<% end %>
</div>
</div>
</div>
<div class="ml-6 mt-0 flex-shrink-0">
<% if resource.otp_required_for_login %>
<%= link_to t("second_factor.generate"),
new_user_backup_codes_path,
data: {turbo_frame: "modal", turbo_prefetch: false},
class: "inline-flex items-center rounded-md bg-white px-4 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
<% end %>
</div>
</div>
</div>
Here is the result.
Backup codes when user already has generated codes
Backup codes when user hasn’t generated codes yet
Generating codes
OTP Confirmation
As outlined earlier in our flow, we will begin with the OTP confirmation form.
First we’ll create a form object.
# this might already exist in your application if you've followed steps from previous post
class OtpConfirmationForm
include ActiveModel::Model
attr_accessor :otp_code
end
And then we’ll create form.
<!-- devise/registrations/second_factor/new_codes -->
<%= form_with(model: otp_confirmation_form, class: "mt-10", url: create_user_backup_codes_path) do |form| %>
<% if otp_confirmation_form.errors.any? %>
<div id="error_explanation" class="relative z-1 bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
<ul>
<% otp_confirmation_form.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="my-2">
<%= form.label :otp_code, t("second_factor.enter_code"), class: "block text-base font-medium text-gray-900" %>
<div class="flex mt-2 gap-3 align-center">
<%= form.text_field :otp_code, id: "otp_code", class: "block shadow rounded-md border border-gray-200 outline-none w-full", autocomplete: "one-time-code", placeholder: t("second_factor.one_time_or_backup_code") %>
<%= form.submit t("second_factor.generate"), class: "rounded-md cursor-pointer bg-amber group-aria-[busy=true]/frame:bg-gray-300 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-amber-dark focus-visible:outline-offset-2" %>
</div>
</div>
<% end %>
And update the controller.
# app/controllers/users/second_factor_controller.rb
# ...
def new_codes
render "devise/registrations/second_factor/new_codes"
end
# ...
Rendered form
Codes generation
Let’s start by creating a view for displaying codes.
<%= turbo_frame_tag "modal" do %>
<%= tag.div(
data: {controller: "turbo-modal"},
class: "relative z-10",
role: "dialog",
aria: {labbeledby: "modal-title", modal: true}
) do %>
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div class="transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
<div class="flex flex-col items-center space-y-4">
<%= button_tag t("application.close"), data: {action: "turbo-modal#hideModal"}, type: "button", class: "self-end rounded-md bg-amber px-3 py-2 text-sm font-semibold text-white shadow-sm hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-amber-dark focus-visible:outline-offset-2" %>
<h1 class="font-medium text-lg"><%= t("second_factor.backup_codes") %></h1>
<div class="flex justify-center items-center w-full mt-4">
<div class="grid grid-cols-2 gap-4">
<% codes.each_slice((codes.size / 2.0).round) do |column| %>
<div>
<% column.each do |code| %>
<p><%= code %></p>
<% end %>
</div>
<% end %>
</div>
</div>
<div class="rounded-md bg-yellow-50 p-4 mt-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800"><%= t("second_factor.warning") %></h3>
<div class="mt-2 text-sm text-yellow-700">
<p><%= t("second_factor.save_codes_or_print") %></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<% end %>
<% end %>
Then we can create some logic.
# app/services/second_factor/generate_backup_codes.rb
#
# this service uses dry-rb monads
# however it is not necessary - just my preference
# feel free to return normal values and raise exceptions
#
# this service returns custom errors
# create them if you want to use them
module SecondFactor
class GenerateBackupCodes
include Dry::Monads[:result]
include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)
# @param [User] user
# @param [String] otp_code
def call(user:, otp_code:)
User.transaction do
unless user.two_factor_app_enabled
return Failure(YouNeedAtLeastOneTwoFactorMethodEnabled.new)
end
unless user.validate_and_consume_otp!(otp_code) || user.invalidate_otp_backup_code!(otp_code)
return Failure(InvalidOtpCodeOrBackupCode.new)
end
codes = user.generate_otp_backup_codes!
user.save!
Success(codes)
end
end
end
end
And use it in the controller.
# app/controllers/users/second_factor_controller.rb
# ...
def create_codes
otp_confirmation_form = OtpConfirmationForm.new(otp_confirmation_params)
SecondFactor::GenerateBackupCodes.new.call(
user: current_user,
otp_code: otp_confirmation_form.otp_code
) do |m|
m.failure do |err|
form = OtpConfirmationForm.new
form.errors.add(:base, err.message)
render "devise/registrations/second_factor/new_codes", locals: {otp_confirmation_form: form, sms_sent: current_user.two_factor_sms_enabled}, status: :unprocessable_entity
end
m.success do |codes|
render "devise/registrations/second_factor/show_codes", locals: {codes: codes}
end
end
end
# ...
def otp_confirmation_params # if you've followed previous tutorial you probably already have this method in the controller
params.require(:otp_confirmation_form).permit(:otp_code)
end
# ...
Show codes view in action
Adjustments to other services
Since we’ve just added a new two-factor authentication method, we’ll have to adjust a few services from the previous tutorial to handle using backup codes instead of OTP correctly.
# app/services/second_factor/disable_app.rb
#
# this service uses dry-rb monads
# however it is not necessary - just my preference
# feel free to return normal values and raise exceptions
#
# this service returns custom errors
# create them if you want to use them
module SecondFactor
class DisableApp
include Dry::Monads[:result]
include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)
# @param [User] user
# @param [String] otp_code
def call(user:, otp_code:)
User.transaction do
# we have to add option to disable OTP using backup codes
unless user.validate_and_consume_otp!(otp_code) || user.invalidate_otp_backup_code!(otp_code)
return Failure(InvalidOtpCodeOrBackupCode.new)
end
user.two_factor_app_enabled = false
user.two_factor_app_enabled_at = nil
user.otp_secret = User.generate_otp_secret
user.otp_required_for_login = false
# also we can consider removing backup codes when disabling 2FA
user.otp_backup_codes = nil
user.save!
Success(user)
end
end
end
end
Conclusion
In this tutorial, we have extended our two-factor authentication implementation by adding backup codes using the devise-two-factor :two_factor_backupable Devise module.
With these changes, your application increases security with a robust two-factor authentication setup and ensures users are never locked out of their accounts.
Make sure to customize and extend the provided code to best suit your unique requirements - your threat model and security needs might differ. If you ever get stuck, be sure to write a comment.