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

  1. 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) }

If you face any issue, drop a comment 📝 in the comments section below 👇 mentioning the issue and error-details if any.

We would ❤️ to hear from you. Thank you.

References