Profile pic of Tommy KuTommy Ku's Blog

Reading and thinking

Integrating two-step two-factor authentication into Rails 4 project with devise

Posted on July 02, 2016

About a month back at where I worked I was asked to implement a two-step two-factor authentication into our existing Rails 4 project.

Table of Content

  1. Why is this hard?
  2. Compromise
  3. Implementation

Why is this hard?

Being two-factor isn’t a problem because there are many libraries (aka gems) implementing that and are handy enought to be working with Devise — the authentication solution we were then using.

For example:

But the two-step part made the two-week sprint rather painful. Fact: none of the aforementioned gems implement two-step two-factor auth; they only handle the two-factor part.

Issue from tinfoil/devise-two-factor shows that many others had the same problem.

Devise isn’t being helpful either — it’s session controller was designed to login in one step only.

And before you ask, no there isn’t an easy way to hack Devise’s SessionsController into neatly adding another action to make it two-step while keeping all the security goodies and still look legible to my coworkers.

Lockable fails to work if you modify SessionController just so you can first: verify login username and password; second: verify the two-step auth token because you are supposed to login with username, password and two-factor auth token all at once with the gems above.

You could end up first verifying the username and password but it then fails to lock the account with multiple attempts to get pass the first step, because the second step is the real Devise login. See my point?

For those trying to brute force you with a dictionary, they know they’re done when they see the two-step auth token prompt. I did check Lockable module, you can’t invoke it from controller.

Of course nobody wants to hack Devise, either. (because that means one has to maintain it)

Compromise

We ended up with a compromise after long frustration. Let’s not verify the password at the first step. We shall send users their two-factor auth token whenever they’ve keyed in the right username.

SMS will then be sent and users be prompted to input their password and two-step auth token. Pretty lockable that way, and it’s still two-step from the look of it.

Code samples down below if you’re interested in our implementation, else you’re good to go.

Implementation

The following implementation is available on tommyku/blog3_lab-two_step. Major steps are committed and you can find the commit id conveniently labelled.

My confession: the code is in Rails 5. I only noticed it after the majority of it has been done. It should work fine on Rails 4.2 as I did it the exact same way as the original implementation.

You may want to skip to the best part because most of these are just simple Rails + Devise setup.

Setup

$ rails -v
Rails 5.0.0
$ rails new two-step
$ cd two-step
$ rails db:migrate
$ rake db:setup

By default this rails project use sqlite database. Pretty care-free for a simple tutorial like this.

You’ve reached commit 81f2cc7 at this point.

Installing Devise and configure a simple login system

Add this line to to Gemfile.

gem 'devise'

Then we will simply set it up.

$ bundle install
$ rails generate devise:install

Follow the Devise installation instructions.

===============================================================================

Some setup you must do manually if you haven't yet:

  1. Ensure you have defined default url options in your environments files. Here
     is an example of default_url_options appropriate for a development environment
     in config/environments/development.rb:

       config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

     In production, :host should be set to the actual host of your application.

  2. Ensure you have defined root_url to *something* in your config/routes.rb.
     For example:

       root to: "home#index"

  3. Ensure you have flash messages in app/views/layouts/application.html.erb.
     For example:

       <p class="notice"><%= notice %></p>
       <p class="alert"><%= alert %></p>

  4. You can copy Devise views (for customization) to your app by running:

       rails g devise:views

===============================================================================

You’re told to add a controller action home#index yet there isn’t a HomeController yet.

# app/controllers/home_controller.rb

class HomeController < ApplicationController
  protect_from_forgery with: :exception

  def index
  end
end
<%# app/views/home/index.html.erb %>

Yeah you're at home.

Start the server and navigate to http://localhost:3000/.

$ rails server

You should see “yeah you’re at home”

You’ve reached commit c35f801 at this point.

Now let’s make a simple login feature.

$ rails generate devise User
$ rake db:migrate

Add this line to home controller because we wanna authenticate the user.

# app/controllers/home_controller.rb

class HomeController < ApplicationController
  protect_from_forgery with: :exception
  before_action :authenticate_user!

  def index
  end
end

Add two seeds for use to test later.

# db/seeds.rb

User.create! email: '[email protected]', password: '1'*8
User.create! email: '[email protected]', password: '1'*8

Then seed it.

$ rake db:seed

You should now restart the server and try to login. Both user accounts should work fine.

You’ve reached commit f4214fc at this point.

Let’s just add the logout button.

<%# app/views/home/index.html.erb %>

Yeah you're at home.
<% if user_signed_in? %>
  <%= link_to('Logout', destroy_user_session_path, method: :delete) %>
<% else %>
  <%= link_to('Login', new_user_session_path)  %>
<% end %>

Now refresh and you should see a working logout button.

You’ve reached commit 95dff89 at this point.

Integrating two-factor authentication

We chose tinfoil/devise-two-factor as I work out-of-the-box but any other devise two-factor auth gem should work similarily in the next part. You may need to tweak the function calls a bit but the flow should be the same.

Now just add the gem.

gem 'devise-two-factor'
$ bundle install
$ rails generate devise_two_factor User ENCRPYTION_KEY

In your User model:

Extend from ActiveRecord::Base instead of ApplicationRecord on Rails 4.

# app/models/user.rb

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  devise :two_factor_authenticatable,
         otp_secret_encryption_key: 'any_random_string_or_rails_secret'
  devise :registerable, :recoverable, 
         :rememberable, :trackable, :validatable
end

The random string that goes into otp_secret_encryption_key should be stored in your ENV (as advised by the gem README) and generating rails secret may come in handy when you want something secure.

And in application_controller.rb, as perscribed.

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  before_action :configure_permitted_parameters, if: :devise_controller?

  protected

  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
  end
end
$ rake db:migrate

There are 2 users in database but two-factor authentication should be activated individually per account. In this case we will only do it to [email protected] and leave [email protected] as is.

Of course in production environment you might want to do it through an user setting page or something.

$ rails console
> u = User.find_by(email: '[email protected]')
> u.otp_required_for_login = true
> u.otp_secret = User.generate_otp_secret
> u.save!

When you try to login. You will see that you can’t because of 2 reasons: 1) you haven’t added a field for one-time password and 2) you don’t know the one-time password to fill in.

<%# app/views/devise/sessions/new.html.erb %>

<h2>Log in</h2>

<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
  <div class="field">
    <%= f.label :email %><br />
    <%= f.email_field :email, autofocus: true %>
  </div>

  <div class="field">
    <%= f.label :password %><br />
    <%= f.password_field :password, autocomplete: "off" %>
  </div>

  <div class="field">
    <%= f.label :otp_attempt %><br />
    <%= f.text_field :otp_attempt, autocomplete: "off" %>
  </div>

  <% if devise_mapping.rememberable? -%>
    <div class="field">
      <%= f.check_box :remember_me %>
      <%= f.label :remember_me %>
    </div>
  <% end -%>

  <div class="actions">
    <%= f.submit "Log in" %>
  </div>
<% end %>

<%= render "devise/shared/links" %>

The OTP of [email protected] can be obtained from the rails console the next time you login. You’re good to go if can get pass the login page.

> u.current_otp

You’ve reached commit 3ebfcfe at this point.

Making it two-step

Finally we are at the meat of this article. All work done so far just try to emulate an existing Rails application you’re running on. Check out to 3ebfcfe of the code sample if you have skipped all those.

General idea:

  1. use SJR to see if username is correct
  2. display one-time password input box if the user has two-factor auth enabled, else just display a simple password box
$ rails generate devise:controllers users

To do this, we need to add an additional action pre_otp to Devise’s session controller.

# config/routes.rb

Rails.application.routes.draw do
  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
  root to: "home#index"

  devise_for :users, controllers: {
    sessions: 'users/sessions'
  }
  devise_scope :user do
    scope :users, as: :users do
      post 'pre_otp', to: 'users/sessions#pre_otp'
    end
  end
end

In this configuration when user sign in Users::SessionsController will be invoked instead of the default Devise::SessionsController.

# app/controllers/user/sessions_controller.rb

class Users::SessionsController < Devise::SessionsController
  def pre_otp
    user = User.find_by pre_otp_params
    @otp_ok = user && user.otp_required_for_login
    respond_to do |format|
      format.js {
        @otp = user.current_otp if @otp_ok
      }
    end
  end

  private

  def pre_otp_params
    params.require(:user).permit(:email)
  end
end

A new action pre_otp is defined in addition to the defaul Devise::SessionsController actions. It is responsible for checking whether an user email has two-factor auth enabled. Result of the test is indicated by an instance variable @otp_ok.

We use respond_to which only respond with Javascript because we want to do AJAX but was too lazy. Server-generated Javascript response (SJR) can be used to implement AJAX conveniently.

We are going to split the login form into 2 steps. In the view of sessions#new there are two forms step-1 and step-2 respectively.

Note that step-1 has an option remote: true. With this flag we tell the Rails application to send an AJAX request instead of form post when we click submit.

step-2 is hidden for the moment.

<%# app/views/users/sessions/new.html.erb %>

<h2>Log in</h2>

<%= form_for(resource, as: resource_name, url: users_pre_otp_path, method: :post, remote: true, html: {id: 'step-1'}) do |f| %>
  <div class="field">
    <%= f.label :email %><br />
    <%= f.email_field :email, autofocus: true %>
  </div>

  <div class="actions">
    <%= f.submit "next" %>
  </div>
<% end %>

<%= form_for(resource, as: resource_name, url: session_path(resource_name), html: {class: 'hidden', id: 'step-2'}) do |f| %>
  <div class="field">
    <%= f.label :email %><br />
    <%= f.email_field :email, autofocus: true %>
  </div>

  <div class="field">
    <%= f.label :password %><br />
    <%= f.password_field :password, autocomplete: "off" %>
  </div>

  <div id="step-2-otp" class="field hidden">
    <%= f.label :otp_attempt %><br />
    <%= f.text_field :otp_attempt, autocomplete: "off" %>
  </div>

  <% if devise_mapping.rememberable? -%>
    <div class="field">
      <%= f.check_box :remember_me %>
      <%= f.label :remember_me %>
    </div>
  <% end -%>

  <div class="actions">
    <%= f.submit "Log in" %>
  </div>
<% end %>

<%= render "devise/shared/links" %>
/* app/assets/stylesheets/application.css */
/*
 *= require_tree .
 *= require_self
 */

.hidden {
  display: none;
}

Now we have the form all set, we need a Javascript file to run the logic after step 1. We do this by adding the view of sessions#pre_otp.

<%# app/views/users/sessions/pre_otp.js.erb %>

var stepOne = $('#step-1');
stepOne.addClass('hidden');
var email = stepOne.find('#user_email').val();
var stepTwo = $('#step-2');
stepTwo.removeClass('hidden');
stepTwo.find('#user_email').val(email);
stepTwo.find('#user_password').focus();
if (<%= @otp_ok === true %>) {
  console.debug(<%= @otp %>);
  $('#step-2-otp').removeClass('hidden');
}

For our convenience we print out the current otp of the logging in user so we don’t have to use the console. In production application of course this should be removed and users should be able to receive their one-time password via SMS sent inside sessions#pre_otp.

We’re done here. Try restarting the server (so new files are loaded) then try to login as [email protected] and [email protected] respectively. You should see Otp attmpt input box when logging in [email protected] but not for [email protected].

You’ve reached commit 3a2ebbf at this point.

You could also look at...