devise-otp 0.6.0 → 0.8.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/.github/workflows/ci.yml +7 -10
- data/.gitignore +3 -1
- data/CHANGELOG.md +54 -8
- data/Gemfile +10 -0
- data/README.md +8 -2
- data/app/controllers/devise_otp/devise/otp_credentials_controller.rb +46 -27
- data/app/controllers/devise_otp/devise/otp_tokens_controller.rb +28 -10
- data/app/views/devise/otp_credentials/show.html.erb +6 -6
- data/app/views/devise/otp_tokens/_token_secret.html.erb +3 -4
- data/app/views/devise/otp_tokens/edit.html.erb +26 -0
- data/app/views/devise/otp_tokens/show.html.erb +7 -14
- data/config/locales/en.yml +23 -14
- data/devise-otp.gemspec +3 -13
- data/docs/QR_CODES.md +1 -40
- data/lib/devise/strategies/database_authenticatable.rb +64 -0
- data/lib/devise-otp/version.rb +1 -1
- data/lib/devise-otp.rb +31 -11
- data/lib/devise_otp_authenticatable/controllers/helpers.rb +9 -10
- data/lib/devise_otp_authenticatable/controllers/public_helpers.rb +39 -0
- data/lib/devise_otp_authenticatable/controllers/url_helpers.rb +10 -0
- data/lib/devise_otp_authenticatable/engine.rb +2 -5
- data/lib/devise_otp_authenticatable/hooks/refreshable.rb +5 -0
- data/lib/devise_otp_authenticatable/models/otp_authenticatable.rb +22 -20
- data/lib/devise_otp_authenticatable/routes.rb +3 -1
- data/lib/generators/active_record/templates/migration.rb +1 -1
- data/test/dummy/app/controllers/admin_posts_controller.rb +85 -0
- data/test/dummy/app/controllers/application_controller.rb +0 -1
- data/test/dummy/app/controllers/base_controller.rb +6 -0
- data/test/dummy/app/models/admin.rb +25 -0
- data/test/dummy/app/views/admin_posts/_form.html.erb +25 -0
- data/test/dummy/app/views/admin_posts/edit.html.erb +6 -0
- data/test/dummy/app/views/admin_posts/index.html.erb +25 -0
- data/test/dummy/app/views/admin_posts/new.html.erb +5 -0
- data/test/dummy/app/views/admin_posts/show.html.erb +15 -0
- data/test/dummy/app/views/base/home.html.erb +1 -0
- data/test/dummy/config/application.rb +0 -2
- data/test/dummy/config/routes.rb +4 -1
- data/test/dummy/db/migrate/20240604000001_create_admins.rb +9 -0
- data/test/dummy/db/migrate/20240604000002_add_devise_to_admins.rb +52 -0
- data/test/dummy/db/migrate/20240604000003_devise_otp_add_to_admins.rb +28 -0
- data/test/integration/disable_token_test.rb +53 -0
- data/test/integration/enable_otp_form_test.rb +57 -0
- data/test/integration/persistence_test.rb +3 -6
- data/test/integration/refresh_test.rb +32 -0
- data/test/integration/reset_token_test.rb +45 -0
- data/test/integration/sign_in_test.rb +10 -14
- data/test/integration/trackable_test.rb +50 -0
- data/test/integration_tests_helper.rb +24 -6
- data/test/models/otp_authenticatable_test.rb +62 -27
- data/test/orm/active_record.rb +6 -1
- data/test/test_helper.rb +1 -71
- metadata +26 -135
- data/lib/devise_otp_authenticatable/hooks/sessions.rb +0 -58
- data/lib/devise_otp_authenticatable/hooks.rb +0 -11
- data/test/integration/token_test.rb +0 -30
@@ -0,0 +1,28 @@
|
|
1
|
+
class DeviseOtpAddToAdmins < ActiveRecord::Migration[5.0]
|
2
|
+
def self.up
|
3
|
+
change_table :admins do |t|
|
4
|
+
t.string :otp_auth_secret
|
5
|
+
t.string :otp_recovery_secret
|
6
|
+
t.boolean :otp_enabled, default: false, null: false
|
7
|
+
t.boolean :otp_mandatory, default: false, null: false
|
8
|
+
t.datetime :otp_enabled_on
|
9
|
+
t.integer :otp_time_drift, default: 0, null: false
|
10
|
+
t.integer :otp_failed_attempts, default: 0, null: false
|
11
|
+
t.integer :otp_recovery_counter, default: 0, null: false
|
12
|
+
t.string :otp_persistence_seed
|
13
|
+
|
14
|
+
t.string :otp_session_challenge
|
15
|
+
t.datetime :otp_challenge_expires
|
16
|
+
end
|
17
|
+
|
18
|
+
add_index :admins, :otp_session_challenge, unique: true
|
19
|
+
add_index :admins, :otp_challenge_expires
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.down
|
23
|
+
change_table :admins do |t|
|
24
|
+
t.remove :otp_auth_secret, :otp_recovery_secret, :otp_enabled, :otp_mandatory, :otp_enabled_on, :otp_session_challenge,
|
25
|
+
:otp_challenge_expires, :otp_time_drift, :otp_failed_attempts, :otp_recovery_counter, :otp_persistence_seed
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
require "integration_tests_helper"
|
3
|
+
|
4
|
+
class DisableTokenTest < ActionDispatch::IntegrationTest
|
5
|
+
|
6
|
+
def setup
|
7
|
+
# log in 1fa
|
8
|
+
@user = enable_otp_and_sign_in
|
9
|
+
assert_equal user_otp_credential_path, current_path
|
10
|
+
|
11
|
+
# otp 2fa
|
12
|
+
fill_in "token", with: ROTP::TOTP.new(@user.otp_auth_secret).at(Time.now)
|
13
|
+
click_button "Submit Token"
|
14
|
+
assert_equal root_path, current_path
|
15
|
+
end
|
16
|
+
|
17
|
+
def teardown
|
18
|
+
Capybara.reset_sessions!
|
19
|
+
end
|
20
|
+
|
21
|
+
test "disabling OTP after successfully enabling" do
|
22
|
+
# disable OTP
|
23
|
+
disable_otp
|
24
|
+
|
25
|
+
assert page.has_content? "Disabled"
|
26
|
+
|
27
|
+
# logout
|
28
|
+
sign_out
|
29
|
+
|
30
|
+
# log back in 1fa
|
31
|
+
sign_user_in(@user)
|
32
|
+
|
33
|
+
assert_equal root_path, current_path
|
34
|
+
end
|
35
|
+
|
36
|
+
test "disabling OTP does not reset token secrets" do
|
37
|
+
# get otp secrets
|
38
|
+
@user.reload
|
39
|
+
auth_secret = @user.otp_auth_secret
|
40
|
+
recovery_secret = @user.otp_recovery_secret
|
41
|
+
|
42
|
+
# disable OTP
|
43
|
+
disable_otp
|
44
|
+
|
45
|
+
# compare otp secrets
|
46
|
+
assert_not_nil @user.otp_auth_secret
|
47
|
+
assert_equal @user.otp_auth_secret, auth_secret
|
48
|
+
|
49
|
+
assert_not_nil @user.otp_recovery_secret
|
50
|
+
assert_equal @user.otp_recovery_secret, recovery_secret
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
require "integration_tests_helper"
|
3
|
+
|
4
|
+
class EnableOtpFormTest < ActionDispatch::IntegrationTest
|
5
|
+
def teardown
|
6
|
+
Capybara.reset_sessions!
|
7
|
+
end
|
8
|
+
|
9
|
+
test "a user should be able enable their OTP authentication by entering a confirmation code" do
|
10
|
+
user = sign_user_in
|
11
|
+
|
12
|
+
visit edit_user_otp_token_path
|
13
|
+
|
14
|
+
user.reload
|
15
|
+
|
16
|
+
fill_in "confirmation_code", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now)
|
17
|
+
|
18
|
+
click_button "Continue..."
|
19
|
+
|
20
|
+
assert_equal user_otp_token_path, current_path
|
21
|
+
assert page.has_content?("Enabled")
|
22
|
+
|
23
|
+
user.reload
|
24
|
+
assert user.otp_enabled?
|
25
|
+
end
|
26
|
+
|
27
|
+
test "a user should not be able enable their OTP authentication with an incorrect confirmation code" do
|
28
|
+
user = sign_user_in
|
29
|
+
|
30
|
+
visit edit_user_otp_token_path
|
31
|
+
|
32
|
+
fill_in "confirmation_code", with: "123456"
|
33
|
+
|
34
|
+
click_button "Continue..."
|
35
|
+
|
36
|
+
assert page.has_content?("To Enable Two-Factor Authentication")
|
37
|
+
|
38
|
+
user.reload
|
39
|
+
assert_not user.otp_enabled?
|
40
|
+
end
|
41
|
+
|
42
|
+
test "a user should not be able enable their OTP authentication with a blank confirmation code" do
|
43
|
+
user = sign_user_in
|
44
|
+
|
45
|
+
visit edit_user_otp_token_path
|
46
|
+
|
47
|
+
fill_in "confirmation_code", with: ""
|
48
|
+
|
49
|
+
click_button "Continue..."
|
50
|
+
|
51
|
+
assert page.has_content?("To Enable Two-Factor Authentication")
|
52
|
+
|
53
|
+
user.reload
|
54
|
+
assert_not user.otp_enabled?
|
55
|
+
end
|
56
|
+
|
57
|
+
end
|
@@ -51,13 +51,10 @@ class PersistenceTest < ActionDispatch::IntegrationTest
|
|
51
51
|
visit user_otp_token_path
|
52
52
|
assert_equal user_otp_token_path, current_path
|
53
53
|
|
54
|
-
|
54
|
+
click_link("Download recovery codes")
|
55
55
|
|
56
|
-
|
57
|
-
|
58
|
-
end
|
59
|
-
|
60
|
-
assert_equal 1, DownloadHelper.downloads.size
|
56
|
+
assert current_path.match?(/recovery\.text/)
|
57
|
+
assert page.body.match?(user.next_otp_recovery_tokens.values.join("\n"))
|
61
58
|
end
|
62
59
|
|
63
60
|
test "trusted status should expire" do
|
@@ -5,10 +5,12 @@ class RefreshTest < ActionDispatch::IntegrationTest
|
|
5
5
|
def setup
|
6
6
|
@old_refresh = User.otp_credentials_refresh
|
7
7
|
User.otp_credentials_refresh = 1.second
|
8
|
+
Admin.otp_credentials_refresh = 1.second
|
8
9
|
end
|
9
10
|
|
10
11
|
def teardown
|
11
12
|
User.otp_credentials_refresh = @old_refresh
|
13
|
+
Admin.otp_credentials_refresh = @old_refresh
|
12
14
|
Capybara.reset_sessions!
|
13
15
|
end
|
14
16
|
|
@@ -72,4 +74,34 @@ class RefreshTest < ActionDispatch::IntegrationTest
|
|
72
74
|
|
73
75
|
assert_equal user_otp_token_path, current_path
|
74
76
|
end
|
77
|
+
|
78
|
+
test "works for non-default warden scopes" do
|
79
|
+
admin = create_full_admin
|
80
|
+
|
81
|
+
admin.populate_otp_secrets!
|
82
|
+
admin.enable_otp!
|
83
|
+
|
84
|
+
visit new_admin_session_path
|
85
|
+
fill_in "admin_email", with: admin.email
|
86
|
+
fill_in "admin_password", with: admin.password
|
87
|
+
|
88
|
+
page.has_content?("Log in") ? click_button("Log in") : click_button("Sign in")
|
89
|
+
|
90
|
+
assert_equal admin_otp_credential_path, current_path
|
91
|
+
|
92
|
+
fill_in "token", with: ROTP::TOTP.new(admin.otp_auth_secret).at(Time.now)
|
93
|
+
click_button "Submit Token"
|
94
|
+
assert_equal "/", current_path
|
95
|
+
|
96
|
+
sleep(2)
|
97
|
+
|
98
|
+
visit admin_otp_token_path
|
99
|
+
assert_equal refresh_admin_otp_credential_path, current_path
|
100
|
+
|
101
|
+
fill_in "admin_refresh_password", with: "12345678"
|
102
|
+
click_button "Continue..."
|
103
|
+
|
104
|
+
assert_equal admin_otp_token_path, current_path
|
105
|
+
end
|
106
|
+
|
75
107
|
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
require "integration_tests_helper"
|
3
|
+
|
4
|
+
class ResetTokenTest < ActionDispatch::IntegrationTest
|
5
|
+
|
6
|
+
def setup
|
7
|
+
# log in 1fa
|
8
|
+
@user = enable_otp_and_sign_in
|
9
|
+
assert_equal user_otp_credential_path, current_path
|
10
|
+
|
11
|
+
# otp 2fa
|
12
|
+
fill_in "token", with: ROTP::TOTP.new(@user.otp_auth_secret).at(Time.now)
|
13
|
+
click_button "Submit Token"
|
14
|
+
assert_equal root_path, current_path
|
15
|
+
end
|
16
|
+
|
17
|
+
|
18
|
+
def teardown
|
19
|
+
Capybara.reset_sessions!
|
20
|
+
end
|
21
|
+
|
22
|
+
test "redirects to otp_tokens#edit page" do
|
23
|
+
reset_otp
|
24
|
+
|
25
|
+
assert_equal "/users/otp/token/edit", current_path
|
26
|
+
end
|
27
|
+
|
28
|
+
test "generates new token secrets" do
|
29
|
+
# get auth secrets
|
30
|
+
auth_secret = @user.otp_auth_secret
|
31
|
+
recovery_secret = @user.otp_recovery_secret
|
32
|
+
|
33
|
+
# reset otp
|
34
|
+
reset_otp
|
35
|
+
|
36
|
+
# compare auth secrets
|
37
|
+
@user.reload
|
38
|
+
assert_not_nil @user.otp_auth_secret
|
39
|
+
assert_not_equal @user.otp_auth_secret, auth_secret
|
40
|
+
|
41
|
+
assert_not_nil @user.otp_recovery_secret
|
42
|
+
assert_not_equal @user.otp_recovery_secret, recovery_secret
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
@@ -17,20 +17,16 @@ class SignInTest < ActionDispatch::IntegrationTest
|
|
17
17
|
assert_equal posts_path, current_path
|
18
18
|
end
|
19
19
|
|
20
|
-
test "a new user, just signed in, should be able to
|
20
|
+
test "a new user, just signed in, should be able to see and click the 'Enable Two-Factor Authentication' link" do
|
21
21
|
user = sign_user_in
|
22
22
|
|
23
23
|
visit user_otp_token_path
|
24
|
-
assert
|
24
|
+
assert page.has_content?("Disabled")
|
25
25
|
|
26
|
-
|
27
|
-
click_button "Continue..."
|
26
|
+
click_link "Enable Two-Factor Authentication"
|
28
27
|
|
29
|
-
|
30
|
-
|
31
|
-
assert page.has_content?("Your token secret")
|
32
|
-
assert !user.otp_auth_secret.nil?
|
33
|
-
assert !user.otp_persistence_seed.nil?
|
28
|
+
assert page.has_content?("Enable Two-Factor Authentication")
|
29
|
+
assert_equal edit_user_otp_token_path, current_path
|
34
30
|
end
|
35
31
|
|
36
32
|
test "a new user should be able to sign in enable OTP and be prompted for their token" do
|
@@ -43,17 +39,17 @@ class SignInTest < ActionDispatch::IntegrationTest
|
|
43
39
|
enable_otp_and_sign_in
|
44
40
|
assert_equal user_otp_credential_path, current_path
|
45
41
|
|
46
|
-
fill_in "
|
42
|
+
fill_in "token", with: "123456"
|
47
43
|
click_button "Submit Token"
|
48
44
|
|
49
|
-
assert_equal
|
45
|
+
assert_equal user_otp_credential_path, current_path
|
50
46
|
end
|
51
47
|
|
52
48
|
test "fail blank token authentication" do
|
53
49
|
enable_otp_and_sign_in
|
54
50
|
assert_equal user_otp_credential_path, current_path
|
55
51
|
|
56
|
-
fill_in "
|
52
|
+
fill_in "token", with: ""
|
57
53
|
click_button "Submit Token"
|
58
54
|
|
59
55
|
assert_equal user_otp_credential_path, current_path
|
@@ -62,7 +58,7 @@ class SignInTest < ActionDispatch::IntegrationTest
|
|
62
58
|
test "successful token authentication" do
|
63
59
|
user = enable_otp_and_sign_in
|
64
60
|
|
65
|
-
fill_in "
|
61
|
+
fill_in "token", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now)
|
66
62
|
click_button "Submit Token"
|
67
63
|
|
68
64
|
assert_equal root_path, current_path
|
@@ -76,7 +72,7 @@ class SignInTest < ActionDispatch::IntegrationTest
|
|
76
72
|
|
77
73
|
sleep(2)
|
78
74
|
|
79
|
-
fill_in "
|
75
|
+
fill_in "token", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now)
|
80
76
|
click_button "Submit Token"
|
81
77
|
|
82
78
|
User.otp_authentication_timeout = old_timeout
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
require "integration_tests_helper"
|
3
|
+
|
4
|
+
class TrackableTest < ActionDispatch::IntegrationTest
|
5
|
+
|
6
|
+
def setup
|
7
|
+
@user = sign_user_in
|
8
|
+
|
9
|
+
@user.reload
|
10
|
+
|
11
|
+
@sign_in_count = @user.sign_in_count
|
12
|
+
@current_sign_in_at = @user.current_sign_in_at
|
13
|
+
|
14
|
+
sign_out
|
15
|
+
end
|
16
|
+
|
17
|
+
def teardown
|
18
|
+
Capybara.reset_sessions!
|
19
|
+
end
|
20
|
+
|
21
|
+
test "if otp is disabled, it should update devise trackable fields as usual when the user signs in" do
|
22
|
+
sign_user_in(@user)
|
23
|
+
|
24
|
+
@user.reload
|
25
|
+
|
26
|
+
assert_not_equal @sign_in_count, @user.sign_in_count
|
27
|
+
assert_not_equal @current_sign_in_at, @user.current_sign_in_at
|
28
|
+
end
|
29
|
+
|
30
|
+
test "if otp is enabled, it should not update devise trackable fields until user enters their user token to complete their sign in" do
|
31
|
+
@user.populate_otp_secrets!
|
32
|
+
@user.enable_otp!
|
33
|
+
|
34
|
+
sign_user_in(@user)
|
35
|
+
|
36
|
+
@user.reload
|
37
|
+
|
38
|
+
assert_equal @sign_in_count, @user.sign_in_count
|
39
|
+
assert_equal @current_sign_in_at, @user.current_sign_in_at
|
40
|
+
|
41
|
+
fill_in "token", with: ROTP::TOTP.new(@user.otp_auth_secret).at(Time.now)
|
42
|
+
click_button "Submit Token"
|
43
|
+
|
44
|
+
@user.reload
|
45
|
+
|
46
|
+
assert_not_equal @sign_in_count, @user.sign_in_count
|
47
|
+
assert_not_equal @current_sign_in_at, @user.current_sign_in_at
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
@@ -16,18 +16,31 @@ class ActionDispatch::IntegrationTest
|
|
16
16
|
end
|
17
17
|
end
|
18
18
|
|
19
|
+
def create_full_admin
|
20
|
+
@admin ||= begin
|
21
|
+
admin = Admin.create!(
|
22
|
+
email: "admin@email.invalid",
|
23
|
+
password: "12345678",
|
24
|
+
password_confirmation: "12345678"
|
25
|
+
)
|
26
|
+
admin
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
19
30
|
def enable_otp_and_sign_in_with_otp
|
20
31
|
enable_otp_and_sign_in.tap do |user|
|
21
|
-
fill_in "
|
32
|
+
fill_in "token", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now)
|
22
33
|
click_button "Submit Token"
|
23
34
|
end
|
24
35
|
end
|
25
36
|
|
26
37
|
def enable_otp_and_sign_in
|
27
38
|
user = create_full_user
|
39
|
+
user.populate_otp_secrets!
|
40
|
+
|
28
41
|
sign_user_in(user)
|
29
|
-
visit
|
30
|
-
|
42
|
+
visit edit_user_otp_token_path
|
43
|
+
fill_in "confirmation_code", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now)
|
31
44
|
click_button "Continue..."
|
32
45
|
|
33
46
|
Capybara.reset_sessions!
|
@@ -37,14 +50,18 @@ class ActionDispatch::IntegrationTest
|
|
37
50
|
end
|
38
51
|
|
39
52
|
def otp_challenge_for(user)
|
40
|
-
fill_in "
|
53
|
+
fill_in "token", with: ROTP::TOTP.new(user.otp_auth_secret).at(Time.now)
|
41
54
|
click_button "Submit Token"
|
42
55
|
end
|
43
56
|
|
44
57
|
def disable_otp
|
45
58
|
visit user_otp_token_path
|
46
|
-
|
47
|
-
|
59
|
+
click_button "Disable Two-Factor Authentication"
|
60
|
+
end
|
61
|
+
|
62
|
+
def reset_otp
|
63
|
+
visit user_otp_token_path
|
64
|
+
click_button "Reset Token Secret"
|
48
65
|
end
|
49
66
|
|
50
67
|
def sign_out
|
@@ -61,4 +78,5 @@ class ActionDispatch::IntegrationTest
|
|
61
78
|
page.has_content?("Log in") ? click_button("Log in") : click_button("Sign in")
|
62
79
|
user
|
63
80
|
end
|
81
|
+
|
64
82
|
end
|
@@ -6,42 +6,70 @@ class OtpAuthenticatableTest < ActiveSupport::TestCase
|
|
6
6
|
new_user
|
7
7
|
end
|
8
8
|
|
9
|
-
test "new users have a
|
10
|
-
|
9
|
+
test "new users do not have a secret set" do
|
10
|
+
user = User.first
|
11
|
+
|
12
|
+
[:otp_auth_secret, :otp_recovery_secret, :otp_persistence_seed].each do |field|
|
13
|
+
assert_nil user.send(field)
|
14
|
+
end
|
11
15
|
end
|
12
16
|
|
13
17
|
test "new users have OTP disabled by default" do
|
14
18
|
assert !User.first.otp_enabled
|
15
19
|
end
|
16
20
|
|
17
|
-
test "
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
+
test "populating otp secrets should populate all required fields" do
|
22
|
+
user = User.first
|
23
|
+
user.populate_otp_secrets!
|
24
|
+
|
25
|
+
[:otp_auth_secret, :otp_recovery_secret, :otp_persistence_seed].each do |field|
|
26
|
+
assert_not_nil user.send(field)
|
27
|
+
end
|
21
28
|
end
|
22
29
|
|
23
|
-
test "
|
24
|
-
|
25
|
-
|
30
|
+
test "time_based_otp and recover_otp fields should be an instance of TOTP/ROTP objects" do
|
31
|
+
user = User.first
|
32
|
+
user.populate_otp_secrets!
|
33
|
+
|
34
|
+
assert user.time_based_otp.is_a? ROTP::TOTP
|
35
|
+
assert user.recovery_otp.is_a? ROTP::HOTP
|
26
36
|
end
|
27
37
|
|
28
|
-
test "
|
29
|
-
|
30
|
-
|
31
|
-
assert u.otp_enabled
|
32
|
-
otp_auth_secret = u.otp_auth_secret
|
33
|
-
otp_persistence_seed = u.otp_persistence_seed
|
38
|
+
test "clear_otp_fields should clear all otp fields" do
|
39
|
+
user = User.first
|
40
|
+
user.populate_otp_secrets!
|
34
41
|
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
42
|
+
user.enable_otp!
|
43
|
+
user.generate_otp_challenge!
|
44
|
+
user.update(
|
45
|
+
:otp_failed_attempts => 1,
|
46
|
+
:otp_recovery_counter => 1
|
47
|
+
)
|
48
|
+
|
49
|
+
|
50
|
+
assert user.otp_enabled
|
51
|
+
[:otp_auth_secret, :otp_recovery_secret, :otp_persistence_seed].each do |field|
|
52
|
+
assert_not_nil user.send(field)
|
53
|
+
end
|
54
|
+
[:otp_failed_attempts, :otp_recovery_counter].each do |field|
|
55
|
+
assert_not user.send(field) == 0
|
56
|
+
end
|
57
|
+
|
58
|
+
user.clear_otp_fields!
|
59
|
+
[:otp_auth_secret, :otp_recovery_secret, :otp_persistence_seed].each do |field|
|
60
|
+
assert_nil user.send(field)
|
61
|
+
end
|
62
|
+
[:otp_failed_attempts, :otp_recovery_counter].each do |field|
|
63
|
+
assert user.send(field) == 0
|
64
|
+
end
|
39
65
|
end
|
40
66
|
|
41
67
|
test "reset_otp_persistence should generate new persistence_seed but NOT change the otp_auth_secret" do
|
42
68
|
u = User.first
|
43
|
-
u.
|
69
|
+
u.populate_otp_secrets!
|
70
|
+
u.enable_otp!
|
44
71
|
assert u.otp_enabled
|
72
|
+
|
45
73
|
otp_auth_secret = u.otp_auth_secret
|
46
74
|
otp_persistence_seed = u.otp_persistence_seed
|
47
75
|
|
@@ -53,7 +81,8 @@ class OtpAuthenticatableTest < ActiveSupport::TestCase
|
|
53
81
|
|
54
82
|
test "generating a challenge, should retrieve the user later" do
|
55
83
|
u = User.first
|
56
|
-
u.
|
84
|
+
u.populate_otp_secrets!
|
85
|
+
u.enable_otp!
|
57
86
|
challenge = u.generate_otp_challenge!
|
58
87
|
|
59
88
|
w = User.find_valid_otp_challenge(challenge)
|
@@ -63,7 +92,8 @@ class OtpAuthenticatableTest < ActiveSupport::TestCase
|
|
63
92
|
|
64
93
|
test "expiring the challenge, should retrieve nothing" do
|
65
94
|
u = User.first
|
66
|
-
u.
|
95
|
+
u.populate_otp_secrets!
|
96
|
+
u.enable_otp!
|
67
97
|
challenge = u.generate_otp_challenge!(1.second)
|
68
98
|
sleep(2)
|
69
99
|
|
@@ -73,7 +103,8 @@ class OtpAuthenticatableTest < ActiveSupport::TestCase
|
|
73
103
|
|
74
104
|
test "expired challenges should not be valid" do
|
75
105
|
u = User.first
|
76
|
-
u.
|
106
|
+
u.populate_otp_secrets!
|
107
|
+
u.enable_otp!
|
77
108
|
challenge = u.generate_otp_challenge!(1.second)
|
78
109
|
sleep(2)
|
79
110
|
assert_equal false, u.otp_challenge_valid?
|
@@ -81,14 +112,16 @@ class OtpAuthenticatableTest < ActiveSupport::TestCase
|
|
81
112
|
|
82
113
|
test "null otp challenge" do
|
83
114
|
u = User.first
|
84
|
-
u.
|
115
|
+
u.populate_otp_secrets!
|
116
|
+
u.enable_otp!
|
85
117
|
assert_equal false, u.validate_otp_token("")
|
86
118
|
assert_equal false, u.validate_otp_token(nil)
|
87
119
|
end
|
88
120
|
|
89
121
|
test "generated otp token should be valid for the user" do
|
90
122
|
u = User.first
|
91
|
-
u.
|
123
|
+
u.populate_otp_secrets!
|
124
|
+
u.enable_otp!
|
92
125
|
|
93
126
|
secret = u.otp_auth_secret
|
94
127
|
token = ROTP::TOTP.new(secret).now
|
@@ -98,7 +131,8 @@ class OtpAuthenticatableTest < ActiveSupport::TestCase
|
|
98
131
|
|
99
132
|
test "generated otp token, out of drift window, should be NOT valid for the user" do
|
100
133
|
u = User.first
|
101
|
-
u.
|
134
|
+
u.populate_otp_secrets!
|
135
|
+
u.enable_otp!
|
102
136
|
|
103
137
|
secret = u.otp_auth_secret
|
104
138
|
|
@@ -110,7 +144,8 @@ class OtpAuthenticatableTest < ActiveSupport::TestCase
|
|
110
144
|
|
111
145
|
test "recovery secrets should be valid, and valid only once" do
|
112
146
|
u = User.first
|
113
|
-
u.
|
147
|
+
u.populate_otp_secrets!
|
148
|
+
u.enable_otp!
|
114
149
|
recovery = u.next_otp_recovery_tokens
|
115
150
|
|
116
151
|
assert u.valid_otp_recovery_token? recovery.fetch(0)
|
data/test/orm/active_record.rb
CHANGED
@@ -3,4 +3,9 @@ ActiveRecord::Base.logger = Logger.new(nil)
|
|
3
3
|
|
4
4
|
migrations_path = File.expand_path("../../dummy/db/migrate/", __FILE__)
|
5
5
|
|
6
|
-
|
6
|
+
if Rails.version.to_f >= 7.2
|
7
|
+
ActiveRecord::MigrationContext.new(migrations_path).migrate
|
8
|
+
else
|
9
|
+
# To support order versions of Rails (pre v7.2)
|
10
|
+
ActiveRecord::MigrationContext.new(migrations_path, ActiveRecord::SchemaMigration).migrate
|
11
|
+
end
|
data/test/test_helper.rb
CHANGED
@@ -6,86 +6,16 @@ require "dummy/config/environment"
|
|
6
6
|
require "orm/#{DEVISE_ORM}"
|
7
7
|
require "rails/test_help"
|
8
8
|
require "capybara/rails"
|
9
|
-
require "capybara/cuprite"
|
10
9
|
require "minitest/reporters"
|
11
10
|
|
12
|
-
|
11
|
+
Minitest::Reporters.use!
|
13
12
|
|
14
13
|
# I18n.load_path << File.expand_path("../support/locale/en.yml", __FILE__) if DEVISE_ORM == :mongoid
|
15
14
|
|
16
15
|
# ActiveSupport::Deprecation.silenced = true
|
17
16
|
|
18
|
-
# Use a module to not pollute the global namespace
|
19
|
-
module CapybaraHelper
|
20
|
-
def self.register_driver(driver_name, args = [])
|
21
|
-
opts = {headless: true, js_errors: true, window_size: [1920, 1200], browser_options: {}}
|
22
|
-
args.each do |arg|
|
23
|
-
opts[:browser_options][arg] = nil
|
24
|
-
end
|
25
|
-
|
26
|
-
Capybara.register_driver(driver_name) do |app|
|
27
|
-
Capybara::Cuprite::Driver.new(app, opts)
|
28
|
-
end
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
|
-
# Register our own custom drivers
|
33
|
-
CapybaraHelper.register_driver(:headless_chrome, %w[disable-gpu no-sandbox disable-dev-shm-usage])
|
34
|
-
|
35
|
-
# Configure Capybara JS driver
|
36
|
-
Capybara.current_driver = :headless_chrome
|
37
|
-
Capybara.javascript_driver = :headless_chrome
|
38
|
-
|
39
|
-
# Configure Capybara server
|
40
|
-
Capybara.run_server = true
|
41
|
-
Capybara.server = :puma, {Silent: true}
|
42
|
-
|
43
17
|
class ActionDispatch::IntegrationTest
|
44
18
|
include Capybara::DSL
|
45
|
-
|
46
|
-
# What capybara calls a "page" in its DSL is actually a Capybara::Session
|
47
|
-
# and doesn't know about the *command* method that allows us to play with
|
48
|
-
# the Chrome API.
|
49
|
-
# See: https://rubydoc.info/github/jnicklas/capybara/master/Capybara/Session
|
50
|
-
#
|
51
|
-
# To enable downloads we need to do it on the browser's page object, so fetch it
|
52
|
-
# from this long method chain.
|
53
|
-
# See: https://github.com/rubycdp/ferrum/blob/master/lib/ferrum/page.rb
|
54
|
-
def enable_chrome_headless_downloads(session, directory)
|
55
|
-
page = session.driver.browser.page
|
56
|
-
page.command("Page.setDownloadBehavior", behavior: "allow", downloadPath: directory)
|
57
|
-
end
|
58
|
-
end
|
59
|
-
|
60
|
-
# From https://collectiveidea.com/blog/archives/2012/01/27/testing-file-downloads-with-capybara-and-chromedriver
|
61
|
-
module DownloadHelper
|
62
|
-
extend self
|
63
|
-
|
64
|
-
TIMEOUT = 10
|
65
|
-
|
66
|
-
def downloads
|
67
|
-
Dir["/tmp/devise-otp/*"]
|
68
|
-
end
|
69
|
-
|
70
|
-
def wait_for_download(count: 1)
|
71
|
-
yield if block_given?
|
72
|
-
|
73
|
-
Timeout.timeout(TIMEOUT) do
|
74
|
-
sleep 0.2 until downloaded?(count)
|
75
|
-
end
|
76
|
-
end
|
77
|
-
|
78
|
-
def downloaded?(count)
|
79
|
-
!downloading? && downloads.size == count
|
80
|
-
end
|
81
|
-
|
82
|
-
def downloading?
|
83
|
-
downloads.grep(/\.crdownload$/).any?
|
84
|
-
end
|
85
|
-
|
86
|
-
def clear_downloads
|
87
|
-
FileUtils.rm_f(downloads)
|
88
|
-
end
|
89
19
|
end
|
90
20
|
|
91
21
|
require "devise-otp"
|