Devise is currently the most popular web authentication library for Rails. As a leading library in the web authentication category, Devise has plenty of third-party companion libraries that allow you to enhance the functionality of Devise.

One of these libraries is devise-two-factor which adds support for One Time Password (OTP) based two factor authentication.

devise-two-factor deliberately leaves the issue of how the UI/UX should work up to the implementer:

Devise-Two-Factor only worries about the backend, leaving the details of the integration up to you. This means that you're responsible for building the UI that drives the gem

If you follow the README for devise-two-factor or look at most publicly available examples you're likely going to end up with a login form that looks a little like this:

This isn't a great user experience in my opinion, as the user is prompted for an OTP code regardless of whether the user has enabled two-factor authentication or not.

I wanted to build a two-stage/two-screen workflow where the user is first asked for their email and password before being prompted for the OTP.

devise-two-factor has a closed issue – #14: Two factor as second login step? –  where various members of the community are asking for and volunteering help on how to achieve this.

This is how I have implemented OTP two-factor as a second login step, based on the community contributions in the GitHub issue.

Implementation

My solution is implemented in a Rails 6 app, following a standard devise and devise-two-factor setup. As devise and devise-two-factor are well documented I will briefly recap the setup steps without providing an in-depth explanation which can be gained by reading the documentation/READMEs of each library.

In the examples that follow you'll notice that I'm also using a few basic gems like Twitter Bootstrap and Simple Form. In the interest of focusing on devise and devise-two-factor, I will sign post you to the documentation for these libraries, if you're not familiar with them already:

Step 1: Install devise

Add Devise to your Gemfile:

gem "devise"

Install Devise and create/augment your User model with Devise attributes:

rails generate devise:install
rails generate devise User

Run rake db:migrate to apply any necessary migrations following the setup of devise.

At this point Devise is now installed and you should be able to sign up a new account and use your sign up details to login.

I also put together a basic screen that will show me who I am logged in as:

This also has an "Account Settings" link that takes me to the devise registrations#edit action which will let the user change their password:

Step 2: Install devise-two-factor

Assuming you can now login and logout of your application using email and password authentication we can look to add OTP-based two-factor authentication using devise-two-factor.

Add devise-two-factor to your gem file, and rqrcode for displaying the OTP secret as a QR code:

gem 'devise-two-factor'
gem 'rqrcode'

Run the devise-two-factor generator to augment the User model:

rails generate devise_two_factor User OTP_SECRET_KEY

OTP_SECRET_KEY is an environment variable that must be set when your application runs.

Optional: Support Backup Codes

In my implementation I wanted to support backup codes, so I generated a migration to add support for storing the codes:

rails g migration AddDeviseTwoFactorBackupableToUsers
class AddDeviseTwoFactorBackupableToUsers < ActiveRecord::Migration[6.0]
  def change
    # Change type from :string to :text if using MySQL database
    add_column :users, :otp_backup_codes, :string, array: true
  end
end

The User model also needs updating to include :two_factor_backupable as a devise capability. You can also configure the number and length of backup codes:

devise :two_factor_authenticatable, :two_factor_backupable,
         otp_backup_code_length: 10, otp_number_of_backup_codes: 10,
         :otp_secret_encryption_key => ENV['OTP_SECRET_KEY']

Step 3: Enabling/Disabling OTP-based two-factor authentication

Before we update the implementation of our login screen, we will first need to allow the user to enable and disable two-factor authentication.

The User model need to be enhanced by adding a series of two-factor methods that will allow:

  • An OTP secret to be generate
  • Two-factor OTP to be enabled
  • Two-factor OTP to be disabled
  • A QR code to be generated representing the issuer and OTP secret
  • A method for determining if backup codes have been generated or not
 
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :registerable,
         :recoverable, :rememberable, :validatable

  devise :two_factor_authenticatable, :two_factor_backupable,
         otp_backup_code_length: 10, otp_number_of_backup_codes: 10,
         :otp_secret_encryption_key => ENV['OTP_SECRET_KEY']

  # Ensure that backup codes can be serialized
  serialize :otp_backup_codes, JSON

  attr_accessor :otp_plain_backup_codes

  # Generate an OTP secret it it does not already exist
  def generate_two_factor_secret_if_missing!
    return unless otp_secret.nil?
    update!(otp_secret: User.generate_otp_secret)
  end

  # Ensure that the user is prompted for their OTP when they login
  def enable_two_factor!
    update!(otp_required_for_login: true)
  end

  # Disable the use of OTP-based two-factor.
  def disable_two_factor!
    update!(
        otp_required_for_login: false,
        otp_secret: nil,
        otp_backup_codes: nil)
  end

  # URI for OTP two-factor QR code
  def two_factor_qr_code_uri
    issuer = ENV['OTP_2FA_ISSUER_NAME']
    label = [issuer, email].join(':')

    otp_provisioning_uri(label, issuer: issuer)
  end

  # Determine if backup codes have been generated
  def two_factor_backup_codes_generated?
    otp_backup_codes.present?
  end

end

We can now use these methods to put together a controller and a workflow that will allow the user to enable or disable OTP-based two-factor authentication.

We will now create a new controller – TwoFactorSettingsController – that will let the user enable and disable two-factor authentication.

This is a straightforward RESTful resource where the

  • new and create routes are used to enable two-factor authentication. Once enabled the user is redirected to :edit
  • edit shows the backup codes
  • destroy disabled two-factor authentication
class TwoFactorSettingsController < ApplicationController
  before_action :authenticate_user!

  def new
    if current_user.otp_required_for_login
      flash[:alert] = 'Two Factor Authentication is already enabled.'
      return redirect_to edit_user_registration_path
    end

    current_user.generate_two_factor_secret_if_missing!
  end

  def create
    unless current_user.valid_password?(enable_2fa_params[:password])
      flash.now[:alert] = 'Incorrect password'
      return render :new
    end

    if current_user.validate_and_consume_otp!(enable_2fa_params[:code])
      current_user.enable_two_factor!

      flash[:notice] = 'Successfully enabled two factor authentication, please make note of your backup codes.'
      redirect_to edit_two_factor_settings_path
    else
      flash.now[:alert] = 'Incorrect Code'
      render :new
    end
  end

  def edit
    unless current_user.otp_required_for_login
      flash[:alert] = 'Please enable two factor authentication first.'
      return redirect_to new_two_factor_settings_path
    end

    if current_user.two_factor_backup_codes_generated?
      flash[:alert] = 'You have already seen your backup codes.'
      return redirect_to edit_user_registration_path
    end

    @backup_codes = current_user.generate_otp_backup_codes!
    current_user.save!
  end

  def destroy
    if current_user.disable_two_factor!
      flash[:notice] = 'Successfully disabled two factor authentication.'
      redirect_to edit_user_registration_path
    else
      flash[:alert] = 'Could not disable two factor authentication.'
      redirect_back fallback_location: root_path
    end
  end

  private

  def enable_2fa_params
    params.require(:two_fa).permit(:code, :password)
  end

end

And the controller should be added to the routes file:

resource :two_factor_settings, except: [:index, :show]

We will also need to define the associated new.html.erb and edit.html.erb views.

Let's start with the new view:

<div class="container">
  <div class="row">
    <div class="col-md-12">
      <h2>Two Factor Authentication</h2>
    </div>
  </div>
  <div class="row">
    <div class="col-md-12">
      <div class="card-deck">
        <div class="card">
          <div class="card-header">
            1. Scan QR Code
          </div>
          <div class="card-body">
            <p>Please scan the below QR code using an OTP compatible app (such as Google Authenticator or Authy).</p>
            <hr />
            <p class="text-center">
              <%= qr_code_as_svg(current_user.two_factor_qr_code_uri)%>
            </p>
            <hr />
            <p class="text-center">
              If you cannot scan, please enter the following code manually: <code><%= current_user.otp_secret%></code>
            </p>
          </div>
        </div>
        <div class="card">
          <div class="card-header">
            2. Confirm OTP Code
          </div>
          <div class="card-body">
            <p>Please confirm that your authentication application is working by entering a generated code below.</p>
            <%= simple_form_for(:two_fa, url: two_factor_settings_path, method: :post) do |f| %>
            <%= f.input :code %>
            <%= f.input :password, label: 'Enter your current password' %>
            <%= f.submit 'Confirm and Enable Two Factor', class: 'btn btn-primary' %>
            <% end %>
          </div>
        </div>page.driver.browser.switch_to.alert.accept
      </div>
    </div>
  </div>
</div>
app/views/two_factor_settings/new.html.erb

qr_code_as_svg is a helper method that we'll need:

module QrCodeHelper
  def qr_code_as_svg(uri)
    RQRCode::QRCode.new(uri).as_svg(
        offset: 0,
        color: '000',
        shape_rendering: 'crispEdges',
        module_size: 4,
        standalone: true
    ).html_safe
  end
end
app/helpers/qr_code_helper.rb

Which should look a little like this:

The edit view will show the user the backup codes:


<div class="container">
  <div class="row">
    <div class="col-md-12">
      <h2>Two Factor Authentication</h2>
    </div>
  </div>
  <div class="row">
    <div class="col-md-12">
      <div class="card-deck">
        <div class="card">
          <div class="card-header">
            Backup Codes
          </div>
          <div class="card-body">
            <p>Keep these backup codes safe in case you lose access to your authenticator app:</p>
            <hr />
            <% @backup_codes.each_slice(2) do |(code1, code2)| %>
              <div class="row">
                <div class="col-6 text-monospace strong"><%=code1 %></div>
                <div class="col-6 text-monospace strong"><%=code2 %></div>
              </div>
            <% end %>
          </div>
        </div>
        <div class="card">
          <div class="card-header">
            Two Factor Setup Complete
          </div>
          <div class="card-body">
            <p>Two factor authentication has been successfully enabled.</p>
            <%= link_to('Return to account settings', edit_user_registration_path, class: 'btn btn-primary') %>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
app/views/two_factor_settings/edit.html.erb

Our two-factor controller will need to be accessible from somewhere. I've opted to make this accessible by modifying the app/views/devise/registrations/edit.html.erb view:

...
<br />
<div class="card">
  <div class="card-header">
    Two factor authentication
  </div>
  <div class="card-body">
    <% if current_user.otp_required_for_login %>
      <p>Two factor authentication is enabled.</p>
      <p><%= link_to('Disable Two Factor Authentication', two_factor_settings_path, class: 'btn btn-danger', method: :delete, data: {confirm: 'Are you sure you want to disable two factor authentication?'}) %></p>
    <% else %>
      <p>Two factor authentication is NOT enabled.</p>
      <p><%= link_to('Enable Two Factor Authentication', new_two_factor_settings_path, class: 'btn btn-primary') %></p>
    <% end %>
  </div>
</div>
<br />
...
app/views/devise/registrations/edit.html.erb

If you're using Devise with the built in view you can run a generator to generate the set of view files that you can then edit:

rails generate devise:views

The final thing left to do here is to update the way that the login screen works. The update will involve adding an otp_attempt field to the login form, so that the view looks as follows:

<div class="card">
  <div class="card-header">Login</div>
  <div class="card-body">
    <%= simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
      <%= f.input :email, autofocus: true, autocomplete: 'email' %>
      <%= f.input :password, autocomplete: 'current-password' %>
      <%= f.input :otp_attempt, label: 'OTP' %>
      <% if devise_mapping.rememberable? %>
        <%= f.input :remember_me, as: :boolean %>
      <% end %>
      <div class="actions">
        <%= f.submit 'Login', class: 'btn btn-primary mt-2' %>
      </div>
    <% end %>

    <%= render "devise/shared/links" %>
  </div>
</div>
app/views/devise/sessions/new.html.erb

The sessions controller will also need to be updated to support the otp_attempt parameter.

Create a new SessionsController that will override the built-in SessionsController that Devise ships with:

class SessionsController < Devise::SessionsController
  layout 'login'

  protected

  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
  end

end
app/controllers/sessions_controller.rb 

Using a custom controller involves updating our devise_for entry in our config/routes.rb:

  devise_for :users, controllers: {
      sessions: 'sessions'
  }

We're now at the point where a user can enable/disable OTP-based two-factor authentication, and login. But the user experience is clunky, as there will always be an OTP field visible regardless of whether the user has enabled two-factor or not. This can be very confusing for an end user.

In our final step we will modify the SessionsController so that the OTP code can be collected via a second login screen.

Step 4: Custom SessionsController and AuthenticateWithOtpTwoFactor concern

Devise handles logins on the create action. One way of modifying the login process is to add a prepend_before_action when the create action is called for a user who has two factor authentication enabled:

At a high level prepend_before_action will apply the following workflow:

  • If a valid password has been submitted but no otp_attempt is present render a prompt requesting the OTP code. This form will submit to the create action, causing the prepend_before_action to execute again
  • If an otp_attempt has been  provided, validate that the OTP is correct. If it is, sign the user in, if it is not, prompt for the OTP code again

We can implement this by modifying the SessionsController and implementing an AuthenticateWithOtpTwoFactor concern. The concern will keep the two-factor complexities outside of the controller:

class SessionsController < Devise::SessionsController
  include AuthenticateWithOtpTwoFactor
  layout 'login'

  prepend_before_action :authenticate_with_otp_two_factor,
                        if: -> { action_name == 'create' && otp_two_factor_enabled? }

  protect_from_forgery with: :exception, prepend: true, except: :destroy

end
app/controllers/sessions_controller.rb
module AuthenticateWithOtpTwoFactor
  extend ActiveSupport::Concern


  def authenticate_with_otp_two_factor
    user = self.resource = find_user

    if user_params[:otp_attempt].present? && session[:otp_user_id]
      authenticate_user_with_otp_two_factor(user)
    elsif user&.valid_password?(user_params[:password])
      prompt_for_otp_two_factor(user)
    end
  end

  private

  def valid_otp_attempt?(user)
    user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
        user.invalidate_otp_backup_code!(user_params[:otp_attempt])
  end

  def prompt_for_otp_two_factor(user)
    @user = user

    session[:otp_user_id] = user.id
    render 'devise/sessions/two_factor'
  end

  def authenticate_user_with_otp_two_factor(user)
    if valid_otp_attempt?(user)
      # Remove any lingering user data from login
      session.delete(:otp_user_id)

      remember_me(user) if user_params[:remember_me] == '1'
      user.save!
      sign_in(user, event: :authentication)
    else
      flash.now[:alert] = 'Invalid two-factor code.'
      prompt_for_otp_two_factor(user)
    end
  end

  def user_params
    params.require(:user).permit(:email, :password, :remember_me, :otp_attempt)
  end

  def find_user
    if session[:otp_user_id]
      User.find(session[:otp_user_id])
    elsif user_params[:email]
      User.find_by(email: user_params[:email])
    end
  end

  def otp_two_factor_enabled?
    find_user&.otp_required_for_login
  end

end
app/controllers/concerns/authenticate_with_otp_two_factor.rb

Finally, remove the otp_attempt from the login screen and create a devise/sessions/two_factor.html.erb view:

<div class="card">
  <div class="card-header">Two Factor Authentication</div>
  <div class="card-body">
    <%= simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f| %>
      <%= f.input :otp_attempt, label: 'OTP' %>
      <div class="actions">
        <%= f.submit 'Login', class: 'btn btn-primary mt-2' %>
      </div>
    <% end %>
  </div>
</div>
devise/sessions/two_factor.html.erb

Conclusion

This implementation will result in two-stage login process where the user is first asked for their email and password, before being prompted for their OTP code:

The code for this example is available on GitHub — jamesridgway/devise-otp-second-step.

The example is complete with RSpec feature tests that can be run using

bundle exec rspec

Resources: