From 8d4da3beb50fceae1dfeffac7fff3deac64b024d Mon Sep 17 00:00:00 2001 From: Paul Bowsher Date: Wed, 2 Sep 2015 13:42:23 +0100 Subject: [PATCH] Add support for remembering a user's 2FA session in a cookie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This makes the gem store a signed cookie for a configurable amount of time that allows the user to bypass 2FA. Our use-case for this is that we expire user’s Devise sessions after 12 hours, but don’t want to force them to authenticate using 2FA every day. Signed cookies are available since Rails 3. This requires the signing functionality to be properly configured, but is disabled by setting the config variable to `0`, the default. --- README.md | 19 ++++++---- .../two_factor_authentication_controller.rb | 7 ++++ lib/two_factor_authentication.rb | 4 ++ .../hooks/two_factor_authenticatable.rb | 3 +- .../models/two_factor_authenticatable.rb | 2 +- .../two_factor_authenticatable_spec.rb | 38 +++++++++++++++++++ spec/spec_helper.rb | 3 ++ two_factor_authentication.gemspec | 1 + 8 files changed, 67 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b56eae3c..d6c9c88d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ * configure max login attempts * per user level control if he really need two factor authentication * your own sms logic +* configurable period where users won't be asked for 2FA again ## Configuration @@ -38,12 +39,13 @@ Add the following line to your model to fully enable two-factor auth: has_one_time_password -Set config values, if desired, for maximum second factor attempts count, allowed time drift, and OTP length. +Set config values, if desired: ```ruby -config.max_login_attempts = 3 -config.allowed_otp_drift_seconds = 30 -config.otp_length = 6 +config.max_login_attempts = 3 # Maximum second factor attempts count +config.allowed_otp_drift_seconds = 30 # Allowed time drift +config.otp_length = 6 # OTP code length +config.remember_otp_session_for_seconds = 30.days # Time before browser has to enter OTP code again ``` Override the method to send one-time passwords in your model, this is automatically called when a user logs in: @@ -67,12 +69,13 @@ Add the following line to your model to fully enable two-factor auth: has_one_time_password -Set config values, if desired, for maximum second factor attempts count, allowed time drift, and OTP length. +Set config values, if desired: ```ruby -config.max_login_attempts = 3 -config.allowed_otp_drift_seconds = 30 -config.otp_length = 6 +config.max_login_attempts = 3 # Maximum second factor attempts count +config.allowed_otp_drift_seconds = 30 # Allowed time drift +config.otp_length = 6 # OTP code length +config.remember_otp_session_for_seconds = 30.days # Time before browser has to enter OTP code again ``` Override the method to send one-time passwords in your model, this is automatically called when a user logs in: diff --git a/app/controllers/devise/two_factor_authentication_controller.rb b/app/controllers/devise/two_factor_authentication_controller.rb index e302e01e..deed574a 100644 --- a/app/controllers/devise/two_factor_authentication_controller.rb +++ b/app/controllers/devise/two_factor_authentication_controller.rb @@ -9,6 +9,13 @@ def update render :show and return if params[:code].nil? if resource.authenticate_otp(params[:code]) + expires_seconds = resource.class.remember_otp_session_for_seconds + if expires_seconds && expires_seconds > 0 + cookies.signed[TwoFactorAuthentication::REMEMBER_TFA_COOKIE_NAME] = { + value: true, + expires: expires_seconds.from_now + } + end warden.session(resource_name)[TwoFactorAuthentication::NEED_AUTHENTICATION] = false sign_in resource_name, resource, :bypass => true set_flash_message :notice, :success diff --git a/lib/two_factor_authentication.rb b/lib/two_factor_authentication.rb index b976721b..1f7b342f 100644 --- a/lib/two_factor_authentication.rb +++ b/lib/two_factor_authentication.rb @@ -16,10 +16,14 @@ module Devise mattr_accessor :otp_length @@otp_length = 6 + + mattr_accessor :remember_otp_session_for_seconds + @@remember_otp_session_for_seconds = 0 end module TwoFactorAuthentication NEED_AUTHENTICATION = 'need_two_factor_authentication' + REMEMBER_TFA_COOKIE_NAME = "remember_tfa" autoload :Schema, 'two_factor_authentication/schema' module Controllers diff --git a/lib/two_factor_authentication/hooks/two_factor_authenticatable.rb b/lib/two_factor_authentication/hooks/two_factor_authenticatable.rb index 985dc345..5b479c4f 100644 --- a/lib/two_factor_authentication/hooks/two_factor_authenticatable.rb +++ b/lib/two_factor_authentication/hooks/two_factor_authenticatable.rb @@ -1,5 +1,6 @@ Warden::Manager.after_authentication do |user, auth, options| - if user.respond_to?(:need_two_factor_authentication?) + if user.respond_to?(:need_two_factor_authentication?) && + !auth.env["action_dispatch.cookies"].signed[TwoFactorAuthentication::REMEMBER_TFA_COOKIE_NAME] if auth.session(options[:scope])[TwoFactorAuthentication::NEED_AUTHENTICATION] = user.need_two_factor_authentication?(auth.request) user.send_two_factor_authentication_code end diff --git a/lib/two_factor_authentication/models/two_factor_authenticatable.rb b/lib/two_factor_authentication/models/two_factor_authenticatable.rb index 8e3e6973..dcfc1ae4 100644 --- a/lib/two_factor_authentication/models/two_factor_authenticatable.rb +++ b/lib/two_factor_authentication/models/two_factor_authenticatable.rb @@ -20,7 +20,7 @@ def self.attributes_protected_by_default #:nodoc: end end end - ::Devise::Models.config(self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length) + ::Devise::Models.config(self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length, :remember_otp_session_for_seconds) end module InstanceMethodsOnActivation diff --git a/spec/features/two_factor_authenticatable_spec.rb b/spec/features/two_factor_authenticatable_spec.rb index a1a424a0..466010b2 100644 --- a/spec/features/two_factor_authenticatable_spec.rb +++ b/spec/features/two_factor_authenticatable_spec.rb @@ -84,5 +84,43 @@ expect(page).to have_content("Access completely denied") expect(page).to have_content("You are signed out") end + + describe "rememberable TFA" do + before do + @original_remember_otp_session_for_seconds = User.remember_otp_session_for_seconds + User.remember_otp_session_for_seconds = 30.days + end + + after do + User.remember_otp_session_for_seconds = @original_remember_otp_session_for_seconds + end + + scenario "doesn't require TFA code again within 30 days" do + visit user_two_factor_authentication_path + fill_in "code", with: user.otp_code + click_button "Submit" + + logout + + login_as user + visit dashboard_path + expect(page).to have_content("Your Personal Dashboard") + expect(page).to have_content("You are signed in as Marissa") + end + + scenario "requires TFA code again after 30 days" do + visit user_two_factor_authentication_path + fill_in "code", with: user.otp_code + click_button "Submit" + + logout + + Timecop.travel(30.days.from_now) + login_as user + visit dashboard_path + expect(page).to have_content("You are signed in as Marissa") + expect(page).to have_content("Enter your personal code") + end + end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index bed93aad..32803c50 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,6 +2,7 @@ require File.expand_path("../rails_app/config/environment.rb", __FILE__) require 'rspec/rails' +require 'timecop' # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration RSpec.configure do |config| @@ -17,6 +18,8 @@ # the seed, which is printed after each run. # --seed 1234 config.order = 'random' + + config.after(:each) { Timecop.return } end Dir["#{Dir.pwd}/spec/support/**/*.rb"].each {|f| require f} diff --git a/two_factor_authentication.gemspec b/two_factor_authentication.gemspec index 32bbba3f..148dd0a6 100644 --- a/two_factor_authentication.gemspec +++ b/two_factor_authentication.gemspec @@ -34,4 +34,5 @@ Gem::Specification.new do |s| s.add_development_dependency 'rspec-rails', '>= 3.0.1' s.add_development_dependency 'capybara', '2.4.1' s.add_development_dependency 'pry' + s.add_development_dependency 'timecop' end