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
@@ -0,0 +1,52 @@
1
+ class AddDeviseToLockableUsers < ActiveRecord::Migration[5.0]
2
+ def self.up
3
+ change_table(:lockable_users) do |t|
4
+ ## Database authenticatable
5
+ t.string :email, null: false, default: ""
6
+ t.string :encrypted_password, null: false, default: ""
7
+
8
+ ## Recoverable
9
+ t.string :reset_password_token
10
+ t.datetime :reset_password_sent_at
11
+
12
+ ## Rememberable
13
+ t.datetime :remember_created_at
14
+
15
+ ## Trackable
16
+ t.integer :sign_in_count, default: 0
17
+ t.datetime :current_sign_in_at
18
+ t.datetime :last_sign_in_at
19
+ t.string :current_sign_in_ip
20
+ t.string :last_sign_in_ip
21
+
22
+ ## Confirmable
23
+ # t.string :confirmation_token
24
+ # t.datetime :confirmed_at
25
+ # t.datetime :confirmation_sent_at
26
+ # t.string :unconfirmed_email # Only if using reconfirmable
27
+
28
+ ## Lockable
29
+ t.integer :failed_attempts, default: 0 # Only if lock strategy is :failed_attempts
30
+ t.string :unlock_token # Only if unlock strategy is :email or :both
31
+ t.datetime :locked_at
32
+
33
+ ## Token authenticatable
34
+ t.string :authentication_token
35
+
36
+ # Uncomment below if timestamps were not included in your original model.
37
+ # t.timestamps
38
+ end
39
+
40
+ add_index :lockable_users, :email, unique: true
41
+ add_index :lockable_users, :reset_password_token, unique: true
42
+ # add_index :lockable_users, :confirmation_token, :unique => true
43
+ add_index :lockable_users, :unlock_token, unique: true
44
+ add_index :lockable_users, :authentication_token, unique: true
45
+ end
46
+
47
+ def self.down
48
+ # By default, we don't want to make any assumption about how to roll back a migration when your
49
+ # model already existed. Please edit below which fields you would like to remove in this migration.
50
+ raise ActiveRecord::IrreversibleMigration
51
+ end
52
+ end
@@ -0,0 +1,28 @@
1
+ class DeviseOtpAddToLockableUsers < ActiveRecord::Migration[5.0]
2
+ def self.up
3
+ change_table :lockable_users 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 :lockable_users, :otp_session_challenge, unique: true
19
+ add_index :lockable_users, :otp_challenge_expires
20
+ end
21
+
22
+ def self.down
23
+ change_table :lockable_users 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,50 @@
1
+ class CreateRememberableUsers < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :rememberable_users do |t|
4
+ t.string :name
5
+
6
+ ## Database authenticatable
7
+ t.string :email, null: false, default: ""
8
+ t.string :encrypted_password, null: false, default: ""
9
+
10
+ ## Recoverable
11
+ t.string :reset_password_token
12
+ t.datetime :reset_password_sent_at
13
+
14
+ ## Rememberable
15
+ t.datetime :remember_created_at
16
+
17
+ ## Trackable
18
+ t.integer :sign_in_count, default: 0
19
+ t.datetime :current_sign_in_at
20
+ t.datetime :last_sign_in_at
21
+ t.string :current_sign_in_ip
22
+ t.string :last_sign_in_ip
23
+
24
+ ## Confirmable
25
+ # t.string :confirmation_token
26
+ # t.datetime :confirmed_at
27
+ # t.datetime :confirmation_sent_at
28
+ # t.string :unconfirmed_email # Only if using reconfirmable
29
+
30
+ ## Lockable
31
+ # t.integer :failed_attempts, default: 0 # Only if lock strategy is :failed_attempts
32
+ # t.string :unlock_token # Only if unlock strategy is :email or :both
33
+ # t.datetime :locked_at
34
+
35
+ ## Token authenticatable
36
+ # t.string :authentication_token
37
+
38
+ # Uncomment below if timestamps were not included in your original model.
39
+ # t.timestamps
40
+ end
41
+
42
+ add_index :rememberable_users, :email, unique: true
43
+ add_index :rememberable_users, :reset_password_token, unique: true
44
+ # add_index :rememberable_users, :confirmation_token, :unique => true
45
+ # add_index :rememberable_users, :unlock_token, unique: true
46
+ # add_index :rememberable_users, :authentication_token, unique: true
47
+ # t.timestamps
48
+ # end
49
+ end
50
+ end
@@ -0,0 +1,28 @@
1
+ class AddDeviseOtpToRememberableUsers < ActiveRecord::Migration[7.0]
2
+ def self.up
3
+ change_table :rememberable_users 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 :rememberable_users, :otp_session_challenge, unique: true
19
+ add_index :rememberable_users, :otp_challenge_expires
20
+ end
21
+
22
+ def self.down
23
+ change_table :rememberable_users 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
@@ -10,36 +10,36 @@
10
10
  #
11
11
  # It's strongly recommended that you check this file into your version control system.
12
12
 
13
- ActiveRecord::Schema[8.0].define(version: 2025_07_18_092536) do
13
+ ActiveRecord::Schema[8.0].define(version: 2025_08_18_030305) do
14
14
  create_table "admins", force: :cascade do |t|
15
- t.string "name"
15
+ t.string "authentication_token"
16
16
  t.datetime "created_at", precision: nil, null: false
17
- t.datetime "updated_at", precision: nil, null: false
17
+ t.datetime "current_sign_in_at", precision: nil
18
+ t.string "current_sign_in_ip"
18
19
  t.string "email", default: "", null: false
19
20
  t.string "encrypted_password", default: "", null: false
20
- t.string "reset_password_token"
21
- t.datetime "reset_password_sent_at", precision: nil
22
- t.datetime "remember_created_at", precision: nil
23
- t.integer "sign_in_count", default: 0
24
- t.datetime "current_sign_in_at", precision: nil
21
+ t.integer "failed_attempts", default: 0
25
22
  t.datetime "last_sign_in_at", precision: nil
26
- t.string "current_sign_in_ip"
27
23
  t.string "last_sign_in_ip"
28
- t.integer "failed_attempts", default: 0
29
- t.string "unlock_token"
30
24
  t.datetime "locked_at", precision: nil
31
- t.string "authentication_token"
25
+ t.string "name"
32
26
  t.string "otp_auth_secret"
33
- t.string "otp_recovery_secret"
27
+ t.datetime "otp_challenge_expires", precision: nil
34
28
  t.boolean "otp_enabled", default: false, null: false
35
- t.boolean "otp_mandatory", default: false, null: false
36
29
  t.datetime "otp_enabled_on", precision: nil
37
- t.integer "otp_time_drift", default: 0, null: false
38
30
  t.integer "otp_failed_attempts", default: 0, null: false
39
- t.integer "otp_recovery_counter", default: 0, null: false
31
+ t.boolean "otp_mandatory", default: false, null: false
40
32
  t.string "otp_persistence_seed"
33
+ t.integer "otp_recovery_counter", default: 0, null: false
34
+ t.string "otp_recovery_secret"
41
35
  t.string "otp_session_challenge"
42
- t.datetime "otp_challenge_expires", precision: nil
36
+ t.integer "otp_time_drift", default: 0, null: false
37
+ t.datetime "remember_created_at", precision: nil
38
+ t.datetime "reset_password_sent_at", precision: nil
39
+ t.string "reset_password_token"
40
+ t.integer "sign_in_count", default: 0
41
+ t.string "unlock_token"
42
+ t.datetime "updated_at", precision: nil, null: false
43
43
  t.index ["authentication_token"], name: "index_admins_on_authentication_token", unique: true
44
44
  t.index ["email"], name: "index_admins_on_email", unique: true
45
45
  t.index ["otp_challenge_expires"], name: "index_admins_on_otp_challenge_expires"
@@ -48,24 +48,61 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_18_092536) do
48
48
  t.index ["unlock_token"], name: "index_admins_on_unlock_token", unique: true
49
49
  end
50
50
 
51
- create_table "non_otp_users", force: :cascade do |t|
52
- t.string "name"
53
- t.datetime "created_at", null: false
54
- t.datetime "updated_at", null: false
51
+ create_table "lockable_users", force: :cascade do |t|
52
+ t.string "authentication_token"
53
+ t.datetime "created_at", precision: nil, null: false
54
+ t.datetime "current_sign_in_at", precision: nil
55
+ t.string "current_sign_in_ip"
55
56
  t.string "email", default: "", null: false
56
57
  t.string "encrypted_password", default: "", null: false
58
+ t.integer "failed_attempts", default: 0
59
+ t.datetime "last_sign_in_at", precision: nil
60
+ t.string "last_sign_in_ip"
61
+ t.datetime "locked_at", precision: nil
62
+ t.string "name"
63
+ t.string "otp_auth_secret"
64
+ t.datetime "otp_challenge_expires", precision: nil
65
+ t.boolean "otp_enabled", default: false, null: false
66
+ t.datetime "otp_enabled_on", precision: nil
67
+ t.integer "otp_failed_attempts", default: 0, null: false
68
+ t.boolean "otp_mandatory", default: false, null: false
69
+ t.string "otp_persistence_seed"
70
+ t.integer "otp_recovery_counter", default: 0, null: false
71
+ t.string "otp_recovery_secret"
72
+ t.string "otp_session_challenge"
73
+ t.integer "otp_time_drift", default: 0, null: false
74
+ t.datetime "remember_created_at", precision: nil
75
+ t.datetime "reset_password_sent_at", precision: nil
57
76
  t.string "reset_password_token"
58
- t.datetime "reset_password_sent_at"
59
- t.datetime "remember_created_at"
60
77
  t.integer "sign_in_count", default: 0
61
- t.datetime "current_sign_in_at"
62
- t.datetime "last_sign_in_at"
78
+ t.string "unlock_token"
79
+ t.datetime "updated_at", precision: nil, null: false
80
+ t.index ["authentication_token"], name: "index_lockable_users_on_authentication_token", unique: true
81
+ t.index ["email"], name: "index_lockable_users_on_email", unique: true
82
+ t.index ["otp_challenge_expires"], name: "index_lockable_users_on_otp_challenge_expires"
83
+ t.index ["otp_session_challenge"], name: "index_lockable_users_on_otp_session_challenge", unique: true
84
+ t.index ["reset_password_token"], name: "index_lockable_users_on_reset_password_token", unique: true
85
+ t.index ["unlock_token"], name: "index_lockable_users_on_unlock_token", unique: true
86
+ end
87
+
88
+ create_table "non_otp_users", force: :cascade do |t|
89
+ t.string "authentication_token"
90
+ t.datetime "created_at", precision: nil, null: false
91
+ t.datetime "current_sign_in_at", precision: nil
63
92
  t.string "current_sign_in_ip"
64
- t.string "last_sign_in_ip"
93
+ t.string "email", default: "", null: false
94
+ t.string "encrypted_password", default: "", null: false
65
95
  t.integer "failed_attempts", default: 0
96
+ t.datetime "last_sign_in_at", precision: nil
97
+ t.string "last_sign_in_ip"
98
+ t.datetime "locked_at", precision: nil
99
+ t.string "name"
100
+ t.datetime "remember_created_at", precision: nil
101
+ t.datetime "reset_password_sent_at", precision: nil
102
+ t.string "reset_password_token"
103
+ t.integer "sign_in_count", default: 0
66
104
  t.string "unlock_token"
67
- t.datetime "locked_at"
68
- t.string "authentication_token"
105
+ t.datetime "updated_at", precision: nil, null: false
69
106
  t.index ["authentication_token"], name: "index_non_otp_users_on_authentication_token", unique: true
70
107
  t.index ["email"], name: "index_non_otp_users_on_email", unique: true
71
108
  t.index ["reset_password_token"], name: "index_non_otp_users_on_reset_password_token", unique: true
@@ -73,41 +110,70 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_18_092536) do
73
110
  end
74
111
 
75
112
  create_table "posts", force: :cascade do |t|
76
- t.string "title"
77
113
  t.text "body"
78
114
  t.datetime "created_at", precision: nil, null: false
115
+ t.string "title"
79
116
  t.datetime "updated_at", precision: nil, null: false
80
117
  end
81
118
 
82
- create_table "users", force: :cascade do |t|
83
- t.string "name"
84
- t.datetime "created_at", precision: nil, null: false
85
- t.datetime "updated_at", precision: nil, null: false
119
+ create_table "rememberable_users", force: :cascade do |t|
120
+ t.datetime "current_sign_in_at"
121
+ t.string "current_sign_in_ip"
86
122
  t.string "email", default: "", null: false
87
123
  t.string "encrypted_password", default: "", null: false
124
+ t.datetime "last_sign_in_at"
125
+ t.string "last_sign_in_ip"
126
+ t.string "name"
127
+ t.string "otp_auth_secret"
128
+ t.datetime "otp_challenge_expires"
129
+ t.boolean "otp_enabled", default: false, null: false
130
+ t.datetime "otp_enabled_on"
131
+ t.integer "otp_failed_attempts", default: 0, null: false
132
+ t.boolean "otp_mandatory", default: false, null: false
133
+ t.string "otp_persistence_seed"
134
+ t.integer "otp_recovery_counter", default: 0, null: false
135
+ t.string "otp_recovery_secret"
136
+ t.string "otp_session_challenge"
137
+ t.integer "otp_time_drift", default: 0, null: false
138
+ t.datetime "remember_created_at"
139
+ t.datetime "reset_password_sent_at"
88
140
  t.string "reset_password_token"
89
- t.datetime "reset_password_sent_at", precision: nil
90
- t.datetime "remember_created_at", precision: nil
91
141
  t.integer "sign_in_count", default: 0
142
+ t.index ["email"], name: "index_rememberable_users_on_email", unique: true
143
+ t.index ["otp_challenge_expires"], name: "index_rememberable_users_on_otp_challenge_expires"
144
+ t.index ["otp_session_challenge"], name: "index_rememberable_users_on_otp_session_challenge", unique: true
145
+ t.index ["reset_password_token"], name: "index_rememberable_users_on_reset_password_token", unique: true
146
+ end
147
+
148
+ create_table "users", force: :cascade do |t|
149
+ t.string "authentication_token"
150
+ t.datetime "created_at", precision: nil, null: false
92
151
  t.datetime "current_sign_in_at", precision: nil
93
- t.datetime "last_sign_in_at", precision: nil
94
152
  t.string "current_sign_in_ip"
95
- t.string "last_sign_in_ip"
153
+ t.string "email", default: "", null: false
154
+ t.string "encrypted_password", default: "", null: false
96
155
  t.integer "failed_attempts", default: 0
97
- t.string "unlock_token"
156
+ t.datetime "last_sign_in_at", precision: nil
157
+ t.string "last_sign_in_ip"
98
158
  t.datetime "locked_at", precision: nil
99
- t.string "authentication_token"
159
+ t.string "name"
100
160
  t.string "otp_auth_secret"
101
- t.string "otp_recovery_secret"
161
+ t.datetime "otp_challenge_expires", precision: nil
102
162
  t.boolean "otp_enabled", default: false, null: false
103
- t.boolean "otp_mandatory", default: false, null: false
104
163
  t.datetime "otp_enabled_on", precision: nil
105
- t.integer "otp_time_drift", default: 0, null: false
106
164
  t.integer "otp_failed_attempts", default: 0, null: false
107
- t.integer "otp_recovery_counter", default: 0, null: false
165
+ t.boolean "otp_mandatory", default: false, null: false
108
166
  t.string "otp_persistence_seed"
167
+ t.integer "otp_recovery_counter", default: 0, null: false
168
+ t.string "otp_recovery_secret"
109
169
  t.string "otp_session_challenge"
110
- t.datetime "otp_challenge_expires", precision: nil
170
+ t.integer "otp_time_drift", default: 0, null: false
171
+ t.datetime "remember_created_at", precision: nil
172
+ t.datetime "reset_password_sent_at", precision: nil
173
+ t.string "reset_password_token"
174
+ t.integer "sign_in_count", default: 0
175
+ t.string "unlock_token"
176
+ t.datetime "updated_at", precision: nil, null: false
111
177
  t.index ["authentication_token"], name: "index_users_on_authentication_token", unique: true
112
178
  t.index ["email"], name: "index_users_on_email", unique: true
113
179
  t.index ["otp_challenge_expires"], name: "index_users_on_otp_challenge_expires"
@@ -2,7 +2,6 @@ require "test_helper"
2
2
  require "integration_tests_helper"
3
3
 
4
4
  class DisableTokenTest < ActionDispatch::IntegrationTest
5
-
6
5
  def setup
7
6
  # log in 1fa
8
7
  @user = enable_otp_and_sign_in
@@ -52,5 +51,4 @@ class DisableTokenTest < ActionDispatch::IntegrationTest
52
51
  assert_not_nil @user.otp_recovery_secret
53
52
  assert_equal @user.otp_recovery_secret, recovery_secret
54
53
  end
55
-
56
54
  end
@@ -48,7 +48,7 @@ class EnableOtpFormTest < ActionDispatch::IntegrationTest
48
48
 
49
49
  visit "/"
50
50
  within "#alerts" do
51
- assert !page.has_content?('The Confirmation Code you entered did not match the QR code shown below.')
51
+ assert_not page.has_content?('The Confirmation Code you entered did not match the QR code shown below.')
52
52
  end
53
53
  end
54
54
 
@@ -71,4 +71,15 @@ class EnableOtpFormTest < ActionDispatch::IntegrationTest
71
71
  assert_not user.otp_enabled?
72
72
  end
73
73
 
74
+ test "failed confirmation code should return a 422 'unprocessable entity' status" do
75
+ user = sign_user_in
76
+
77
+ visit edit_user_otp_token_path
78
+
79
+ fill_in "confirmation_code", with: "123456"
80
+
81
+ click_button "Continue..."
82
+
83
+ assert_equal 422, page.status_code
84
+ end
74
85
  end
@@ -0,0 +1,143 @@
1
+ require "test_helper"
2
+ require "integration_tests_helper"
3
+
4
+ class SignInTest < ActionDispatch::IntegrationTest
5
+ def setup
6
+ @lockable_user = create_lockable_user
7
+ @lockable_user.populate_otp_secrets!
8
+ @lockable_user.update(otp_enabled: true)
9
+
10
+ sign_user_in(@lockable_user)
11
+ assert_equal lockable_user_otp_credential_path, current_path
12
+ end
13
+
14
+ def teardown
15
+ Capybara.reset_sessions!
16
+ end
17
+
18
+ test "a normal User should not get locked out for entering incorrect OTP tokens" do
19
+ enable_otp_and_sign_in
20
+
21
+ 6.times do
22
+ fill_in "token", with: "123456"
23
+ click_button "Submit Token"
24
+ end
25
+
26
+ assert page.has_content? "The token you provided was invalid."
27
+ end
28
+
29
+ test "a Lockable User should increment failed_attempts for each incorrect OTP token" do
30
+ fill_in "token", with: "123456"
31
+ click_button "Submit Token"
32
+ assert page.has_content? "The token you provided was invalid."
33
+
34
+ @lockable_user.reload
35
+ assert_equal 1, @lockable_user.failed_attempts
36
+
37
+ fill_in "token", with: "123456"
38
+ click_button "Submit Token"
39
+ assert page.has_content? "The token you provided was invalid."
40
+
41
+ @lockable_user.reload
42
+ assert_equal 2, @lockable_user.failed_attempts
43
+ end
44
+
45
+ test "a Lockable User should reset failed_attempts after a correct OTP token" do
46
+ fill_in "token", with: ROTP::TOTP.new(@lockable_user.otp_auth_secret).at(Time.now)
47
+ click_button "Submit Token"
48
+ assert_equal root_path, current_path
49
+
50
+ @lockable_user.reload
51
+ assert_equal 0, @lockable_user.failed_attempts
52
+ end
53
+
54
+ test "a Lockable User should get locked out for entering incorrect OTP token too many times" do
55
+ # Enter incorrect token
56
+ 5.times do
57
+ fill_in "token", with: "123456"
58
+ click_button "Submit Token"
59
+ end
60
+
61
+ @lockable_user.reload
62
+ assert @lockable_user.access_locked?
63
+ end
64
+
65
+ test "a locked out user should be redirected to the sign in form" do
66
+ # Enter incorrect token
67
+ 5.times do
68
+ fill_in "token", with: "123456"
69
+ click_button "Submit Token"
70
+ end
71
+
72
+ assert_equal new_lockable_user_session_path, current_path
73
+ end
74
+
75
+ test "a locked out user should see the default 'Your account is locked' message" do
76
+ # Enter incorrect token
77
+ 5.times do
78
+ fill_in "token", with: "123456"
79
+ click_button "Submit Token"
80
+ end
81
+
82
+ assert page.has_content? "Your account is locked."
83
+ end
84
+
85
+ test "the OTP credentials form should display the 'one more attempt' message before being locked out" do
86
+ # Enter incorrect token
87
+ 4.times do
88
+ fill_in "token", with: "123456"
89
+ click_button "Submit Token"
90
+ end
91
+
92
+ assert page.has_content? "You have one more attempt"
93
+ end
94
+
95
+ test "the OTP credentials form should not work for a locked out user (in case of URL revisit)" do
96
+ # Save challenge path
97
+ uri = URI.parse(current_url)
98
+ challenge_path = "#{uri.path}?#{uri.query}"
99
+
100
+ # Enter incorrect token
101
+ 5.times do
102
+ fill_in "token", with: "123456"
103
+ click_button "Submit Token"
104
+ end
105
+
106
+ visit challenge_path
107
+
108
+ # Enter correct token
109
+ fill_in "token", with: ROTP::TOTP.new(@lockable_user.otp_auth_secret).at(Time.now)
110
+ click_button "Submit Token"
111
+
112
+ assert page.has_content? "Your account is locked."
113
+ end
114
+
115
+ test "manually revisiting the sign_in form should not reset the failed attempts for the OTP form" do
116
+ # Enter incorrect token
117
+ 4.times do
118
+ fill_in "token", with: "123456"
119
+ click_button "Submit Token"
120
+ end
121
+
122
+ @lockable_user.reload
123
+ assert_equal 4, @lockable_user.failed_attempts
124
+
125
+ # Attempt to reset failed attempts via sign_in form
126
+ reset!
127
+ visit posts_path
128
+ assert_equal new_user_session_path, current_path
129
+ sign_user_in(@lockable_user)
130
+
131
+ @lockable_user.reload
132
+ assert_equal 4, @lockable_user.failed_attempts
133
+
134
+ # Enter incorrect token again
135
+ fill_in "token", with: "123456"
136
+ click_button "Submit Token"
137
+
138
+ @lockable_user.reload
139
+ assert_equal 5, @lockable_user.failed_attempts
140
+
141
+ assert page.has_content? "Your account is locked."
142
+ end
143
+ end
@@ -2,7 +2,6 @@ require "test_helper"
2
2
  require "integration_tests_helper"
3
3
 
4
4
  class NonOtpUserModelsTest < ActionDispatch::IntegrationTest
5
-
6
5
  def teardown
7
6
  Capybara.reset_sessions!
8
7
  end
@@ -17,5 +16,4 @@ class NonOtpUserModelsTest < ActionDispatch::IntegrationTest
17
16
 
18
17
  assert_equal non_otp_posts_path, current_path
19
18
  end
20
-
21
19
  end
@@ -0,0 +1,35 @@
1
+ require "test_helper"
2
+ require "integration_tests_helper"
3
+
4
+ class OtpDriftTest < ActionDispatch::IntegrationTest
5
+ def teardown
6
+ Capybara.reset_sessions!
7
+ Timecop.return
8
+ end
9
+
10
+ test "should allow OTP token usage up within the OTP drift window" do
11
+ user = enable_otp_and_sign_in
12
+
13
+ copied_token = ROTP::TOTP.new(user.otp_auth_secret).at(Time.now)
14
+
15
+ Timecop.freeze(Time.now + 90)
16
+
17
+ fill_in "token", with: copied_token
18
+ click_button "Submit Token"
19
+
20
+ assert_equal root_path, current_path
21
+ end
22
+
23
+ test "should not allow OTP token usage beyond the OTP drift window" do
24
+ user = enable_otp_and_sign_in
25
+
26
+ copied_token = ROTP::TOTP.new(user.otp_auth_secret).at(Time.now)
27
+
28
+ Timecop.freeze(Time.now + 120)
29
+
30
+ fill_in "token", with: copied_token
31
+ click_button "Submit Token"
32
+
33
+ assert_not_equal root_path, current_path
34
+ end
35
+ end