devise-otp 1.1.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.erb_lint.yml +30 -0
  3. data/.github/workflows/ci.yml +4 -2
  4. data/.rubocop.yml +18 -0
  5. data/Appraisals +4 -17
  6. data/CHANGELOG.md +59 -1
  7. data/Gemfile +7 -1
  8. data/README.md +37 -14
  9. data/app/controllers/devise_otp/devise/otp_credentials_controller.rb +30 -7
  10. data/app/controllers/devise_otp/devise/otp_persistence_controller.rb +60 -0
  11. data/app/controllers/devise_otp/devise/otp_tokens_controller.rb +2 -37
  12. data/app/views/devise/otp_credentials/refresh.html.erb +6 -6
  13. data/app/views/devise/otp_credentials/show.html.erb +10 -13
  14. data/app/views/devise/otp_tokens/_token_secret.html.erb +9 -9
  15. data/app/views/devise/otp_tokens/_trusted_devices.html.erb +7 -7
  16. data/app/views/devise/otp_tokens/edit.html.erb +9 -9
  17. data/app/views/devise/otp_tokens/recovery.html.erb +8 -6
  18. data/app/views/devise/otp_tokens/show.html.erb +6 -6
  19. data/bin/appraisal +16 -0
  20. data/bin/erb_lint +16 -0
  21. data/bin/rake +16 -0
  22. data/bin/rubocop +16 -0
  23. data/config/locales/{en.yml → devise-otp.en.yml} +10 -8
  24. data/devise-otp.gemspec +2 -2
  25. data/gemfiles/rails_8.0.gemfile +5 -1
  26. data/gemfiles/{rails_7.1.gemfile → rails_8.1.gemfile} +7 -7
  27. data/lib/devise/strategies/database_authenticatable.rb +4 -17
  28. data/lib/devise-otp/version.rb +1 -1
  29. data/lib/devise-otp.rb +1 -2
  30. data/lib/devise_otp_authenticatable/controllers/helpers.rb +1 -2
  31. data/lib/devise_otp_authenticatable/controllers/public_helpers.rb +1 -2
  32. data/lib/devise_otp_authenticatable/controllers/url_helpers.rb +7 -2
  33. data/lib/devise_otp_authenticatable/hooks/refreshable.rb +0 -1
  34. data/lib/devise_otp_authenticatable/models/otp_authenticatable.rb +18 -12
  35. data/lib/devise_otp_authenticatable/routes.rb +7 -7
  36. data/test/dummy/app/assets/stylesheets/application.css +15 -1
  37. data/test/dummy/app/controllers/admin_posts_controller.rb +0 -1
  38. data/test/dummy/app/controllers/base_controller.rb +0 -2
  39. data/test/dummy/app/controllers/non_otp_posts_controller.rb +0 -1
  40. data/test/dummy/app/models/admin.rb +0 -1
  41. data/test/dummy/app/models/lockable_user.rb +8 -0
  42. data/test/dummy/app/models/non_otp_user.rb +0 -1
  43. data/test/dummy/app/models/rememberable_user.rb +8 -0
  44. data/test/dummy/app/views/layouts/application.html.erb +4 -2
  45. data/test/dummy/app/views/posts/show.html.erb +0 -1
  46. data/test/dummy/app/views/shared/_navbar.html.erb +23 -0
  47. data/test/dummy/config/application.rb +3 -0
  48. data/test/dummy/config/initializers/devise.rb +2 -2
  49. data/test/dummy/config/routes.rb +3 -0
  50. data/test/dummy/db/migrate/20250731000001_create_lockable_users.rb +9 -0
  51. data/test/dummy/db/migrate/20250731000002_add_devise_to_lockable_users.rb +52 -0
  52. data/test/dummy/db/migrate/20250731000003_devise_otp_add_to_lockable_users.rb +28 -0
  53. data/test/dummy/db/migrate/20250817221304_create_rememberable_users.rb +50 -0
  54. data/test/dummy/db/migrate/20250818030305_add_devise_otp_to_rememberable_users.rb +28 -0
  55. data/test/dummy/db/schema.rb +110 -44
  56. data/test/integration/disable_token_test.rb +0 -2
  57. data/test/integration/enable_otp_form_test.rb +12 -1
  58. data/test/integration/lockable_test.rb +143 -0
  59. data/test/integration/non_otp_user_models_test.rb +0 -2
  60. data/test/integration/otp_drift_test.rb +35 -0
  61. data/test/integration/persistence_test.rb +96 -5
  62. data/test/integration/refresh_test.rb +23 -14
  63. data/test/integration/rememberable_test.rb +143 -0
  64. data/test/integration/reset_token_test.rb +0 -2
  65. data/test/integration/sign_in_test.rb +15 -8
  66. data/test/integration/trackable_test.rb +0 -2
  67. data/test/integration_tests_helper.rb +22 -1
  68. data/test/models/otp_authenticatable_test.rb +48 -11
  69. data/test/test_helper.rb +1 -0
  70. metadata +26 -9
  71. data/gemfiles/rails_7.2.gemfile +0 -17
@@ -4,12 +4,12 @@ require "integration_tests_helper"
4
4
  class PersistenceTest < ActionDispatch::IntegrationTest
5
5
  def setup
6
6
  @old_persistence = User.otp_trust_persistence
7
- User.otp_trust_persistence = 3.seconds
8
7
  end
9
8
 
10
9
  def teardown
11
10
  User.otp_trust_persistence = @old_persistence
12
11
  Capybara.reset_sessions!
12
+ Timecop.return
13
13
  end
14
14
 
15
15
  test "a user should be requested the otp challenge every log in" do
@@ -34,7 +34,7 @@ class PersistenceTest < ActionDispatch::IntegrationTest
34
34
  visit user_otp_token_path
35
35
  assert_equal user_otp_token_path, current_path
36
36
 
37
- click_link("Trust this browser")
37
+ click_button("Trust this browser")
38
38
  assert_text "Your browser is trusted."
39
39
  within "#alerts" do
40
40
  assert page.has_content? 'Your device is now trusted.'
@@ -46,6 +46,46 @@ class PersistenceTest < ActionDispatch::IntegrationTest
46
46
  assert_equal root_path, current_path
47
47
  end
48
48
 
49
+ test "a user should be able to remove their browser" do
50
+ # log in 1fa
51
+ user = enable_otp_and_sign_in
52
+ otp_challenge_for user
53
+
54
+ original_persistence_seed = user.otp_persistence_seed
55
+
56
+ visit user_otp_token_path
57
+ click_button("Trust this browser")
58
+ click_button("Remove this browser from the list of trusted browsers")
59
+
60
+ assert_text "Your browser is not trusted."
61
+ # Test that the OTP Persistence Seed is still valid
62
+ assert_equal user.reload.otp_persistence_seed, original_persistence_seed
63
+
64
+ sign_out
65
+ sign_user_in
66
+ assert_equal user_otp_credential_path, current_path
67
+ end
68
+
69
+ test "a user should be able to clear all trusted browsers" do
70
+ # log in 1fa
71
+ user = enable_otp_and_sign_in
72
+ otp_challenge_for user
73
+
74
+ original_persistence_seed = user.otp_persistence_seed
75
+
76
+ visit user_otp_token_path
77
+ click_button("Trust this browser")
78
+ click_button("Clear the list of trusted browsers")
79
+
80
+ assert_text "Your browser is not trusted."
81
+ # Test that the OTP Persistence Seed was reset
82
+ assert_not_equal user.reload.otp_persistence_seed, original_persistence_seed
83
+
84
+ sign_out
85
+ sign_user_in
86
+ assert_equal user_otp_credential_path, current_path
87
+ end
88
+
49
89
  test "a user should be able to download its recovery codes" do
50
90
  # log in 1fa
51
91
  user = enable_otp_and_sign_in
@@ -60,7 +100,7 @@ class PersistenceTest < ActionDispatch::IntegrationTest
60
100
  assert page.body.match?(user.next_otp_recovery_tokens.values.join("\n"))
61
101
  end
62
102
 
63
- test "trusted status should expire" do
103
+ test "trusted status should last until the expiration date" do
64
104
  # log in 1fa
65
105
  user = enable_otp_and_sign_in
66
106
  otp_challenge_for user
@@ -68,13 +108,64 @@ class PersistenceTest < ActionDispatch::IntegrationTest
68
108
  visit user_otp_token_path
69
109
  assert_equal user_otp_token_path, current_path
70
110
 
71
- click_link("Trust this browser")
111
+ click_button("Trust this browser")
72
112
  assert_text "Your browser is trusted."
73
113
  sign_out
74
114
 
75
- sleep User.otp_trust_persistence.to_i + 1
115
+ Timecop.travel(Time.now + 29.days)
116
+
76
117
  sign_user_in
118
+ assert_equal root_path, current_path
119
+ end
120
+
121
+ test "trusted status should expire after the expiration date" do
122
+ # log in 1fa
123
+ user = enable_otp_and_sign_in
124
+ otp_challenge_for user
125
+
126
+ visit user_otp_token_path
127
+ assert_equal user_otp_token_path, current_path
128
+
129
+ click_button("Trust this browser")
130
+ assert_text "Your browser is trusted."
131
+ sign_out
132
+
133
+ Timecop.travel(Time.now + 30.days)
77
134
 
135
+ sign_user_in
78
136
  assert_equal user_otp_credential_path, current_path
79
137
  end
138
+
139
+ test "a user should be prompted for credentials if the credentials_refresh time is expired" do
140
+ # log in 1fa
141
+ user = enable_otp_and_sign_in
142
+ otp_challenge_for user
143
+
144
+ visit user_otp_token_path
145
+ assert_equal user_otp_token_path, current_path
146
+
147
+ Timecop.travel(Time.now + 15.minutes)
148
+
149
+ click_button("Trust this browser")
150
+ assert_equal refresh_user_otp_credential_path, current_path
151
+ end
152
+
153
+ test "a user should be redirected to the fallback (OTP Token page) after refreshing their credentials for trusting their browser" do
154
+ # log in 1fa
155
+ user = enable_otp_and_sign_in
156
+ otp_challenge_for user
157
+
158
+ visit user_otp_token_path
159
+ assert_equal user_otp_token_path, current_path
160
+
161
+ Timecop.travel(Time.now + 15.minutes)
162
+
163
+ click_button("Trust this browser")
164
+ assert_equal refresh_user_otp_credential_path, current_path
165
+
166
+ fill_in "user_refresh_password", with: "12345678"
167
+ click_button "Continue..."
168
+
169
+ assert_equal user_otp_token_path, current_path
170
+ end
80
171
  end
@@ -2,16 +2,9 @@ require "test_helper"
2
2
  require "integration_tests_helper"
3
3
 
4
4
  class RefreshTest < ActionDispatch::IntegrationTest
5
- def setup
6
- @old_refresh = User.otp_credentials_refresh
7
- User.otp_credentials_refresh = 1.second
8
- Admin.otp_credentials_refresh = 1.second
9
- end
10
-
11
5
  def teardown
12
- User.otp_credentials_refresh = @old_refresh
13
- Admin.otp_credentials_refresh = @old_refresh
14
6
  Capybara.reset_sessions!
7
+ Timecop.return
15
8
  end
16
9
 
17
10
  test "a user that just signed in should be able to access their OTP settings without refreshing" do
@@ -26,7 +19,7 @@ class RefreshTest < ActionDispatch::IntegrationTest
26
19
  visit user_otp_token_path
27
20
  assert_equal user_otp_token_path, current_path
28
21
 
29
- sleep(2)
22
+ Timecop.travel(Time.now + 15.minutes)
30
23
 
31
24
  visit user_otp_token_path
32
25
  assert_equal refresh_user_otp_credential_path, current_path
@@ -37,7 +30,7 @@ class RefreshTest < ActionDispatch::IntegrationTest
37
30
  visit user_otp_token_path
38
31
  assert_equal user_otp_token_path, current_path
39
32
 
40
- sleep(2)
33
+ Timecop.travel(Time.now + 15.minutes)
41
34
 
42
35
  visit user_otp_token_path
43
36
  assert_equal refresh_user_otp_credential_path, current_path
@@ -52,7 +45,7 @@ class RefreshTest < ActionDispatch::IntegrationTest
52
45
  visit user_otp_token_path
53
46
  assert_equal user_otp_token_path, current_path
54
47
 
55
- sleep(2)
48
+ Timecop.travel(Time.now + 15.minutes)
56
49
 
57
50
  visit user_otp_token_path
58
51
  assert_equal refresh_user_otp_credential_path, current_path
@@ -67,14 +60,15 @@ class RefreshTest < ActionDispatch::IntegrationTest
67
60
 
68
61
  visit "/"
69
62
  within "#alerts" do
70
- assert !page.has_content?('Sorry, you provided the wrong credentials.')
63
+ assert_not page.has_content?('Sorry, you provided the wrong credentials.')
71
64
  end
72
65
  end
73
66
 
74
67
  test "user should be finally be able to access their settings, and just password is enough" do
75
68
  user = enable_otp_and_sign_in_with_otp
76
69
 
77
- sleep(2)
70
+ Timecop.travel(Time.now + 15.minutes)
71
+
78
72
  visit user_otp_token_path
79
73
  assert_equal refresh_user_otp_credential_path, current_path
80
74
 
@@ -102,7 +96,7 @@ class RefreshTest < ActionDispatch::IntegrationTest
102
96
  click_button "Submit Token"
103
97
  assert_equal "/", current_path
104
98
 
105
- sleep(2)
99
+ Timecop.travel(Time.now + 15.minutes)
106
100
 
107
101
  visit admin_otp_token_path
108
102
  assert_equal refresh_admin_otp_credential_path, current_path
@@ -113,4 +107,19 @@ class RefreshTest < ActionDispatch::IntegrationTest
113
107
  assert_equal admin_otp_token_path, current_path
114
108
  end
115
109
 
110
+ test "failed credentials should return a 422 'unprocessable entity' status" do
111
+ sign_user_in
112
+ visit user_otp_token_path
113
+ assert_equal user_otp_token_path, current_path
114
+
115
+ Timecop.travel(Time.now + 15.minutes)
116
+
117
+ visit user_otp_token_path
118
+ assert_equal refresh_user_otp_credential_path, current_path
119
+
120
+ fill_in "user_refresh_password", with: "12345670"
121
+ click_button "Continue..."
122
+
123
+ assert_equal 422, page.status_code
124
+ end
116
125
  end
@@ -0,0 +1,143 @@
1
+ require "test_helper"
2
+ require "integration_tests_helper"
3
+
4
+ class RememberableTest < ActionDispatch::IntegrationTest
5
+ def setup
6
+ @rememberable_user = create_rememberable_user
7
+ @rememberable_user.populate_otp_secrets!
8
+ @rememberable_user.enable_otp!
9
+ end
10
+
11
+ def teardown
12
+ Capybara.reset_sessions!
13
+ Timecop.return
14
+ end
15
+
16
+ test "checking remember_me at the sign in page persists the selection to the OTP credentials page" do
17
+ visit new_rememberable_user_session_path
18
+
19
+ fill_in "rememberable_user_email", with: "rememberable-user@email.invalid"
20
+ fill_in "rememberable_user_password", with: "12345678"
21
+ check "Remember me"
22
+ click_button("Log in")
23
+
24
+ assert_equal rememberable_user_otp_credential_path, current_path
25
+
26
+ assert_equal "true", find("#remember_me", visible: false).value
27
+ end
28
+
29
+ test "checking remember me during the sign in process with OTP enabled remembers the user" do
30
+ visit new_rememberable_user_session_path
31
+
32
+ fill_in "rememberable_user_email", with: "rememberable-user@email.invalid"
33
+ fill_in "rememberable_user_password", with: "12345678"
34
+ check "Remember me"
35
+ click_button("Log in")
36
+
37
+ assert_equal rememberable_user_otp_credential_path, current_path
38
+
39
+ fill_in "token", with: ROTP::TOTP.new(@rememberable_user.otp_auth_secret).at(Time.now)
40
+ click_button("Submit Token")
41
+
42
+ assert current_path, "/"
43
+
44
+ assert page.driver.browser.last_request.cookies['remember_rememberable_user_token']
45
+ end
46
+
47
+ test "not checking remember me during the sign in process does not remember the user" do
48
+ visit new_rememberable_user_session_path
49
+
50
+ fill_in "rememberable_user_email", with: "rememberable-user@email.invalid"
51
+ fill_in "rememberable_user_password", with: "12345678"
52
+ click_button("Log in")
53
+
54
+ assert_equal rememberable_user_otp_credential_path, current_path
55
+
56
+ fill_in "token", with: ROTP::TOTP.new(@rememberable_user.otp_auth_secret).at(Time.now)
57
+ click_button("Submit Token")
58
+
59
+ assert current_path, "/"
60
+
61
+ assert_nil page.driver.browser.last_request.cookies['remember_rememberable_user_token']
62
+ end
63
+
64
+ test "the OTP credentials page persists the remember_me value through any reloads" do
65
+ visit new_rememberable_user_session_path
66
+
67
+ fill_in "rememberable_user_email", with: "rememberable-user@email.invalid"
68
+ fill_in "rememberable_user_password", with: "12345678"
69
+ check "Remember me"
70
+ click_button("Log in")
71
+
72
+ assert_equal rememberable_user_otp_credential_path, current_path
73
+
74
+ fill_in "token", with: "123456"
75
+ click_button("Submit Token")
76
+
77
+ assert_equal rememberable_user_otp_credential_path, current_path
78
+
79
+ assert_equal "true", find("#remember_me", visible: false).value
80
+ end
81
+
82
+ test "checking remember_me at the sign in page does not remember the user until sign in is completed" do
83
+ visit new_rememberable_user_session_path
84
+
85
+ fill_in "rememberable_user_email", with: "rememberable-user@email.invalid"
86
+ fill_in "rememberable_user_password", with: "12345678"
87
+ check "Remember me"
88
+ click_button("Log in")
89
+
90
+ assert_equal rememberable_user_otp_credential_path, current_path
91
+
92
+ assert_nil page.driver.browser.last_request.cookies['remember_rememberable_user_token']
93
+ end
94
+
95
+ test "rememberable users without OTP enabled are remembered immediately" do
96
+ @rememberable_user.disable_otp!
97
+
98
+ visit new_rememberable_user_session_path
99
+ fill_in "rememberable_user_email", with: "rememberable-user@email.invalid"
100
+ fill_in "rememberable_user_password", with: "12345678"
101
+ check "Remember me"
102
+ click_button("Log in")
103
+
104
+ assert page.driver.browser.last_request.cookies['remember_rememberable_user_token']
105
+ end
106
+
107
+ test "rememberable users with browser persistence enabled are still remembered when signing in" do
108
+ visit new_rememberable_user_session_path
109
+
110
+ fill_in "rememberable_user_email", with: "rememberable-user@email.invalid"
111
+ fill_in "rememberable_user_password", with: "12345678"
112
+ check "Remember me"
113
+ click_button("Log in")
114
+
115
+ fill_in "token", with: ROTP::TOTP.new(@rememberable_user.otp_auth_secret).at(Time.now)
116
+ click_button("Submit Token")
117
+
118
+ visit rememberable_user_otp_token_path
119
+ click_button "Trust this browser"
120
+ click_button("Sign Out")
121
+
122
+ visit new_rememberable_user_session_path
123
+ fill_in "rememberable_user_email", with: "rememberable-user@email.invalid"
124
+ fill_in "rememberable_user_password", with: "12345678"
125
+ check "Remember me"
126
+ click_button("Log in")
127
+
128
+ assert page.driver.browser.last_request.cookies['remember_rememberable_user_token']
129
+ end
130
+
131
+ test "normal users without rememberable strategy are not affected" do
132
+ create_full_user
133
+ visit new_user_session_path
134
+
135
+ assert_not page.has_content? "Remember me"
136
+
137
+ fill_in "user_email", with: "user@email.invalid"
138
+ fill_in "user_password", with: "12345678"
139
+ click_button("Log in")
140
+
141
+ assert_nil page.driver.browser.last_request.cookies['remember_rememberable_user_token']
142
+ end
143
+ end
@@ -2,7 +2,6 @@ require "test_helper"
2
2
  require "integration_tests_helper"
3
3
 
4
4
  class ResetTokenTest < ActionDispatch::IntegrationTest
5
-
6
5
  def setup
7
6
  # log in 1fa
8
7
  @user = enable_otp_and_sign_in
@@ -44,5 +43,4 @@ class ResetTokenTest < ActionDispatch::IntegrationTest
44
43
  assert_not_nil @user.otp_recovery_secret
45
44
  assert_not_equal @user.otp_recovery_secret, recovery_secret
46
45
  end
47
-
48
46
  end
@@ -4,6 +4,7 @@ require "integration_tests_helper"
4
4
  class SignInTest < ActionDispatch::IntegrationTest
5
5
  def teardown
6
6
  Capybara.reset_sessions!
7
+ Timecop.return
7
8
  end
8
9
 
9
10
  test "a new user should be able to sign in without using their token" do
@@ -57,6 +58,16 @@ class SignInTest < ActionDispatch::IntegrationTest
57
58
  assert page.has_content? "You need to type in the token you generated with your device."
58
59
  end
59
60
 
61
+ test "failed authentication should return a 422 'unprocessable entity' status" do
62
+ enable_otp_and_sign_in
63
+ assert_equal user_otp_credential_path, current_path
64
+
65
+ fill_in "token", with: "123456"
66
+ click_button "Submit Token"
67
+
68
+ assert_equal 422, page.status_code
69
+ end
70
+
60
71
  test "successful token authentication" do
61
72
  user = enable_otp_and_sign_in
62
73
 
@@ -66,18 +77,14 @@ class SignInTest < ActionDispatch::IntegrationTest
66
77
  assert_equal root_path, current_path
67
78
  end
68
79
 
69
- test "should fail if the the challenge times out" do
70
- old_timeout = User.otp_authentication_timeout
71
- User.otp_authentication_timeout = 1.second
72
-
80
+ test "should fail and redirect if the the challenge is expired" do
73
81
  user = enable_otp_and_sign_in
74
82
 
75
- sleep(2)
83
+ Timecop.travel(Time.now + 3.minutes)
76
84
 
77
85
  fill_in "token", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now)
78
86
  click_button "Submit Token"
79
87
 
80
- User.otp_authentication_timeout = old_timeout
81
88
  assert_equal new_user_session_path, current_path
82
89
  end
83
90
 
@@ -92,7 +99,7 @@ class SignInTest < ActionDispatch::IntegrationTest
92
99
  fill_in "token", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now)
93
100
  click_button "Submit Token"
94
101
 
95
- assert !page.has_content?("The token you provided was invalid.")
102
+ assert_not page.has_content?("The token you provided was invalid.")
96
103
  end
97
104
 
98
105
  test "invalid token flash message does not persist to successful authentication redirect." do
@@ -106,6 +113,6 @@ class SignInTest < ActionDispatch::IntegrationTest
106
113
  fill_in "token", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now)
107
114
  click_button "Submit Token"
108
115
 
109
- assert !page.has_content?("You need to type in the token you generated with your device.")
116
+ assert_not page.has_content?("You need to type in the token you generated with your device.")
110
117
  end
111
118
  end
@@ -2,7 +2,6 @@ require "test_helper"
2
2
  require "integration_tests_helper"
3
3
 
4
4
  class TrackableTest < ActionDispatch::IntegrationTest
5
-
6
5
  def setup
7
6
  @user = sign_user_in
8
7
 
@@ -46,5 +45,4 @@ class TrackableTest < ActionDispatch::IntegrationTest
46
45
  assert_not_equal @sign_in_count, @user.sign_in_count
47
46
  assert_not_equal @current_sign_in_at, @user.current_sign_in_at
48
47
  end
49
-
50
48
  end
@@ -27,6 +27,17 @@ class ActionDispatch::IntegrationTest
27
27
  end
28
28
  end
29
29
 
30
+ def create_lockable_user
31
+ @lockable_user ||= begin
32
+ lockable_user = LockableUser.create!(
33
+ email: "lockable-user@devise-otp.local",
34
+ password: "12345678",
35
+ password_confirmation: "12345678"
36
+ )
37
+ lockable_user
38
+ end
39
+ end
40
+
30
41
  def create_non_otp_user
31
42
  @non_otp_user ||= begin
32
43
  non_otp_user = NonOtpUser.create!(
@@ -38,6 +49,17 @@ class ActionDispatch::IntegrationTest
38
49
  end
39
50
  end
40
51
 
52
+ def create_rememberable_user
53
+ @rememberable_user ||= begin
54
+ rememberable_user = RememberableUser.create!(
55
+ email: "rememberable-user@email.invalid",
56
+ password: "12345678",
57
+ password_confirmation: "12345678"
58
+ )
59
+ rememberable_user
60
+ end
61
+ end
62
+
41
63
  def enable_otp_and_sign_in_with_otp
42
64
  enable_otp_and_sign_in.tap do |user|
43
65
  fill_in "token", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now)
@@ -89,5 +111,4 @@ class ActionDispatch::IntegrationTest
89
111
  page.has_content?("Log in") ? click_button("Log in") : click_button("Sign in")
90
112
  user
91
113
  end
92
-
93
114
  end
@@ -6,6 +6,10 @@ class OtpAuthenticatableTest < ActiveSupport::TestCase
6
6
  new_user
7
7
  end
8
8
 
9
+ def teardown
10
+ Timecop.return
11
+ end
12
+
9
13
  test "new users do not have a secret set" do
10
14
  user = User.first
11
15
 
@@ -15,7 +19,7 @@ class OtpAuthenticatableTest < ActiveSupport::TestCase
15
19
  end
16
20
 
17
21
  test "new users have OTP disabled by default" do
18
- assert !User.first.otp_enabled
22
+ assert_not User.first.otp_enabled
19
23
  end
20
24
 
21
25
  test "populating otp secrets should populate all required fields" do
@@ -42,8 +46,8 @@ class OtpAuthenticatableTest < ActiveSupport::TestCase
42
46
  user.enable_otp!
43
47
  user.generate_otp_challenge!
44
48
  user.update(
45
- :otp_failed_attempts => 1,
46
- :otp_recovery_counter => 1
49
+ otp_failed_attempts: 1,
50
+ otp_recovery_counter: 1
47
51
  )
48
52
 
49
53
 
@@ -75,7 +79,7 @@ class OtpAuthenticatableTest < ActiveSupport::TestCase
75
79
 
76
80
  u.reset_otp_persistence!
77
81
  assert(otp_auth_secret == u.otp_auth_secret)
78
- assert !(otp_persistence_seed == u.otp_persistence_seed)
82
+ assert_not (otp_persistence_seed == u.otp_persistence_seed)
79
83
  assert u.otp_enabled
80
84
  end
81
85
 
@@ -95,7 +99,8 @@ class OtpAuthenticatableTest < ActiveSupport::TestCase
95
99
  u.populate_otp_secrets!
96
100
  u.enable_otp!
97
101
  challenge = u.generate_otp_challenge!(1.second)
98
- sleep(2)
102
+
103
+ Timecop.travel(Time.now + 2)
99
104
 
100
105
  w = User.find_valid_otp_challenge(challenge)
101
106
  assert_nil w
@@ -106,7 +111,7 @@ class OtpAuthenticatableTest < ActiveSupport::TestCase
106
111
  u.populate_otp_secrets!
107
112
  u.enable_otp!
108
113
  challenge = u.generate_otp_challenge!(1.second)
109
- sleep(2)
114
+ Timecop.travel(Time.now + 2)
110
115
  assert_equal false, u.otp_challenge_valid?
111
116
  end
112
117
 
@@ -129,17 +134,49 @@ class OtpAuthenticatableTest < ActiveSupport::TestCase
129
134
  assert_equal true, u.validate_otp_token(token)
130
135
  end
131
136
 
132
- test "generated otp token, out of drift window, should be NOT valid for the user" do
137
+ test "generated otp token within the drift window should be valid for the user" do
133
138
  u = User.first
134
139
  u.populate_otp_secrets!
135
140
  u.enable_otp!
136
141
 
137
142
  secret = u.otp_auth_secret
143
+ token = ROTP::TOTP.new(secret).at(Time.now)
138
144
 
139
- [3.minutes.from_now, 3.minutes.ago].each do |time|
140
- token = ROTP::TOTP.new(secret).at(time)
141
- assert_equal false, u.valid_otp_token?(token)
142
- end
145
+ Timecop.freeze(Time.now + 90)
146
+ assert_equal true, u.valid_otp_token?(token)
147
+
148
+ Timecop.return
149
+ Timecop.freeze(Time.now - 90)
150
+ assert_equal true, u.valid_otp_token?(token)
151
+ end
152
+
153
+ test "generated otp token outside of drift window should NOT be valid for the user" do
154
+ # Since the otp_drift_window defines steps (not just time), and these steps
155
+ # begin at the 30 and 60 second marks of each minute, it is possible for
156
+ # an OTP token to be used up to 119 seconds after generation with a 3 step
157
+ # drift window. For example, a token generated at 12:00:00PM could be used
158
+ # within the timeframe for any of the following steps:
159
+ # - 12:00:00~12:00:29 (current step)
160
+ # - 12:00:30~12:00:59 (drift of 1 step)
161
+ # - 12:01:00~12:00:29 (drift of 2 steps)
162
+ # - 12:01:30~12:01:59 (drift of 3 steps)
163
+ #
164
+ # As a result, we need to test for 120 seconds to ensure that the test
165
+ # always passes.
166
+
167
+ u = User.first
168
+ u.populate_otp_secrets!
169
+ u.enable_otp!
170
+
171
+ secret = u.otp_auth_secret
172
+ token = ROTP::TOTP.new(secret).at(Time.now)
173
+
174
+ Timecop.freeze(Time.now + 120)
175
+ assert_equal false, u.valid_otp_token?(token)
176
+
177
+ Timecop.return
178
+ Timecop.freeze(Time.now - 120)
179
+ assert_equal false, u.valid_otp_token?(token)
143
180
  end
144
181
 
145
182
  test "recovery secrets should be valid, and valid only once" do
data/test/test_helper.rb CHANGED
@@ -4,6 +4,7 @@ require "dummy/config/environment"
4
4
  require "rails/test_help"
5
5
  require "capybara/rails"
6
6
  require "minitest/reporters"
7
+ require "timecop"
7
8
 
8
9
  Minitest::Reporters.use!
9
10