Skip to content

Commit 3243180

Browse files
committed
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.
1 parent 9c71601 commit 3243180

File tree

8 files changed

+66
-10
lines changed

8 files changed

+66
-10
lines changed

README.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* configure max login attempts
1010
* per user level control if he really need two factor authentication
1111
* your own sms logic
12+
* configurable period where users won't be asked for 2FA again
1213

1314
## Configuration
1415

@@ -38,12 +39,13 @@ Add the following line to your model to fully enable two-factor auth:
3839

3940
has_one_time_password
4041

41-
Set config values, if desired, for maximum second factor attempts count, allowed time drift, and OTP length.
42+
Set config values, if desired:
4243

4344
```ruby
44-
config.max_login_attempts = 3
45-
config.allowed_otp_drift_seconds = 30
46-
config.otp_length = 6
45+
config.max_login_attempts = 3 # Maximum second factor attempts count
46+
config.allowed_otp_drift_seconds = 30 # Allowed time drift
47+
config.otp_length = 6 # OTP code length
48+
config.remember_otp_session_for_seconds = 30.days # Time before browser has to enter OTP code again
4749
```
4850

4951
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:
6769

6870
has_one_time_password
6971

70-
Set config values, if desired, for maximum second factor attempts count, allowed time drift, and OTP length.
72+
Set config values, if desired:
7173

7274
```ruby
73-
config.max_login_attempts = 3
74-
config.allowed_otp_drift_seconds = 30
75-
config.otp_length = 6
75+
config.max_login_attempts = 3 # Maximum second factor attempts count
76+
config.allowed_otp_drift_seconds = 30 # Allowed time drift
77+
config.otp_length = 6 # OTP code length
78+
config.remember_otp_session_for_seconds = 30.days # Time before browser has to enter OTP code again
7679
```
7780

7881
Override the method to send one-time passwords in your model, this is automatically called when a user logs in:

app/controllers/devise/two_factor_authentication_controller.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ def update
99
render :show and return if params[:code].nil?
1010

1111
if resource.authenticate_otp(params[:code])
12+
expires_seconds = resource.class.remember_otp_session_for_seconds
13+
if expires_seconds && expires_seconds > 0
14+
cookies.signed[TwoFactorAuthentication::REMEMBER_TFA_COOKIE_NAME] = {
15+
value: true,
16+
expires: expires_seconds.from_now
17+
}
18+
end
1219
warden.session(resource_name)[TwoFactorAuthentication::NEED_AUTHENTICATION] = false
1320
sign_in resource_name, resource, :bypass => true
1421
set_flash_message :notice, :success

lib/two_factor_authentication.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@ module Devise
1616

1717
mattr_accessor :otp_length
1818
@@otp_length = 6
19+
20+
mattr_accessor :remember_otp_session_for_seconds
21+
@@remember_otp_session_for_seconds = 0
1922
end
2023

2124
module TwoFactorAuthentication
2225
NEED_AUTHENTICATION = 'need_two_factor_authentication'
26+
REMEMBER_TFA_COOKIE_NAME = "remember_tfa"
2327

2428
autoload :Schema, 'two_factor_authentication/schema'
2529
module Controllers

lib/two_factor_authentication/hooks/two_factor_authenticatable.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
Warden::Manager.after_authentication do |user, auth, options|
2-
if user.respond_to?(:need_two_factor_authentication?)
2+
if user.respond_to?(:need_two_factor_authentication?) &&
3+
!auth.env["action_dispatch.cookies"].signed[TwoFactorAuthentication::REMEMBER_TFA_COOKIE_NAME]
34
if auth.session(options[:scope])[TwoFactorAuthentication::NEED_AUTHENTICATION] = user.need_two_factor_authentication?(auth.request)
45
user.send_two_factor_authentication_code
56
end

lib/two_factor_authentication/models/two_factor_authenticatable.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def self.attributes_protected_by_default #:nodoc:
2020
end
2121
end
2222
end
23-
::Devise::Models.config(self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length)
23+
::Devise::Models.config(self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length, :remember_otp_session_for_seconds)
2424
end
2525

2626
module InstanceMethodsOnActivation

spec/features/two_factor_authenticatable_spec.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,5 +84,42 @@
8484
expect(page).to have_content("Access completely denied")
8585
expect(page).to have_content("You are signed out")
8686
end
87+
88+
describe "rememberable TFA" do
89+
before do
90+
@original_remember_otp_session_for_seconds = User.remember_otp_session_for_seconds
91+
User.remember_otp_session_for_seconds = 30.days
92+
end
93+
94+
after do
95+
User.remember_otp_session_for_seconds = @original_remember_otp_session_for_seconds
96+
end
97+
98+
scenario "doesn't require TFA code again within 30 days" do
99+
visit user_two_factor_authentication_path
100+
fill_in "code", with: user.otp_code
101+
click_button "Submit"
102+
103+
logout
104+
105+
login_as user
106+
visit dashboard_path
107+
expect(page).to have_content("Your Personal Dashboard")
108+
expect(page).to have_content("You are signed in as Marissa")
109+
end
110+
111+
scenario "requires TFA code again after 30 days" do
112+
visit user_two_factor_authentication_path
113+
fill_in "code", with: user.otp_code
114+
click_button "Submit"
115+
116+
logout
117+
118+
Timecop.travel(30.days.from_now)
119+
login_as user
120+
visit dashboard_path
121+
expect(page).to have_content("You are signed in as Marissa")
122+
end
123+
end
87124
end
88125
end

spec/spec_helper.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
require File.expand_path("../rails_app/config/environment.rb", __FILE__)
33

44
require 'rspec/rails'
5+
require 'timecop'
56

67
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
78
RSpec.configure do |config|
@@ -17,6 +18,8 @@
1718
# the seed, which is printed after each run.
1819
# --seed 1234
1920
config.order = 'random'
21+
22+
config.after(:each) { Timecop.return }
2023
end
2124

2225
Dir["#{Dir.pwd}/spec/support/**/*.rb"].each {|f| require f}

two_factor_authentication.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,5 @@ Gem::Specification.new do |s|
3434
s.add_development_dependency 'rspec-rails', '>= 3.0.1'
3535
s.add_development_dependency 'capybara', '2.4.1'
3636
s.add_development_dependency 'pry'
37+
s.add_development_dependency 'timecop'
3738
end

0 commit comments

Comments
 (0)