Browse Source

Add support for remembering a user's 2FA session in a cookie

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.
master
Paul Bowsher 9 years ago
parent
commit
8d4da3beb5
  1. 19
      README.md
  2. 7
      app/controllers/devise/two_factor_authentication_controller.rb
  3. 4
      lib/two_factor_authentication.rb
  4. 3
      lib/two_factor_authentication/hooks/two_factor_authenticatable.rb
  5. 2
      lib/two_factor_authentication/models/two_factor_authenticatable.rb
  6. 38
      spec/features/two_factor_authenticatable_spec.rb
  7. 3
      spec/spec_helper.rb
  8. 1
      two_factor_authentication.gemspec

19
README.md

@ -9,6 +9,7 @@
* configure max login attempts * configure max login attempts
* per user level control if he really need two factor authentication * per user level control if he really need two factor authentication
* your own sms logic * your own sms logic
* configurable period where users won't be asked for 2FA again
## Configuration ## Configuration
@ -38,12 +39,13 @@ Add the following line to your model to fully enable two-factor auth:
has_one_time_password 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 ```ruby
config.max_login_attempts = 3 config.max_login_attempts = 3 # Maximum second factor attempts count
config.allowed_otp_drift_seconds = 30 config.allowed_otp_drift_seconds = 30 # Allowed time drift
config.otp_length = 6 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: 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 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 ```ruby
config.max_login_attempts = 3 config.max_login_attempts = 3 # Maximum second factor attempts count
config.allowed_otp_drift_seconds = 30 config.allowed_otp_drift_seconds = 30 # Allowed time drift
config.otp_length = 6 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: Override the method to send one-time passwords in your model, this is automatically called when a user logs in:

7
app/controllers/devise/two_factor_authentication_controller.rb

@ -9,6 +9,13 @@ class Devise::TwoFactorAuthenticationController < DeviseController
render :show and return if params[:code].nil? render :show and return if params[:code].nil?
if resource.authenticate_otp(params[:code]) 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 warden.session(resource_name)[TwoFactorAuthentication::NEED_AUTHENTICATION] = false
sign_in resource_name, resource, :bypass => true sign_in resource_name, resource, :bypass => true
set_flash_message :notice, :success set_flash_message :notice, :success

4
lib/two_factor_authentication.rb

@ -16,10 +16,14 @@ module Devise
mattr_accessor :otp_length mattr_accessor :otp_length
@@otp_length = 6 @@otp_length = 6
mattr_accessor :remember_otp_session_for_seconds
@@remember_otp_session_for_seconds = 0
end end
module TwoFactorAuthentication module TwoFactorAuthentication
NEED_AUTHENTICATION = 'need_two_factor_authentication' NEED_AUTHENTICATION = 'need_two_factor_authentication'
REMEMBER_TFA_COOKIE_NAME = "remember_tfa"
autoload :Schema, 'two_factor_authentication/schema' autoload :Schema, 'two_factor_authentication/schema'
module Controllers module Controllers

3
lib/two_factor_authentication/hooks/two_factor_authenticatable.rb

@ -1,5 +1,6 @@
Warden::Manager.after_authentication do |user, auth, options| 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) if auth.session(options[:scope])[TwoFactorAuthentication::NEED_AUTHENTICATION] = user.need_two_factor_authentication?(auth.request)
user.send_two_factor_authentication_code user.send_two_factor_authentication_code
end end

2
lib/two_factor_authentication/models/two_factor_authenticatable.rb

@ -20,7 +20,7 @@ module Devise
end end
end 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 end
module InstanceMethodsOnActivation module InstanceMethodsOnActivation

38
spec/features/two_factor_authenticatable_spec.rb

@ -84,5 +84,43 @@ feature "User of two factor authentication" do
expect(page).to have_content("Access completely denied") expect(page).to have_content("Access completely denied")
expect(page).to have_content("You are signed out") expect(page).to have_content("You are signed out")
end 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
end end

3
spec/spec_helper.rb

@ -2,6 +2,7 @@ ENV["RAILS_ENV"] ||= "test"
require File.expand_path("../rails_app/config/environment.rb", __FILE__) require File.expand_path("../rails_app/config/environment.rb", __FILE__)
require 'rspec/rails' require 'rspec/rails'
require 'timecop'
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
RSpec.configure do |config| RSpec.configure do |config|
@ -17,6 +18,8 @@ RSpec.configure do |config|
# the seed, which is printed after each run. # the seed, which is printed after each run.
# --seed 1234 # --seed 1234
config.order = 'random' config.order = 'random'
config.after(:each) { Timecop.return }
end end
Dir["#{Dir.pwd}/spec/support/**/*.rb"].each {|f| require f} Dir["#{Dir.pwd}/spec/support/**/*.rb"].each {|f| require f}

1
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 'rspec-rails', '>= 3.0.1'
s.add_development_dependency 'capybara', '2.4.1' s.add_development_dependency 'capybara', '2.4.1'
s.add_development_dependency 'pry' s.add_development_dependency 'pry'
s.add_development_dependency 'timecop'
end end

Loading…
Cancel
Save