Stories From The Field / Tech

Two-factor Authentication with Rails

Posted on June 24 2011 by Richard Taylor (@moomerman)

Web application security is a hot topic at the moment with the recent high-profile hacks and publication of user email/password lists so any measures that can be taken to further protect users should be considered depending on the nature of your web application. This post walks through setting up two-factor authentication for a Rails application.

Two-factor authentication helps to protect users by requiring two pieces of information to log in; one that the user knows (their password usually) and another that is random, unique, time-sensitive and only generated on demand.

Typically this second code is sent to the user in real-time either by Email, SMS, Automated Voice or by the use of a 'code generating' mobile application (like Google Authenticator). Using this mechanism ensures that someone who has access to your login and password still can't use the web application unless they also have access to your secondary authentication mechanism (email account or mobile phone in this case).

This implementation uses Email for simplicity but adding other delivery mechanisms is very easy to do. Rather than requiring the user to perform two-factor authentication every time they log in we use a permanent cookie to remember the 'validation' of the client for 30 days. The source code for the full application is available on github.

The implementation

First we're going to create a table to store each unique client (or device) the user logs in from and some other useful information to identify the client and track validation.

rails g model session

Then complete the migration like this:

class CreateSessions < ActiveRecord::Migration
  def change
    create_table :sessions do |t|
      t.references :user
      
      t.string   :client_id, :null => false
      t.string   :ip_address, :null => false
      t.string   :user_agent
      t.integer  :login_count, :null => false, :default => 0
      
      t.string   :unique_key
      t.datetime :unique_key_generated_at
      
      t.integer  :confirmation_failure_count, :null => false, :default => 0
      t.datetime :client_confirmed_at
      t.datetime :authenticated_at
      t.datetime :finished_at
      
      t.timestamps
    end
    add_index :sessions, [:user_id, :client_id], :unique => true
  end
end

Our ApplicationController adds some filters to control access to your resources, we're using a simple User authentication model here but this should fit in nicely with any user authentication you're using:


class ApplicationController < ActionController::Base
  protect_from_forgery
  helper_method :current_user, :current_session
  before_filter :user_required, :session_required, :confirmed_session_required
  
  private
    def current_user
      @current_user ||= User.find_by_id(session[:user_id]) if session[:user_id]
      session[:user_id] = nil unless @current_user
      @current_user
    end
    
    def current_session
      @current_session ||= Session.find_by_id(session[:user_session_id])
      session[:user_session_id] = nil unless @current_session
      @current_session
    end
    
    def user_required
      redirect_to :new_session unless current_user
    end
    
    def session_required
      redirect_to :track_sessions unless current_session
    end
    
    def confirmed_session_required
      redirect_to confirm_session_url(current_session), 
        :alert => "This device is not recognised" unless current_session.confirmed?
    end
end

The authentication flow is forced via the SessionsController#track method which is the meat of the implementation:

def track
  Session.perform_housekeeping
    
  # assign a new id for this client if it isn't recognised
  client_id = cookies.signed[:_client_id] ||= UUIDTools::UUID.timestamp_create.to_s
  @session = Session.find_or_initialize_by_user_id_and_client_id(current_user.id, client_id)
    
  # check if we have already authenticated this session
  redirect_to params[:next] || :root and return if @session == current_session
    
  @session.update_attributes(
    :ip_address => request.remote_ip,
    :user_agent => request.user_agent,
    :client_id =>  client_id,
    :login_count => @session.login_count + 1,
    :authenticated_at => Time.now.utc,
    :finished_at => nil
  )
    
  # sign up session is trusted
  @session.confirm! if session[:first_visit]
    
  session[:user_session_id] = @session.id
    
  # remember this client
  cookies.permanent.signed[:_client_id] = {
    :value => @session.client_id, 
    :secure => Rails.env.production? ? true : false,
    :httponly => true
  }
    
  @session.send_confirmation_code unless @session.confirmed?
    
  flash.keep # pass on any flash messages
  redirect_to params[:next] || :root
end

Unless the session is valid, the unique token is sent via email and the user is forced to a page where they can enter the code.

The SessionController#validate method also tracks the number of failed attempts to enter the validation code to prevent brute-force attacks and if the user exceeds 3 attempts a new code is generated and sent. We could potentially implement a delay here as well if required.

def validate
  @session.validation_code = params[:session][:validation_code]
  if @session == current_session and @session.validates?
    @session.confirm!
    redirect_to :root, :notice => 'This device is now validated'
  else
    @session.increment! :confirmation_failure_count
    if @session.too_many_failures?
      @session.send_confirmation_code
      flash[:alert] = "Too many validation failures. We've sent you another code."
    end
    redirect_to :action => :confirm
  end
end

Finally the Session model contains all the business logic for managing the session, an abbreviated version is below:



class Session < ActiveRecord::Base
  attr_accessor :email, :password, :validation_code
  
  validates_uniqueness_of :client_id, :scope => :user_id
  belongs_to :user
  default_scope :order => 'authenticated_at DESC'
  
  scope :confirmed, where('client_confirmed_at IS NOT NULL')
  scope :expired, lambda { confirmed.where("client_confirmed_at < ?", Time.zone.now - 30.days) }  
   
  def validates?
    return false if self.unique_key_generated_at < (Time.now.utc - 10.minutes)
    return false unless self.validation_code == self.unique_key
    return true
  end
  
  def send_confirmation_code
    assign_unique_key!
    self.update_attribute :confirmation_failure_count, 0
    # Could also easily do SMS or Automated Voice call here
    Mailer.session_confirmation(self).deliver
  end
  
  def self.perform_housekeeping
    # invalidate any expired sessions (30 days old)
    self.expired.update_all :client_confirmed_at => nil
  end
  
  private
    def assign_unique_key!
      assign_unique_key
      self.save!
    end
    
    # Assigns a time-sensitive random validation key
    def assign_unique_key
      # generate zero padded random 5 digits
      self.unique_key = ActiveSupport::SecureRandom.random_number(10 **5).to_s.rjust(5,'0')
      self.unique_key_generated_at = Time.now.utc
    end
end

Wrapping up

This implementation is relatively straight-forward and not too intrusive for the end-user. An added bonus is that the user can review and invalidate any unused sessions.

I thought about trying to package this up into a gem but I find that with this kind of thing a one-size-fits-all approach usually doesn't work. I hope it helps anyone else looking to do two-factor authentication and welcome any feedback on this implementation.

The next post continues with this theme and adds support for Google Authenticator.

Comments