Introduction

If you want to enable two-factor authentication for rails app, you may find some idea from this post.

Background

Getstart

1. Setup devise-two-factor

  • Add gem 'devise-two-factor' to Gemfile

  • $ bundle install

  • Add the following line into your user model. Note, rails5 your ENV key “MFA_ENCRYPTION_KEY” must be at least 32 bytes as required by OpenSSL;

    devise :two_factor_authenticatable, otp_secret_encryption_key: ENV['MFA_ENCRYPTION_KEY']
    
  • Execute $ rails generate devise_two_factor user MFA_ENCRYPTION_KEY This will generate a migration file with extra fields used for user model, and it will also configure the devise initializer to define a :two_factor_authenticatable strategy.

  • bundle exec rake db:migrate Now you have database ready.

2. Override devise SessionsController

  • If you happen to use the activeadmin like I do, you could make an initializer to override the devise’s session_controller
# config/initializers/active_admin_devise_sessions_controller.rb
class ActiveAdmin::Devise::SessionsController
  include AuthenticatesWithTwoFactor

  prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]

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

  private

  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 two_factor_enabled?
    find_user&.two_factor_enabled?
  end

  def user_params
    params.require(:user).permit(:email, :password, :remember_me, :otp_attempt)
  end
end
  • The module of AuthenticatesWithTwoFactor

module AuthenticatesWithTwoFactor
  extend ActiveSupport::Concern

  included do
    # This action comes from DeviseController, but because we call `sign_in`
    # manually, not skipping this action would cause a "You are already signed
    # in." error message to be shown upon successful login.
    skip_before_action :require_no_authentication, only: [:create], raise: false
  end

  # The user must have been authenticated with a valid login and password
  # before calling this method!
  def prompt_for_two_factor(user)
    # Set @user for Devise views
    @user = user

    return locked_user_redirect(user) unless user.can? :login # define your own logic

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

  def locked_user_redirect(_user)
    flash.now[:alert] = 'Invalid Login or password'
    render 'devise/sessions/new'
  end

  def authenticate_with_two_factor
    user = self.resource = find_user
    if user_params[:otp_attempt].present? && session[:otp_user_id]
      authenticate_with_two_factor_via_otp(user)
    elsif user &.valid_password?(user_params[:password])
      prompt_for_two_factor(user)
    end
  end

  private

  def authenticate_with_two_factor_via_otp(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, notice: :two_factor_authenticated)
    else
      Rails.logger.info("Failed Login: user=#{user.email} ip=#{request.remote_ip} method=OTP")
      flash.now[:alert] = 'Invalid two-factor code.'
      prompt_for_two_factor(user)
    end
  end
end

  • view for submit the extra code

    app/views/sessions/two_factor.html.erb

<% if @user.two_factor_enabled? %>
  <%= form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f| %>
    <% resource_params = params[resource_name].presence || params %>
    <%= f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0) %>
    <div>
      <%= f.label 'Two-Factor Authentication code', name:  :otp_attempt %>
      <%= f.text_field :otp_attempt, class: 'form-control', required: true, autofocus: true, autocomplete: 'off', title: 'This field is required.' %>
      <p class="form-text text-muted hint">Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.</p>
      <div class="prepend-top-20">
        <%= f.submit "Verify code", class: "button button-action" %>
      </div>
    </div>
  <% end %>
<% end %>


  • If you do not use ActiveAdmin, you can just override the session controller
# app/controllers/sessions_controller.rb
class SessionsController < Devise::SessionsController
  ...
end

Explanation

The idea is to call a befor_action of authenticate_with_two_factor if the user.two_factor_enabled?. If validated, then it call the :create method in session controller.

QRcode

Generate a QRcode in the UI for the user to integrate the 2fa with a 3rd mobile application, and provide the code to actually enable this feature.

The way to generate the QRCode is simple

  • Add gem 'rqrcode' into the Gemfile
  • $ bundle install
  • In your user model
  def mfa_qrcode_source
    issuer = "Your app name"
    label = "#{issuer}:#{email}"
    otp_provisioning_uri(label, issuer: issuer)
  end

  # then to generate a qucode svg for this user would be:
  svg =  RQRCode::QRCode.new(user.mfa_qrcode_source)
           .as_svg(offset: 0,
                   color: '000',
                   shape_rendering: 'crispEdges',
                   module_size: 2)
           .html_safe

Conclusion:

This should get your application setup with a basic 2fa, you can continue to make it perfect, e.g. two-factor-backupable.

Finally, most of the code in thie article is inspired from an open source project, gitlabhq, you may find more useful information over there.