Device-two-factor

Public
Add devise-two-factor flow to your Devise User.
Icons/chart bar
Used 10 times
Created by
M Mike Kniazevic

Usage
Devise-Two-Factor byte doesn't require much to get started, but there are two prerequisites before you can start using it in your application:
  1. A Rails application with devise installed for User
  2. Secrets configured for ActiveRecord encrypted attributes

First, you'll need a Rails application setup with Devise. Visit the Devise homepage for instructions.
Devise-Two-Factor uses ActiveRecord encrypted attributes which in turn uses Rails' encrypted credentials. The Rails encrypted attributes guide has full details of how to set these up but briefly:
# generate suitable encryption secrets to stdout
$ ./bin/rails db:encryption:init

# Add the output from the command above to your encrypted credentials file via
# Setting the EDITOR environment variable is optional, without it, your default editor will open
$ EDITOR=code ./bin/rails credentials:edit

And the last one, after byte was run, add next code to your users/sessions controller:
include Users::AuthenticateWithOtpTwoFactor

before_action :authenticate_with_otp_two_factor, only: :create

Run this command in your Rails app directory in the terminal:

rails app:template LOCATION="https://railsbytes.com/script/VB0sJl"
Template Source

Review the code before running this template on your machine.

run "bundle add devise-two-factor"
run "bundle add rqrcode-rails3"
run "bundle install"

rails_command("generate devise_two_factor User")
rails_command("db:migrate")

inject_into_file 'app/controllers/application_controller.rb', after: "class ApplicationController < ActionController::Base\n" do <<~RUBY
    before_action :configure_permitted_parameters, if: :devise_controller?
  RUBY
end

inject_into_file "app/controllers/application_controller.rb", before: /^end/ do <<~RUBY
    
    def configure_permitted_parameters
      devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
    end
  RUBY
end

inject_into_file 'app/models/user.rb', after: "class User < ApplicationRecord\n" do <<~RUBY
    include Users::Otp
  RUBY
end

create_file "app/models/concerns/users/otp.rb", <<~RUBY
  # frozen_string_literal: true

  module Users::Otp
    extend ActiveSupport::Concern
    
    SVG_SETTINGS = {
      offset: 0,
      color: '000',
      shape_rendering: 'crispEdges',
      module_size: 4,
      standalone: true
    }.freeze

    def generate_two_factor_secret_if_missing!
      return if otp_secret.present?
      
      update!(otp_secret: User.generate_otp_secret)
    end
    
    # rubocop:disable Rails/OutputSafety
    def generate_two_factor_qr_code
      RQRCode::QRCode.new(two_factor_qr_code_uri).as_svg(SVG_SETTINGS).html_safe
    end
    # rubocop:enable Rails/OutputSafety
    
    def two_factor_qr_code_uri
      issuer = 'RB2fa'
      label = [issuer, email].join(':')
      
      otp_provisioning_uri(label, issuer: issuer)
    end
  end
RUBY

create_file "app/controllers/concerns/users/authenticate_with_otp_two_factor.rb", <<~RUBY
  # frozen_string_literal: true

  module Users::AuthenticateWithOtpTwoFactor
    extend ActiveSupport::Concern

    def authenticate_with_otp_two_factor
      return unless user&.otp_required_for_login?

      user.generate_two_factor_secret_if_missing!
      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 authenticate_user_with_otp_two_factor(user)
      if valid_otp_attempt?(user)
        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] = I18n.t('flashes.invalid_2fa_code')
        prompt_for_otp_two_factor(user)
      end
    end

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

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

    def user
      @user ||= if session[:otp_user_id]
                  User.find(session[:otp_user_id])
                else
                  User.find_by(email: user_params[:email])
                end
    end

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

create_file "app/views/devise/sessions/two_factor.html.erb", <<~RUBY
  <% if resource.consumed_timestep.blank? %>
    <h6 class='bold'>Please scan the below QR code using an OTP compatible app (such as Google Authenticator or Authy)</h6>
    <div class='center-text'>
      <%= resource.generate_two_factor_qr_code %>
    </div>
  <% else %> 
    <h2>Log in</h2>
  <% end %>
  <%= form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f| %>
    <div class='form-group'>
      <%= f.label 'Your One-time password code' %>
      <%= f.password_field :otp_attempt, autocomplete: "off", class: 'form-control' %>
    </div>
    <div class='actions'>
      <%= f.submit "Log in", class: 'btn btn-success pull-right' %>
    </div>
  <% end %>
RUBY
Comments

Sign up or Login to leave a comment.