To enhance the security of a web application having a user authentication workflow, we use a security method called 2FA. It is also known as Two Factor Authentication(type of Multi-Factor Authentication). In this blog post, we will see how to implement email-based 2FA in ActiveAdmin auth of a Ruby on Rails application.
In the email-based 2FA approach, when logging in with an email and password, an OTP will be sent on a registered email address. Upon entering the OTP, it will successfully authenticate and the session will be started.
Also, we will see the following additional functionality and customizations in this article:
- OTP to auto-expire in 60 seconds.
- OTP to be reset automatically after 60 seconds.
- OTP authentication expires after 24 hours.
Steps to implement the 2FA
- Setup active_model_otp
gem 'active_model_otp', '~> 2.0', '>= 2.0.1'
2. Create a migration
otp_secret_key
otp_auth_at
$ rails g migration AddOtpSecretKeyAndOtpAuthAtToUser otp_secret_key:string otp_auth_at:timestamp
3. Add routes
# config/routes.rb
devise_scope :user do
get '/admin/otp', to: 'users/sessions/otp_authentications#new', as: :admin_otp_page
post '/admin/otp', to: 'users/sessions/otp_authentications#create', as: :admin_verify_otp
end
4. Configure and add a "before" action
# config/initializers/active_admin.rb
config.before_action :authenticate_current_user_with_otp!
# app/controllers/application_controller.rb
def authenticate_current_user_with_otp!
return if devise_controller? || current_user.otp_authenticated?
redirect_to(admin_otp_page_path)
end
5. Add Controller
# app/controllers/users/sessions/otp_authentications_controller.rb
# frozen_string_literal: true
module Users
module Sessions
class OtpAuthenticationsController < ActiveAdmin::Devise::SessionsController
prepend_before_action -> { authenticate_user!(force: true) }
skip_before_action :require_no_authentication
def new
return unless otp_sent?
current_user.send_otp_mail
session[:otp_invalid_after] = Time.zone.now.advance(minutes: 1)
end
def create
if current_user.authenticate_otp(params[:otp], drift: 1.minutes)
current_user.touch(:otp_auth_at)
redirect_to admin_dashboard_path
else
# set invalid OTP flash message
render :new
end
end
private
def otp_sent?
return true if session[:otp_invalid_after].nil?
session[:otp_invalid_after] < Time.zone.now
end
end
end
end
6. Add OTP page
# app/views/users/sessions/otp_authentications/new.html.erb
<%= form_tag admin_verify_otp_path do %>
<h2>Enter OTP</h2>
<h2>Please check your email for the OTP</h2>
<%= text_field_tag 'otp', nil, options = { placeholder: 'One Time Password', size: '6', maxlength: '6' } %>
<%= hidden_field_tag(:email, current_user.email) %>
<%= submit_tag 'Submit OTP' %>
<%= link_to 'Resend OTP', admin_otp_page_path %>
<%= link_to 'Logout', destroy_user_session_path %>
<% end %>
7. Add methods to verify auth and for the mailer
# app/models/user.rb
class User < ApplicationRecord
OTP_AUTH_EXPIRES_IN = 24.hours
has_one_time_password
def send_otp_mail
AdminMailer.user_otp(email, otp_code).deliver_now
end
def otp_authenticated?
return unless otp_auth_at?
otp_auth_at + OTP_AUTH_EXPIRES_IN > Time.zone.now
end
end
8. Add AdminMailer
# app/mailers/admin_mailer.rb
class AdminMailer < ActionMailer::Base
default from: DEFAULT_EMAIL_ADDRESS
def user_otp(email, otp_code)
@otp_code = otp_code
mail(
to: email,
subject: 'Sign-in: Email verification',
bcc: BCC_EMAIL_ADDRESS_FOR_OTP
)
end
end
# app/views/admin_mailer/user_otp.html.erb
<h3>Verify your login</h3>
<h2><%= "Your OTP is #{@otp_code}" %></h2>
9. If you're adding this to an existing User model you'll need to generate otp_secret_key with a migration like:
User.find_each { |user| user.update_attribute(:otp_secret_key, User.otp_random_secret) }
Thank you for reading.