Setting Up TOTP Two-Factor Authentication in Ruby on Rails Application
Two-factor authentication should be a standard feature in today’s world — especially if your app is processing sensitive data. This guide will walk you through implementing it into your Rails application with the help of Devise and a few other helpful gems.
Prerequisites
This guide assumes that you are already familiar with the basic concepts of Ruby on Rails and Devise and have already set up an app with Devise.
— Existing Ruby on Rails project
— Installed and configured Devise
— Tailwind CSS in the assets pipeline (only for the view part)
— Turbo enabled
I’ll be 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
Let’s start building — Basic configuration
We’ll base our implementation on a great gem called devise-two-factor providing basics of two-factor authentication.
Setting up active record encryption
Devise-two-factor uses ActiveRecord encrypted attributes to add an extra layer of security to the OTP secret. If you have already set up Active Record encryption, skip this step.
If you haven’t, you must generate a key set and configure your application to use it with Rails encrypted credentials or from another source, such as environment variables.
# Generates keyset and outputs it to stdout
rails db:encryption:init
Then you can load the key set using Rails credentials.
# Copy the generated key set into your encrypted credentials file
rails credentials:edit
Or set up environment variables if that is more convenient for your project.
# add those lines to the config/application.rb
config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
config.active_record.encryption.deterministic_key = ENV['ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY']
config.active_record.encryption.key_derivation_salt = ENV['ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT']
Devise-two-factor setup
Add devise-two-factor gem to your project.
bundle add devise-two-factor
And then run the devise-two-factor generator (usually your MODEL will be User).
rails generate devise_two_factor MODEL
This generator will create a migration which adds a few fields to your model.
add_column :users, :otp_secret, :string
add_column :users, :consumed_timestep, :integer
add_column :users, :otp_required_for_login, :boolean
Edit the modelapp/models/MODEL.rb
— Addtwo_factor_authenticatable
to the model Devise config
— Remove database_authenticatable
from the model Devise config
Check if those changes were successful — this generator may fail if your model is complicated.
Keep in mind that loading both :database_authenticatable
and :two_factor_authenticatable
in a model is a security issue and should be avoided.
After running the generator, we have to run database migrations.
rails db:migrate
Devise configuration
The next step is configuring Devise params sanitizer to permit otp_attempt
in sign_in params.
# app/controllers/application_controller.rb
before_action :configure_permitted_parameters, if: :devise_controller?
# ...
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
end
If you are using recoverable
Devise strategy, make sure to disable automatic login after the password reset. Leaving this behavior enabled will result in a security flaw.
# config/initializers/devise.rb
Devise.setup do |config|
# ...
config.sign_in_after_reset_password = false
# ...
end
The last configuration is the allowed OTP drift, which defines the maximum allowed difference between the client and server clock.
# config/initializers/devise.rb
Devise.otp_allowed_drift = 120 # in seconds
Let’s test it before continuing to the next steps
First, we have to add the :otp_attempt
field to the login page.
You can generate custom Devise views if you don’t have them in your app yet.
# skip this step if your app already has custom devise views
rails generate devise:views
# example attempt field
# app/views/devise/sessions/new.html.erb
<%= f.label :otp_attempt, "Two-factor authentication", class: "block text-sm font-medium mb-2" %>
<%= f.text_field :otp_attempt, id: :otp_attempt,
autocomplete: "one-time-code",
placeholder: "one-time code",
class: "appearance-none border border-gray-300 rounded-md w-full py-2 px-3 text-sm text-gray-800 focus:border-amber-dark focus:ring-0" %>
Then, let’s enable two-factor authentication for a user using the rails console.
$ rails c
Loading development environment (Rails 7.1.3.4)
irb(main):001> u = User.first
User Load (1.0ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1 [["LIMIT", 1]]
=> #<User id: 16, first_name: "Admin", last_name: "Adminowski", created_at: "2024-09-06 12:16:01.207888000 +0200", updated_at: "2024-09-09 09:41:09.813245000 +0200", email: "admin@example.com", roles: ["admin"], phone_num...
irb(main):002> u.otp_secret = User.generate_otp_secret
=> "XO4344F564O5S4K2EG2EDW52"
irb(main):003> u.otp_required_for_login = true
=> true
irb(main):004> u.save!
TRANSACTION (0.2ms) BEGIN
User Update (2.5ms) UPDATE "users" SET "updated_at" = $1, "otp_secret" = $2, "otp_required_for_login" = $3 WHERE "users"."id" = $4 [["updated_at", "2024-09-09 12:10:47.363689"], ["otp_secret", "{\"p\":\"X6Obax6iKfjSLcu9cVVQ0Yp58blDmdhr\",\"h\":{\"iv\":\"H5AzwkKZGK2IYMgL\",\"at\":\"Ee/hOs9DkaVJyIQfllCONg==\",\"e\":\"QVNDSUktOEJJVA==\"}}"], ["otp_required_for_login", true], ["id", 16]]
TRANSACTION (2.2ms) COMMIT
=> true
Now, start the app and go to the login page. You can get the OTP code by calling #current_otp
on User instance.
irb(main):005> u.current_otp
=> "063980"
If everything is set up correctly, you will be able to log in using your OTP, but you won’t be able to log in without it.
Additional model fields
We’ll add a few more fields to our model to better track changes made to an account.
# rails g migration AddAdditionalTwoFactorParams
class AddAdditionalTwoFactorParams < ActiveRecord::Migration[7.1]
def change
add_column :users, :two_factor_app_enabled, :boolean, default: false, null: false
add_column :users, :two_factor_app_enabled_at, :datetime
end
end
Thanks to this, we’ll know when the user has enabled two-factor authentication and track what two-factor method they use (in case we want to add other methods like SMS and email in the future).
Account Settings
Now, we’ll add an option to turn two-factor authentication on and off in the account settings.
New options in account settings
First, let’s create a new part of the account settings page for our two-factor settings. Most commonly, the account settings view is located in app/views/devise/registrations/edit.html.erb
file; however, its location may differ depending on your custom Devise configuration.
<!-- ... -->
<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"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512"
xml:space="preserve">
<path fill="#1A73E8" d="M440,255.99997v0.00006C440,273.12085,426.12085,287,409.00003,287H302l-46-93.01001l49.6507-85.9951
c8.56021-14.82629,27.51834-19.9065,42.34518-11.34724l0.00586,0.0034c14.82776,8.55979,19.90875,27.51928,11.34857,42.34682
L309.70001,225h99.30002C426.12085,225,440,238.87917,440,255.99997z" />
<path fill="#EA4335" d="M348.00174,415.34897l-0.00586,0.00339c-14.82684,8.55927-33.78497,3.47903-42.34518-11.34723L256,318.01001
l-49.65065,85.99509c-8.5602,14.82629-27.51834,19.90652-42.34517,11.34729l-0.00591-0.00342
c-14.82777-8.55978-19.90875-27.51929-11.34859-42.34683L202.29999,287L256,285l53.70001,2l49.6503,86.00214
C367.91049,387.82968,362.8295,406.78918,348.00174,415.34897z" />
<path fill="#FBBC04" d="M256,193.98999L242,232l-39.70001-7l-49.6503-86.00212
c-8.56017-14.82755-3.47919-33.78705,11.34859-42.34684l0.00591-0.00341c14.82683-8.55925,33.78497-3.47903,42.34517,11.34726
L256,193.98999z" />
<path fill="#34A853" d="M248,225l-36,62H102.99997C85.87916,287,72,273.12085,72,256.00003v-0.00006
C72,238.87917,85.87916,225,102.99997,225H248z" />
<polygon fill="#185DB7" points="309.70001,287 202.29999,287 256,193.98999 " />
</svg>
<div class="ml-4 mt-0">
<div class="text-sm font-medium text-gray-900"><%= t("second_factor.authenticator_app") %></div>
<div class="mt-1 text-sm text-gray-600 flex items-center">
<% if current_user.two_factor_app_enabled %>
<div><%= t("second_factor.enabled") %> <%= current_user.two_factor_app_enabled_at.strftime("%Y.%m.%d") %> <span class="hidden sm:inline"><%= current_user.two_factor_app_enabled_at.strftime("%H:%M") %></span></div>
<% else %>
<div><%= t("second_factor.disabled") %></div>
<% end %>
</div>
</div>
</div>
<div class="ml-6 mt-0 flex-shrink-0">
<% if current_user.two_factor_app_enabled %>
<%= link_to t("second_factor.disable"),
"#destroy_two_factor_app_path",
data: {turbo_frame: "modal"},
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" %>
<% else %>
<%= link_to t("second_factor.enable"),
"#init_two_factor_app_path",
data: {turbo_frame: "modal"},
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>
<!-- ... -->
This example uses Tailwind for styling; for now, links are just placeholders; we’ll replace them with proper paths after creating the controller.
Authenticator App settings when it’s disabled
Authenticator App settings when it’s enabled
Skeleton controller
Let’s create a skeleton controller with empty methods for now. As this tutorial progresses, we will add more functionality to it.
# app/controllers/users/second_factor_controller.rb
class Users::SecondFactorController < ApplicationController
before_action :authenticate_user!
layout "devise/registrations"
def initiate_new_app
end
def new_app
end
def create_app
end
def new_destroy_app
end
def destroy_app
end
end
Next, add this controller and its methods to routes in whatever way suits you best.
# config/routes.rb
# this is example on how you can define those routes
get "auth/edit/2fa/app/init" => "users/second_factor#initiate_new_app", :as => :init_new_user_two_factor_app
post "auth/edit/2fa/app/new" => "users/second_factor#new_app", :as => :new_user_two_factor_app
post "auth/edit/2fa/app" => "users/second_factor#create_app", :as => :create_user_two_factor_app
get "auth/edit/2fa/app/destroy" => "users/second_factor#new_destroy_app", :as => :new_destroy_user_two_factor_app
post "auth/edit/2fa/app/destroy" => "users/second_factor#destroy_app", :as => :destroy_user_two_factor_app
Now is a good time to update paths in our account settings view.
<% if current_user.two_factor_app_enabled %>
<%= link_to t("second_factor.disable"),
new_destroy_user_two_factor_app, # <-- changed
data: {turbo_frame: "modal"},
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" %>
<% else %>
<%= link_to t("second_factor.enable"),
init_new_user_two_factor_app, # <-- changed
data: {turbo_frame: "modal"},
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 %>
Enabling two-factor authentication app
Let’s consider what a two-factor enabling flow should look like. Below, you’ll find an example flow from one of Lunar Logic apps
- User clicks on the Enable button
- User confirms their password
- User scans QR code or copies secret
- User enters generated one time code
- Two-factor authentication is enabled
Your requirements might differ, so customize this flow according to your needs (e.g. sending a confirmation email, confirming it with a code sent via SMS, etc.).
Password reconfirmation view
We’ll begin by creating a form object for our password reconfirmation view.
class PasswordConfirmationForm
include ActiveModel::Model
attr_accessor :password
end
Now we’ll create a new view that will be used to reconfirm user credentials.
<%= form_with(model: password_confirmation_form, class: "mt-10 ", url: new_user_two_factor_app_path) do |form| %>
<% if password_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>
<% password_confirmation_form.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="my-2">
<%= form.label :password, t("second_factor.enter_password"), class: "block text-base font-medium text-gray-900" %>
<div class="flex gap-3 mt-2">
<%= form.password_field :password, id: "password", class: "block shadow rounded-md border border-gray-200 outline-none w-full", autocomplete: "current-password", placeholder: t("second_factor.password") %>
<%= form.submit t("second_factor.confirm"), 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 SecondFactorController
.
# app/controllers/users/second_factor_controller
# ...
def initiate_new_app
render "devise/registrations/second_factor/initiate_new_app", locals: {password_confirmation_form: PasswordConfirmationForm.new}
end
# ...
In case of a real app, the form is rendered in a modal using turbo
TOTP initialization
Before enabling two-factor authentication, we must display the OTP secret to the user. We can do that by displaying it as plain text or rendering a provisioning QR code. Let’s create a view dedicated to this purpose.
First, we will need a form object for this view.
class TwoFactorAppEnablementForm
include ActiveModel::Model
attr_accessor :password, :otp_code
end
Then we can create a view.
<%= turbo_frame_tag "modal" do %> <!-- this will be rendered in a modal -->
<%= 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>
<%= form_with(model: two_factor_app_enablement_form, class: "fixed inset-0 z-10 w-screen overflow-y-auto", url: create_user_two_factor_app_path) do |form| %>
<%= form.hidden_field :password %>
<div class="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div class="relative 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="">
<h1 class="font-medium"><%= t("second_factor.authenticator_app") %></h1>
<div class="flex justify-center items-center w-full flex-col">
<div class="flex flex-col mt-4 space-y-4">
<div class="">
<% if two_factor_app_enablement_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>
<% two_factor_app_enablement_form.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<h3 class="text-base font-semibold leading-6 text-gray-900"><%= t("second_factor.1_download_the_authenticator_app") %></h3>
<div class="flex justify-around items-center w-full flex-col space-y-6 mt-3">
<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" target="_blank">
<!-- you can find this image in google partner marketing hub -->
<%= image_tag("get_it_on_google_play_en.png", class: "w-[9rem]") %>
</a>
<a href="https://apps.apple.com/pl/app/google-authenticator/id388497605" target="_blank">
<!-- you can find this image in apple developer marketing materials -->
<%= image_tag("get_it_on_app_store.svg", class: "w-[9rem]") %>
</a>
</div>
</div>
<h3 class="text-base font-semibold leading-6 text-gray-900"><%= t("second_factor.2_scan_or_enter_the_code") %></h3>
<div class="flex justify-center items-center w-full flex-col space-y-3 mt-3">
<div class="text-white w-32">
<%= raw(qr_code) %> <!-- see controller file below for qr code generation example -->
</div>
<div class="font-medium">
<%= otp_secret %>
</div>
</div>
<h3 class="text-base font-semibold leading-6 text-gray-900 mt-5"><%= t("second_factor.3_enter_the_generated_code") %></h3>
</div>
<%= form.text_field :otp_code, id: "password", class: "my-3 block shadow rounded-md border border-gray-200 outline-none px-3 py-2 w-full", autocomplete: "one-time-code", placeholder: t("second_factor.one_time_code_placeholder") %>
</div>
<%= form.submit t("second_factor.enable"), class: "rounded-md 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>
<%= button_tag t("application.close"), data: {action: "turbo-modal#hideModal"}, type: "button", class: "absolute top-4 right-4 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" %>
</div>
</div>
<% end %>
<% end %>
<% end %>
Then, we will create a TOTP initialization service. This service will check if the user provided the correct password and generate a new OTP secret — we will need it to create a QR code and display it to the user.
# app/services/second_factor/initialize_app_provisioning.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 InitializeAppProvisioning
include Dry::Monads[:result] # <-- We are using dry-rb modnads
include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)
# @param [User] user
# @param [String] password
def call(user:, password:)
unless user.valid_password?(password)
return Failure(InvalidPassword.new) # <-- instead you could raise
end
if user.two_factor_app_enabled
return Failure(YouAlreadyHaveSecondFactorAppEnabled.new) # <-- instead you could raise
end
user.otp_secret = User.generate_otp_secret
user.save!
Success(user) # <-- instead just return a value
end
end
end
Next, we will use this service in the controller.
# app/controllers/users/second_factor_controller.rb
# ...
def new_app
password_confirmation_form = PasswordConfirmationForm.new(password_confirmation_params)
SecondFactor::InitializeAppProvisioning.new.call(user: current_user, password: password_confirmation_form.password) do |m|
m.failure do |err|
password_confirmation_form.errors.add(:base, err.message)
render "devise/registrations/second_factor/initiate_new_app", locals: {password_confirmation_form: password_confirmation_form}, status: :unprocessable_entity
end
m.success do |user|
render "devise/registrations/second_factor/new_app", locals: {
qr_code: "",
otp_secret: user.otp_secret,
two_factor_app_enablement_form: TwoFactorAppEnablementForm.new(password: password_confirmation_form.password)
}
end
end
end
# ...
def password_confirmation_params
params.require(:password_confirmation_form).permit(:password)
end
# ...
But wait, one thing is missing — provisioning QR code generation. Even though devise-two-factor provides a method for generating the provisioning URI, we need another gem to generate the QR code. We will use rqrcode.
bundle add rqrcode
Now we have to update our controller.
# app/controllers/users/second_factor_controller.rb
require "rqrcode"
# ...
def new_app
password_confirmation_form = PasswordConfirmationForm.new(password_confirmation_params)
SecondFactor::InitializeAppProvisioning.new.call(user: current_user, password: password_confirmation_form.password) do |m|
# we are using dry-rb monads with pattern matching here
# if you don't want to use it you'll have to rewrite this part yourself
m.failure do |err|
password_confirmation_form.errors.add(:base, err.message)
render "devise/registrations/second_factor/initiate_new_app", locals: {password_confirmation_form: password_confirmation_form}, status: :unprocessable_entity
end
m.success do |user|
render "devise/registrations/second_factor/new_app", locals: {
qr_code: qr_code(user),
otp_secret: user.otp_secret,
two_factor_app_enablement_form: TwoFactorAppEnablementForm.new(password: password_confirmation_form.password)
}
end
end
end
# ...
def password_confirmation_params
params.require(:password_confirmation_form).permit(:password)
end
# ...
# param [User] user
def qr_code(user)
provisioning_uri = user.otp_provisioning_uri(user.email, issuer: "Your App Name")
RQRCode::QRCode.new(provisioning_uri, level: :l).as_svg(fill: :currentColor, color: "020617", viewbox: true)
end
# ...
And here is how the step 3 of our flow looks like.
User can scan the QR code or enter the secret to add a new account to the authenticator app
Google Authenticator screenshot
Enabling two-factor authentication
So now, after the user scans the QR code, adds the OTP secret to their authentication app, and enters the code into our form, we can proceed with enabling two-factor authentication.
Let’s create a new service to reduce clutter and decouple things from our controllers. I am using dry-rb monads and pattern matchers to keep the code readable and make error handling easier.
# app/services/second_factor/provision_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 ProvisionApp
include Dry::Monads[:result] # once again we are using dry-rb monads
include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)
# @param [User] user
# @param [String] password
# @param [String] otp_code
def call(user:, password:, otp_code:)
User.transaction do
unless user.valid_password?(password)
return Failure(InvalidPassword.new)
end
if user.two_factor_app_enabled
return Failure(YouAlreadyHaveSecondFactorAppEnabled.new)
end
unless user.validate_and_consume_otp!(otp_code)
return Failure(InvalidOtpCode.new)
end
user.otp_required_for_login = true
user.two_factor_app_enabled = true
user.two_factor_app_enabled_at = Time.current
user.save!
Success(user)
end
end
end
end
After creating the service we can use it in our controller.
# app/controllers/users/second_factor_controller.rb
# ...
def create_app
two_factor_app_enablement_form = TwoFactorAppEnablementForm.new(two_factor_app_enablement_params)
SecondFactor::ProvisionApp.new.call(
user: current_user,
password: two_factor_app_enablement_form.password,
otp_code: two_factor_app_enablement_form.otp_code
) do |m|
m.failure(SecondFactor::InvalidPassword) do |err|
form = PasswordConfirmationForm.new(password: two_factor_app_enablement_form.password)
form.errors.add(:base, err.message)
render "devise/registrations/second_factor/initiate_new_app", locals: {
password_confirmation_form: form
}, status: :unprocessable_entity
end
m.failure do |err|
form = TwoFactorAppEnablementForm.new(password: two_factor_app_enablement_form.password)
form.errors.add(:base, err.message)
render "devise/registrations/second_factor/new_app", locals: {
qr_code: qr_code(current_user),
otp_secret: current_user.otp_secret,
two_factor_app_enablement_form: form
}, status: :unprocessable_entity
end
m.success do
respond_to do |format|
format.html do
redirect_to edit_user_registration_path
end
format.turbo_stream do
# turbo stream refresh refreshes account settings page
# to immediately show user updated view
# alternatively we could turbo_stream.replace (but for now this lazy approach is fine :p )
render turbo_stream: turbo_stream.turbo_stream_refresh_tag(request_id: nil)
end
end
end
end
end
# ...
def two_factor_app_enablement_params
params.require(:two_factor_app_enablement_form).permit(:password, :otp_code)
end
# ...
🎉 Step 5 of our flow is ready!
Authenticator App settings when it’s enabled
Our controller so far.
# app/controllers/users/second_factor_controller.rb
require "rqrcode"
class Users::SecondFactorController < ApplicationController
before_action :authenticate_user!
layout "devise/registrations"
def initiate_new_app
render "devise/registrations/second_factor/initiate_new_app", locals: {password_confirmation_form: PasswordConfirmationForm.new}
end
def new_app
password_confirmation_form = PasswordConfirmationForm.new(password_confirmation_params)
SecondFactor::InitializeAppProvisioning.new.call(user: current_user, password: password_confirmation_form.password) do |m|
m.failure do |err|
password_confirmation_form.errors.add(:base, err.message)
render "devise/registrations/second_factor/initiate_new_app", locals: {password_confirmation_form: password_confirmation_form}, status: :unprocessable_entity
end
m.success do |user|
render "devise/registrations/second_factor/new_app", locals: {
qr_code: qr_code(user),
otp_secret: user.otp_secret,
two_factor_app_enablement_form: TwoFactorAppEnablementForm.new(password: password_confirmation_form.password)
}
end
end
end
def create_app
two_factor_app_enablement_form = TwoFactorAppEnablementForm.new(two_factor_app_enablement_params)
SecondFactor::ProvisionApp.new.call(
user: current_user,
password: two_factor_app_enablement_form.password,
otp_code: two_factor_app_enablement_form.otp_code
) do |m|
m.failure(SecondFactor::InvalidPassword) do |err|
form = PasswordConfirmationForm.new(password: two_factor_app_enablement_form.password)
form.errors.add(:base, err.message)
render "devise/registrations/second_factor/initiate_new_app", locals: {
password_confirmation_form: form
}, status: :unprocessable_entity
end
m.failure do |err|
form = TwoFactorAppEnablementForm.new(password: two_factor_app_enablement_form.password)
form.errors.add(:base, err.message)
render "devise/registrations/second_factor/new_app", locals: {
qr_code: qr_code(current_user),
otp_secret: current_user.otp_secret,
two_factor_app_enablement_form: form
}, status: :unprocessable_entity
end
m.success do
respond_to do |format|
format.html do
redirect_to edit_user_registration_path
end
format.turbo_stream do
render turbo_stream: turbo_stream.turbo_stream_refresh_tag(request_id: nil)
end
end
end
end
end
def new_destroy_app
end
def destroy_app
end
protected
def password_confirmation_params
params.require(:password_confirmation_form).permit(:password)
end
def two_factor_app_enablement_params
params.require(:two_factor_app_enablement_form).permit(:password, :otp_code)
end
# param [User] user
def qr_code(user)
provisioning_uri = user.otp_provisioning_uri(user.email, issuer: "Your App Name")
RQRCode::QRCode.new(provisioning_uri, level: :l).as_svg(fill: :currentColor, color: "020617", viewbox: true)
end
end
Disabling two-factor authentication
What how should a two-factor disabling flow look like? You can begin with a straightforward one like this
- User enters one time code
- Two-factor authentication is disabled
Make sure to tailor it to your needs (ex. additional password reconfirmation, sending code via SMS or email)
One time code entry form
First let’s create form object for OTP confirmation.
class OtpConfirmationForm
include ActiveModel::Model
attr_accessor :otp_code
end
Then we will use it in OTP confirmation view.
<!-- app/views/devise/registrations/second_factor/_new_destroy_app.html.erb -->
<%= form_with(model: otp_confirmation_form, class: "mt-10", url: destroy_user_two_factor_app_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 gap-3 mt-2">
<%= form.text_field :otp_code, id: "otp", class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 w-full", autocomplete: "one-time-password", placeholder: t("second_factor.one_time_or_backup_code") %>
<%= form.submit t("second_factor.disable"), 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 in the controller.
# app/controllers/users/second_factor_controller.rb
# ...
def new_destroy_app
render "devise/registrations/second_factor/new_destroy_app", locals: {otp_confirmation_form: OtpConfirmationForm.new}
end
# ...
Our form should be ready now!
Form is displayed inside modal using turbo
Disabling two-factor authentication
As usual, we’ll begin by creating a service.
# 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
unless user.validate_and_consume_otp!(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
user.save!
Success(user)
end
end
end
end
Then we’ll use it in the second factor controller.
# app/controllers/users/second_factor_controller.rb
# ...
def destroy_app
otp_confirmation_form = OtpConfirmationForm.new(otp_confirmation_params)
SecondFactor::DisableApp.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_destroy_app", locals: {otp_confirmation_form: form}, status: :unprocessable_entity
end
m.success do
respond_to do |format|
format.html do
redirect_to edit_user_registration_path
end
format.turbo_stream do
render turbo_stream: turbo_stream.turbo_stream_refresh_tag(request_id: nil)
end
end
end
end
end
# ...
def otp_confirmation_params
params.require(:otp_confirmation_form).permit(:otp_code)
end
And now we can disable two-factor authentication 🎉
Authenticator App settings when it’s disabled
Complete controller should be similar to this.
# app/controllers/users/second_factor_controller.rb
require "rqrcode"
class Users::SecondFactorController < ApplicationController
before_action :authenticate_user!
layout "devise/registrations"
def initiate_new_app
render "devise/registrations/second_factor/initiate_new_app", locals: {password_confirmation_form: PasswordConfirmationForm.new}
end
def new_app
password_confirmation_form = PasswordConfirmationForm.new(password_confirmation_params)
SecondFactor::InitializeAppProvisioning.new.call(user: current_user, password: password_confirmation_form.password) do |m|
m.failure do |err|
password_confirmation_form.errors.add(:base, err.message)
render "devise/registrations/second_factor/initiate_new_app", locals: {password_confirmation_form: password_confirmation_form}, status: :unprocessable_entity
end
m.success do |user|
render "devise/registrations/second_factor/new_app", locals: {
qr_code: qr_code(user),
otp_secret: user.otp_secret,
two_factor_app_enablement_form: TwoFactorAppEnablementForm.new(password: password_confirmation_form.password)
}
end
end
end
def create_app
two_factor_app_enablement_form = TwoFactorAppEnablementForm.new(two_factor_app_enablement_params)
SecondFactor::ProvisionApp.new.call(
user: current_user,
password: two_factor_app_enablement_form.password,
otp_code: two_factor_app_enablement_form.otp_code
) do |m|
m.failure(SecondFactor::InvalidPassword) do |err|
form = PasswordConfirmationForm.new(password: two_factor_app_enablement_form.password)
form.errors.add(:base, err.message)
render "devise/registrations/second_factor/initiate_new_app", locals: {
password_confirmation_form: form
}, status: :unprocessable_entity
end
m.failure do |err|
form = TwoFactorAppEnablementForm.new(password: two_factor_app_enablement_form.password)
form.errors.add(:base, err.message)
render "devise/registrations/second_factor/new_app", locals: {
qr_code: qr_code(current_user),
otp_secret: current_user.otp_secret,
two_factor_app_enablement_form: form
}, status: :unprocessable_entity
end
m.success do
respond_to do |format|
format.html do
redirect_to edit_user_registration_path
end
format.turbo_stream do
render turbo_stream: turbo_stream.turbo_stream_refresh_tag(request_id: nil)
end
end
end
end
end
def new_destroy_app
render "devise/registrations/second_factor/new_destroy_app", locals: {otp_confirmation_form: OtpConfirmationForm.new}
end
def destroy_app
otp_confirmation_form = OtpConfirmationForm.new(otp_confirmation_params)
SecondFactor::DisableApp.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_destroy_app", locals: {otp_confirmation_form: form}, status: :unprocessable_entity
end
m.success do
respond_to do |format|
format.html do
redirect_to edit_user_registration_path
end
format.turbo_stream do
render turbo_stream: turbo_stream.turbo_stream_refresh_tag(request_id: nil)
end
end
end
end
end
protected
def password_confirmation_params
params.require(:password_confirmation_form).permit(:password)
end
def two_factor_app_enablement_params
params.require(:two_factor_app_enablement_form).permit(:password, :otp_code)
end
def otp_confirmation_params
params.require(:otp_confirmation_form).permit(:otp_code)
end
# param [User] user
def qr_code(user)
provisioning_uri = user.otp_provisioning_uri(user.email, issuer: "Your App Name")
RQRCode::QRCode.new(provisioning_uri, level: :l).as_svg(fill: :currentColor, color: "020617", viewbox: true)
end
end
Showing OTP field only to users who have OTP enabled
We can achieve that in multiple ways, but in this tutorial, we’ll do that in a lazy way.
To begin we have to modify Devise SessionController. If you don’t have custom Devise controllers you can set them up by running Rails generator and following its instructions.
# if you already have custom devise controllers you can skip this part
rails generate devise:controllers users -c=sessions
Let’s create OTP attempt partial.
<div class="mb-3" data-login-target="otpField">
<%= label :user, :otp_attempt, t("second_factor.two_factor_authentication"), class: "block text-sm font-medium mb-2" %>
<%= text_field :user, :otp_attempt, id: :otp_attempt,
autocomplete: "one-time-code",
placeholder: t("second_factor.one_time_or_backup_code"),
class: "appearance-none border border-gray-300 rounded-md w-full py-2 px-3 text-sm text-gray-800 focus:border-amber-dark focus:ring-0" %>
</div>
Add an outlet where :otp_attempt
field will appear.
<!-- ... -->
<div id="otp_attempt_outlet">
</div>
<!-- ... -->
Then, we will render :otp_attempt
from SessionsController using turbo if the user has enabled two-factor authentication.
class Users::SessionsController < Devise::SessionsController
# ...
# POST /resource/sign_in
def create
# if the super is not called last devise breaks so we can't return early
if sign_in_params[:otp_attempt].nil?
user = attempting_user
if user&.otp_required_for_login
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.append("otp_attempt_outlet", partial: "devise/sessions/otp_attempt")
end
end
return
end
end
super
end
# ...
protected
def attempting_user
email = sign_in_params[:email]
password = sign_in_params[:password]
user = User.find_by(email: email)
unless user&.valid_password?(password)
return nil
end
user
end
end
Result
Login page without OTP attempt field
OTP attempt field appears if user has enabled two-factor authentication
Improvements to consider
Backup Codes
We should provide users with a secondary way of two-factor authentication in case they lose a primary device or it malfunctions. Backup codes are a good and easy solution to this problem. If you are interested in details of implementing backup codes please see this post.
SMS Authentication
Even though sending one-time codes through SMS is less secure than the Authenticator App, it’s still prevalent among many users. You can consider adding it as a standalone two-factor authentication method or a backup measure in case the user loses access to the Authenticator App.
Admin Panel
Administration / Customer support should have an option to disable two-factor authentication. However, verifying user identity before doing so is of the utmost importance—do not skimp on staff training; humans are the weakest link, after all.