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.
- checksums.yaml +4 -4
- data/.erb_lint.yml +30 -0
- data/.github/workflows/ci.yml +4 -2
- data/.rubocop.yml +18 -0
- data/Appraisals +4 -17
- data/CHANGELOG.md +59 -1
- data/Gemfile +7 -1
- data/README.md +37 -14
- data/app/controllers/devise_otp/devise/otp_credentials_controller.rb +30 -7
- data/app/controllers/devise_otp/devise/otp_persistence_controller.rb +60 -0
- data/app/controllers/devise_otp/devise/otp_tokens_controller.rb +2 -37
- data/app/views/devise/otp_credentials/refresh.html.erb +6 -6
- data/app/views/devise/otp_credentials/show.html.erb +10 -13
- data/app/views/devise/otp_tokens/_token_secret.html.erb +9 -9
- data/app/views/devise/otp_tokens/_trusted_devices.html.erb +7 -7
- data/app/views/devise/otp_tokens/edit.html.erb +9 -9
- data/app/views/devise/otp_tokens/recovery.html.erb +8 -6
- data/app/views/devise/otp_tokens/show.html.erb +6 -6
- data/bin/appraisal +16 -0
- data/bin/erb_lint +16 -0
- data/bin/rake +16 -0
- data/bin/rubocop +16 -0
- data/config/locales/{en.yml → devise-otp.en.yml} +10 -8
- data/devise-otp.gemspec +2 -2
- data/gemfiles/rails_8.0.gemfile +5 -1
- data/gemfiles/{rails_7.1.gemfile → rails_8.1.gemfile} +7 -7
- data/lib/devise/strategies/database_authenticatable.rb +4 -17
- data/lib/devise-otp/version.rb +1 -1
- data/lib/devise-otp.rb +1 -2
- data/lib/devise_otp_authenticatable/controllers/helpers.rb +1 -2
- data/lib/devise_otp_authenticatable/controllers/public_helpers.rb +1 -2
- data/lib/devise_otp_authenticatable/controllers/url_helpers.rb +7 -2
- data/lib/devise_otp_authenticatable/hooks/refreshable.rb +0 -1
- data/lib/devise_otp_authenticatable/models/otp_authenticatable.rb +18 -12
- data/lib/devise_otp_authenticatable/routes.rb +7 -7
- data/test/dummy/app/assets/stylesheets/application.css +15 -1
- data/test/dummy/app/controllers/admin_posts_controller.rb +0 -1
- data/test/dummy/app/controllers/base_controller.rb +0 -2
- data/test/dummy/app/controllers/non_otp_posts_controller.rb +0 -1
- data/test/dummy/app/models/admin.rb +0 -1
- data/test/dummy/app/models/lockable_user.rb +8 -0
- data/test/dummy/app/models/non_otp_user.rb +0 -1
- data/test/dummy/app/models/rememberable_user.rb +8 -0
- data/test/dummy/app/views/layouts/application.html.erb +4 -2
- data/test/dummy/app/views/posts/show.html.erb +0 -1
- data/test/dummy/app/views/shared/_navbar.html.erb +23 -0
- data/test/dummy/config/application.rb +3 -0
- data/test/dummy/config/initializers/devise.rb +2 -2
- data/test/dummy/config/routes.rb +3 -0
- data/test/dummy/db/migrate/20250731000001_create_lockable_users.rb +9 -0
- data/test/dummy/db/migrate/20250731000002_add_devise_to_lockable_users.rb +52 -0
- data/test/dummy/db/migrate/20250731000003_devise_otp_add_to_lockable_users.rb +28 -0
- data/test/dummy/db/migrate/20250817221304_create_rememberable_users.rb +50 -0
- data/test/dummy/db/migrate/20250818030305_add_devise_otp_to_rememberable_users.rb +28 -0
- data/test/dummy/db/schema.rb +110 -44
- data/test/integration/disable_token_test.rb +0 -2
- data/test/integration/enable_otp_form_test.rb +12 -1
- data/test/integration/lockable_test.rb +143 -0
- data/test/integration/non_otp_user_models_test.rb +0 -2
- data/test/integration/otp_drift_test.rb +35 -0
- data/test/integration/persistence_test.rb +96 -5
- data/test/integration/refresh_test.rb +23 -14
- data/test/integration/rememberable_test.rb +143 -0
- data/test/integration/reset_token_test.rb +0 -2
- data/test/integration/sign_in_test.rb +15 -8
- data/test/integration/trackable_test.rb +0 -2
- data/test/integration_tests_helper.rb +22 -1
- data/test/models/otp_authenticatable_test.rb +48 -11
- data/test/test_helper.rb +1 -0
- metadata +26 -9
- 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
|
-
|
|
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
|
|
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
|
-
|
|
111
|
+
click_button("Trust this browser")
|
|
72
112
|
assert_text "Your browser is trusted."
|
|
73
113
|
sign_out
|
|
74
114
|
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
46
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|