two_factor_authentication 2.2.0 → 3.0.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 +5 -5
- data/.github/workflows/ci.yml +77 -0
- data/.gitignore +3 -0
- data/CHANGELOG.md +37 -2
- data/Gemfile +29 -8
- data/MIGRATION_GUIDE.md +143 -0
- data/README.md +145 -93
- data/app/controllers/devise/two_factor_authentication_controller.rb +3 -2
- data/app/views/devise/two_factor_authentication/show.html.erb +4 -4
- data/config/locales/de.yml +8 -0
- data/lib/generators/active_record/templates/migration.rb +9 -1
- data/lib/generators/active_record/two_factor_authentication_generator.rb +18 -0
- data/lib/generators/two_factor_authentication/two_factor_authentication_generator.rb +1 -1
- data/lib/two_factor_authentication/controllers/helpers.rb +7 -3
- data/lib/two_factor_authentication/models/two_factor_authenticatable.rb +8 -5
- data/lib/two_factor_authentication/routes.rb +1 -1
- data/lib/two_factor_authentication/schema.rb +7 -7
- data/lib/two_factor_authentication/version.rb +1 -1
- data/spec/controllers/two_factor_authentication_controller_spec.rb +84 -7
- data/spec/features/two_factor_authenticatable_spec.rb +119 -14
- data/spec/generators/active_record/two_factor_authentication_generator_spec.rb +52 -4
- data/spec/generators/two_factor_authentication/two_factor_authentication_generator_spec.rb +37 -0
- data/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +89 -7
- data/spec/lib/two_factor_authentication/schema_spec.rb +41 -0
- data/spec/rails_app/app/assets/config/manifest.js +2 -0
- data/spec/rails_app/app/controllers/home_controller.rb +6 -1
- data/spec/rails_app/app/models/guest_user.rb +1 -1
- data/spec/rails_app/app/models/secure_user.rb +13 -0
- data/spec/rails_app/config/application.rb +1 -1
- data/spec/rails_app/config/environments/test.rb +3 -8
- data/spec/rails_app/config/initializers/inflections.rb +4 -0
- data/spec/rails_app/config/routes.rb +2 -0
- data/spec/rails_app/db/migrate/20260929000000_add_otp_columns_to_users.rb +8 -0
- data/spec/rails_app/db/migrate/20260929000001_devise_create_secure_users.rb +22 -0
- data/spec/rails_app/db/schema.rb +26 -8
- data/spec/rails_app/lib/sms_provider.rb +2 -4
- data/spec/requests/two_factor_authentication_helpers_spec.rb +48 -0
- data/spec/spec_helper.rb +1 -0
- data/spec/support/authenticated_model_helper.rb +5 -30
- data/spec/support/controller_helper.rb +5 -4
- data/spec/support/features_spec_helper.rb +1 -0
- data/two_factor_authentication.gemspec +4 -5
- metadata +43 -26
- data/.travis.yml +0 -28
- data/spec/rails_app/config/initializers/secret_token.rb +0 -7
data/README.md
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://gitter.im/Houdini/two_factor_authentication?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
|
4
4
|
|
|
5
|
-
[](https://github.com/Houdini/two_factor_authentication/actions/workflows/ci.yml)
|
|
6
6
|
[](https://codeclimate.com/github/Houdini/two_factor_authentication)
|
|
7
7
|
|
|
8
8
|
## Features
|
|
9
9
|
|
|
10
10
|
* Support for 2 types of OTP codes
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
1. Codes delivered directly to the user
|
|
12
|
+
2. TOTP (Google Authenticator) codes based on a shared secret (HMAC)
|
|
13
13
|
* Configurable OTP code digit length
|
|
14
14
|
* Configurable max login attempts
|
|
15
15
|
* Customizable logic to determine if a user needs two factor authentication
|
|
@@ -28,7 +28,7 @@ Once that's done, run:
|
|
|
28
28
|
|
|
29
29
|
bundle install
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
The gem is tested on Ruby 2.3 – 4.0 with Rails 5.2 through 8.1.
|
|
32
32
|
|
|
33
33
|
### Installation
|
|
34
34
|
|
|
@@ -69,13 +69,20 @@ rails g migration AddTwoFactorFieldsToUsers second_factor_attempts_count:integer
|
|
|
69
69
|
|
|
70
70
|
Open your migration file (it will be in the `db/migrate` directory and will be
|
|
71
71
|
named something like `20151230163930_add_two_factor_fields_to_users.rb`), and
|
|
72
|
-
|
|
72
|
+
set the attempt counter's default to `0` and add `unique: true` to the
|
|
73
|
+
`add_index` line so that they look like this:
|
|
73
74
|
|
|
74
75
|
```ruby
|
|
76
|
+
add_column :users, :second_factor_attempts_count, :integer, default: 0
|
|
75
77
|
add_index :users, :encrypted_otp_secret_key, unique: true
|
|
76
78
|
```
|
|
77
79
|
Save the file.
|
|
78
80
|
|
|
81
|
+
The counter must contain an integer because failed OTP submissions increment
|
|
82
|
+
it. If you already ran a migration without this default, add a follow-up
|
|
83
|
+
migration that sets the default to `0` and backfills existing `NULL` counters
|
|
84
|
+
to `0`, preserving other counter values.
|
|
85
|
+
|
|
79
86
|
#### Complete the setup
|
|
80
87
|
|
|
81
88
|
Run the migration with:
|
|
@@ -161,9 +168,13 @@ Below is an example using ERB:
|
|
|
161
168
|
<%= submit_tag "Log in!" %>
|
|
162
169
|
<% end %>
|
|
163
170
|
|
|
164
|
-
<%=
|
|
171
|
+
<%= button_to "Sign out", destroy_user_session_path, :method => :delete %>
|
|
165
172
|
```
|
|
166
173
|
|
|
174
|
+
#### Upgrading from version 2.2.0 to 3.0.0
|
|
175
|
+
|
|
176
|
+
See the [Migration Guide](MIGRATION_GUIDE.md#upgrading-from-220-to-300).
|
|
177
|
+
|
|
167
178
|
#### Upgrading from version 1.X to 2.X
|
|
168
179
|
|
|
169
180
|
The following database fields are new in version 2.
|
|
@@ -224,7 +235,7 @@ steps:
|
|
|
224
235
|
|
|
225
236
|
Open the generated file, and replace its contents with the following:
|
|
226
237
|
```ruby
|
|
227
|
-
class PopulateEncryptedOtpFields < ActiveRecord::Migration
|
|
238
|
+
class PopulateEncryptedOtpFields < ActiveRecord::Migration[8.1]
|
|
228
239
|
def up
|
|
229
240
|
User.reset_column_information
|
|
230
241
|
|
|
@@ -238,8 +249,9 @@ steps:
|
|
|
238
249
|
User.reset_column_information
|
|
239
250
|
|
|
240
251
|
User.find_each do |user|
|
|
241
|
-
|
|
242
|
-
|
|
252
|
+
# Read through the encrypted accessor, then write the plaintext column
|
|
253
|
+
# directly without invoking the encrypted setter.
|
|
254
|
+
user.update_columns(otp_secret_key: user.otp_secret_key)
|
|
243
255
|
end
|
|
244
256
|
end
|
|
245
257
|
end
|
|
@@ -255,14 +267,23 @@ steps:
|
|
|
255
267
|
If, for some reason, you want to switch back to the old non-encrypted version,
|
|
256
268
|
use these steps:
|
|
257
269
|
|
|
258
|
-
1.
|
|
270
|
+
1. Stop application writes while rolling back and changing the model
|
|
271
|
+
configuration. Keep `has_one_time_password(encrypted: true)` and the same
|
|
272
|
+
`otp_secret_encryption_key` configured throughout the rollback.
|
|
259
273
|
|
|
260
274
|
2. Roll back the last 3 migrations (assuming you haven't added any new ones
|
|
261
|
-
after them):
|
|
275
|
+
after them):
|
|
262
276
|
```
|
|
263
277
|
bundle exec rake db:rollback STEP=3
|
|
264
278
|
```
|
|
265
279
|
|
|
280
|
+
This recreates the plaintext column, copies each decrypted secret back into
|
|
281
|
+
it, and then removes the encryption columns. Existing authenticator secrets
|
|
282
|
+
are preserved, and users without TOTP retain a `nil` secret.
|
|
283
|
+
|
|
284
|
+
3. Remove `(encrypted: true)` from `has_one_time_password` and restart the
|
|
285
|
+
application before allowing writes again.
|
|
286
|
+
|
|
266
287
|
#### Critical Security Note! Add before_action to your user registration controllers
|
|
267
288
|
|
|
268
289
|
You should have a file registrations_controller.rb in your controllers folder
|
|
@@ -283,61 +304,103 @@ to overwrite/customize user registrations. It should include the lines below, fo
|
|
|
283
304
|
end
|
|
284
305
|
```
|
|
285
306
|
|
|
307
|
+
Route registrations through this controller in `config/routes.rb`:
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
devise_for :users, controllers: { registrations: 'registrations' }
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Update your existing `devise_for :users` declaration. Defining the controller
|
|
314
|
+
alone does not change Devise's routes, so the callback will not run until
|
|
315
|
+
this mapping is configured.
|
|
316
|
+
|
|
317
|
+
`is_fully_authenticated?` checks the scope of the current Devise controller,
|
|
318
|
+
or `Devise.default_scope` elsewhere. Pass a scope to check another model,
|
|
319
|
+
e.g. `is_fully_authenticated?(:admin)`.
|
|
320
|
+
|
|
286
321
|
#### Critical Security Note! Add 2FA validation to your custom user actions
|
|
287
322
|
|
|
288
|
-
|
|
323
|
+
Require fresh second-factor verification for sensitive account changes,
|
|
324
|
+
including replacing or disabling TOTP. Verify a code against the existing
|
|
325
|
+
secret before assigning a replacement; a code for the replacement secret only
|
|
326
|
+
proves that the user configured the new authenticator.
|
|
289
327
|
|
|
290
|
-
|
|
328
|
+
For initial TOTP enrollment, verify a fresh direct OTP instead. The following
|
|
329
|
+
example generates a pending secret on the server, keeps it separate from the
|
|
330
|
+
active secret, and requires separate `current_code` and `new_code` values:
|
|
291
331
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
332
|
+
```ruby
|
|
333
|
+
class AccountController < ApplicationController
|
|
334
|
+
before_action :authenticate_user!
|
|
335
|
+
|
|
336
|
+
def new_totp
|
|
337
|
+
secret = current_user.generate_totp_secret
|
|
338
|
+
session[:pending_totp] = { 'user_id' => current_user.id, 'secret' => secret }
|
|
339
|
+
current_user.send_new_otp unless current_user.totp_enabled?
|
|
340
|
+
render json: { provisioning_uri: current_user.provisioning_uri(nil, otp_secret_key: secret) }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def update_totp
|
|
344
|
+
codes = params.require(:two_factor).permit(:current_code, :new_code)
|
|
345
|
+
pending = session[:pending_totp]
|
|
346
|
+
unless pending && pending['user_id'] == current_user.id
|
|
347
|
+
render json: { error: 'Start TOTP setup first.' }, status: :unprocessable_entity
|
|
348
|
+
return
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
updated = false
|
|
352
|
+
current_user.with_lock do
|
|
353
|
+
next if current_user.max_login_attempts?
|
|
354
|
+
|
|
355
|
+
valid_current_code = if current_user.totp_enabled?
|
|
356
|
+
current_user.authenticate_totp(codes[:current_code].to_s)
|
|
357
|
+
else
|
|
358
|
+
current_user.authenticate_direct_otp(codes[:current_code].to_s)
|
|
359
|
+
end
|
|
360
|
+
unless valid_current_code
|
|
361
|
+
current_user.increment!(:second_factor_attempts_count)
|
|
362
|
+
next
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Persist consumption of the current code even if the new code is invalid.
|
|
366
|
+
current_user.save!
|
|
367
|
+
|
|
368
|
+
# The new secret has its own replay timestamp, independent of the old one.
|
|
369
|
+
candidate = current_user.class.new
|
|
370
|
+
next unless candidate.confirm_totp_secret(pending['secret'], codes[:new_code].to_s)
|
|
371
|
+
|
|
372
|
+
current_user.update!(
|
|
373
|
+
otp_secret_key: candidate.otp_secret_key,
|
|
374
|
+
totp_timestamp: candidate.totp_timestamp,
|
|
375
|
+
direct_otp: nil,
|
|
376
|
+
direct_otp_sent_at: nil,
|
|
377
|
+
second_factor_attempts_count: 0
|
|
378
|
+
)
|
|
379
|
+
updated = true
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
if updated
|
|
383
|
+
session.delete(:pending_totp)
|
|
384
|
+
render json: { success: 'TOTP configuration saved.' }
|
|
385
|
+
else
|
|
386
|
+
render json: { error: 'Second-factor verification failed.' }, status: :unauthorized
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Connect these actions in `config/routes.rb`:
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
post 'account/totp/setup', to: 'account#new_totp'
|
|
396
|
+
patch 'account/totp', to: 'account#update_totp'
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Show the provisioning URI as a QR code and ask for both codes before submitting
|
|
400
|
+
the update. Users without TOTP receive a fresh direct code through your
|
|
401
|
+
`send_two_factor_authentication_code` implementation. Apply the same existing
|
|
402
|
+
factor check to other sensitive changes, and save the user after a successful
|
|
403
|
+
TOTP check to persist its replay timestamp.
|
|
341
404
|
|
|
342
405
|
|
|
343
406
|
### Example App
|
|
@@ -353,20 +416,12 @@ config.otp_secret_encryption_key = ENV['OTP_SECRET_ENCRYPTION_KEY']
|
|
|
353
416
|
|
|
354
417
|
to set up TOTP for Google Authenticator for user:
|
|
355
418
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
( encrypted db fields are set upon user model save action,
|
|
362
|
-
rails c access relies on setting env var: OTP_SECRET_ENCRYPTION_KEY )
|
|
363
|
-
|
|
364
|
-
to check if user has input the correct code (from the QR display page)
|
|
365
|
-
before saving the user model:
|
|
419
|
+
Use the `new_totp` and `update_totp` actions above to prepare the QR code and
|
|
420
|
+
verify both the current factor and the new authenticator code. Keep the pending
|
|
421
|
+
secret separate from `current_user.otp_secret_key` until both checks succeed.
|
|
366
422
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
```
|
|
423
|
+
Encrypted database fields are persisted when the update action saves the user.
|
|
424
|
+
Rails console access also requires `OTP_SECRET_ENCRYPTION_KEY` to be set.
|
|
370
425
|
|
|
371
426
|
additional note:
|
|
372
427
|
|
|
@@ -381,25 +436,22 @@ otpauth://totp/LABEL?secret=p6wwetjnkjnrcmpd (example secret used here)
|
|
|
381
436
|
|
|
382
437
|
where LABEL should be something like "example.com (Username)", which shows up in their GA app to remind them the code is for example.com
|
|
383
438
|
|
|
384
|
-
this returns true or false with an allowed_otp_drift_seconds 'grace period'
|
|
385
|
-
|
|
386
439
|
to set TOTP to DISABLED for a user account:
|
|
387
440
|
|
|
388
|
-
|
|
389
|
-
current_user.second_factor_attempts_count=nil
|
|
390
|
-
current_user.encrypted_otp_secret_key=nil
|
|
391
|
-
current_user.encrypted_otp_secret_key_iv=nil
|
|
392
|
-
current_user.encrypted_otp_secret_key_salt=nil
|
|
393
|
-
current_user.direct_otp=nil
|
|
394
|
-
current_user.direct_otp_sent_at=nil
|
|
395
|
-
current_user.totp_timestamp=nil
|
|
396
|
-
current_user.direct_otp=nil
|
|
397
|
-
current_user.otp_secret_key=nil
|
|
398
|
-
current_user.otp_confirmed=nil
|
|
399
|
-
current_user.save! (if in ruby code instead of console)
|
|
400
|
-
current_user.direct_otp? => false
|
|
401
|
-
current_user.totp_enabled? => false
|
|
402
|
-
```
|
|
403
|
-
|
|
441
|
+
After verifying the existing factor as described above:
|
|
404
442
|
|
|
443
|
+
```ruby
|
|
444
|
+
current_user.update!(
|
|
445
|
+
otp_secret_key: nil,
|
|
446
|
+
totp_timestamp: nil,
|
|
447
|
+
direct_otp: nil,
|
|
448
|
+
direct_otp_sent_at: nil,
|
|
449
|
+
second_factor_attempts_count: 0
|
|
450
|
+
)
|
|
451
|
+
current_user.direct_otp? # => false
|
|
452
|
+
current_user.totp_enabled? # => false
|
|
453
|
+
```
|
|
405
454
|
|
|
455
|
+
This disables TOTP and leaves the attempt counter at an integer value. Direct
|
|
456
|
+
OTP authentication remains required unless `need_two_factor_authentication?`
|
|
457
|
+
returns `false`.
|
|
@@ -61,11 +61,12 @@ class Devise::TwoFactorAuthenticationController < DeviseController
|
|
|
61
61
|
resource.save
|
|
62
62
|
set_flash_message :alert, :attempt_failed, now: true
|
|
63
63
|
|
|
64
|
+
# 422 lets Turbo (the Rails 7+ default) render the response to a form submission
|
|
64
65
|
if resource.max_login_attempts?
|
|
65
66
|
sign_out(resource)
|
|
66
|
-
render :max_login_attempts_reached
|
|
67
|
+
render :max_login_attempts_reached, status: 422
|
|
67
68
|
else
|
|
68
|
-
render :show
|
|
69
|
+
render :show, status: 422
|
|
69
70
|
end
|
|
70
71
|
end
|
|
71
72
|
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
<p><%= flash[:notice] %></p>
|
|
8
8
|
|
|
9
9
|
<%= form_tag([resource_name, :two_factor_authentication], :method => :put) do %>
|
|
10
|
-
<%= text_field_tag :code %>
|
|
10
|
+
<%= text_field_tag :code, '', autofocus: true %>
|
|
11
11
|
<%= submit_tag "Submit" %>
|
|
12
12
|
<% end %>
|
|
13
13
|
|
|
14
14
|
<% if resource.direct_otp %>
|
|
15
|
-
<%= link_to "Resend Code", send("resend_code_#{resource_name}_two_factor_authentication_path")
|
|
15
|
+
<%= link_to "Resend Code", send("resend_code_#{resource_name}_two_factor_authentication_path") %>
|
|
16
16
|
<% else %>
|
|
17
|
-
<%= link_to "Send me a code instead", send("resend_code_#{resource_name}_two_factor_authentication_path")
|
|
17
|
+
<%= link_to "Send me a code instead", send("resend_code_#{resource_name}_two_factor_authentication_path") %>
|
|
18
18
|
<% end %>
|
|
19
|
-
<%=
|
|
19
|
+
<%= button_to "Sign out", send("destroy_#{resource_name}_session_path"), :method => Array(Devise.sign_out_via).first %>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
de:
|
|
2
|
+
devise:
|
|
3
|
+
two_factor_authentication:
|
|
4
|
+
success: "Ihre Zwei-Faktor-Authentifizierung war erfolgreich."
|
|
5
|
+
attempt_failed: "Authentifizierungsversuch fehlgeschlagen."
|
|
6
|
+
max_login_attempts_reached: "Ihr Zugang wurde ganz verweigert, da Sie Ihr Versuchslimit erreicht haben."
|
|
7
|
+
contact_administrator: "Kontaktieren Sie bitte einen Ihrer Administratoren."
|
|
8
|
+
code_has_been_sent: "Ihr Einmal-Passwort wurde verschickt."
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Migration
|
|
1
|
+
class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
<% if postgresql? -%>
|
|
3
|
+
disable_ddl_transaction!
|
|
4
|
+
|
|
5
|
+
<% end -%>
|
|
2
6
|
def change
|
|
3
7
|
add_column :<%= table_name %>, :second_factor_attempts_count, :integer, default: 0
|
|
4
8
|
add_column :<%= table_name %>, :encrypted_otp_secret_key, :string
|
|
@@ -8,6 +12,10 @@ class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Mig
|
|
|
8
12
|
add_column :<%= table_name %>, :direct_otp_sent_at, :datetime
|
|
9
13
|
add_column :<%= table_name %>, :totp_timestamp, :timestamp
|
|
10
14
|
|
|
15
|
+
<% if postgresql? -%>
|
|
16
|
+
add_index :<%= table_name %>, :encrypted_otp_secret_key, unique: true, algorithm: :concurrently
|
|
17
|
+
<% else -%>
|
|
11
18
|
add_index :<%= table_name %>, :encrypted_otp_secret_key, unique: true
|
|
19
|
+
<% end -%>
|
|
12
20
|
end
|
|
13
21
|
end
|
|
@@ -9,6 +9,24 @@ module ActiveRecord
|
|
|
9
9
|
migration_template "migration.rb", "db/migrate/two_factor_authentication_add_to_#{table_name}.rb"
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def migration_version
|
|
15
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# CREATE INDEX CONCURRENTLY is PostgreSQL-only; other adapters reject the option.
|
|
19
|
+
def postgresql?
|
|
20
|
+
config = if ActiveRecord::Base.respond_to?(:connection_db_config)
|
|
21
|
+
ActiveRecord::Base.connection_db_config
|
|
22
|
+
else
|
|
23
|
+
ActiveRecord::Base.connection_config
|
|
24
|
+
end
|
|
25
|
+
adapter = config.respond_to?(:adapter) ? config.adapter : config[:adapter]
|
|
26
|
+
adapter.to_s.include?("postg")
|
|
27
|
+
rescue StandardError
|
|
28
|
+
false
|
|
29
|
+
end
|
|
12
30
|
end
|
|
13
31
|
end
|
|
14
32
|
end
|
|
@@ -7,7 +7,7 @@ module TwoFactorAuthenticatable
|
|
|
7
7
|
|
|
8
8
|
def inject_two_factor_authentication_content
|
|
9
9
|
path = File.join("app", "models", "#{file_path}.rb")
|
|
10
|
-
inject_into_file(path, "two_factor_authenticatable, :", :after => "devise :") if File.
|
|
10
|
+
inject_into_file(path, "two_factor_authenticatable, :", :after => "devise :") if File.exist?(File.join(destination_root, path))
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
hook_for :orm
|
|
@@ -20,9 +20,12 @@ module TwoFactorAuthentication
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def handle_failed_second_factor(scope)
|
|
23
|
-
if request.format
|
|
23
|
+
if request.format&.html?
|
|
24
24
|
session["#{scope}_return_to"] = request.original_fullpath if request.get?
|
|
25
25
|
redirect_to two_factor_authentication_path_for(scope)
|
|
26
|
+
elsif request.format&.json?
|
|
27
|
+
session["#{scope}_return_to"] = root_path(format: :html)
|
|
28
|
+
render json: { redirect_to: two_factor_authentication_path_for(scope) }, status: :unauthorized
|
|
26
29
|
else
|
|
27
30
|
head :unauthorized
|
|
28
31
|
end
|
|
@@ -41,8 +44,9 @@ end
|
|
|
41
44
|
module Devise
|
|
42
45
|
module Controllers
|
|
43
46
|
module Helpers
|
|
44
|
-
def is_fully_authenticated?
|
|
45
|
-
|
|
47
|
+
def is_fully_authenticated?(scope = nil)
|
|
48
|
+
scope ||= devise_controller? ? resource_name : Devise.default_scope
|
|
49
|
+
!session["warden.user.#{scope}.session"].try(:[], TwoFactorAuthentication::NEED_AUTHENTICATION)
|
|
46
50
|
end
|
|
47
51
|
end
|
|
48
52
|
end
|
|
@@ -16,7 +16,7 @@ module Devise
|
|
|
16
16
|
::Devise::Models.config(
|
|
17
17
|
self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length,
|
|
18
18
|
:remember_otp_session_for_seconds, :otp_secret_encryption_key,
|
|
19
|
-
:direct_otp_length, :direct_otp_valid_for, :
|
|
19
|
+
:direct_otp_length, :direct_otp_valid_for, :delete_cookie_on_logout
|
|
20
20
|
)
|
|
21
21
|
end
|
|
22
22
|
|
|
@@ -44,7 +44,7 @@ module Devise
|
|
|
44
44
|
drift_ahead: drift, drift_behind: drift, after: totp_timestamp
|
|
45
45
|
)
|
|
46
46
|
return false unless new_timestamp
|
|
47
|
-
self.totp_timestamp = new_timestamp
|
|
47
|
+
self.totp_timestamp = Time.at(new_timestamp).utc
|
|
48
48
|
true
|
|
49
49
|
end
|
|
50
50
|
|
|
@@ -92,13 +92,16 @@ module Devise
|
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
def generate_totp_secret
|
|
95
|
-
ROTP
|
|
95
|
+
# ROTP gem since version 5 to version 5.1
|
|
96
|
+
# at version 5.1 ROTP gem reinstates.
|
|
97
|
+
# Details: https://github.com/mdp/rotp/blob/master/CHANGELOG.md#510
|
|
98
|
+
ROTP::Base32.try(:random) || ROTP::Base32.random_base32
|
|
96
99
|
end
|
|
97
100
|
|
|
98
101
|
def create_direct_otp(options = {})
|
|
99
102
|
# Create a new random OTP and store it in the database
|
|
100
103
|
digits = options[:length] || self.class.direct_otp_length || 6
|
|
101
|
-
|
|
104
|
+
update(
|
|
102
105
|
direct_otp: random_base10(digits),
|
|
103
106
|
direct_otp_sent_at: Time.now.utc
|
|
104
107
|
)
|
|
@@ -119,7 +122,7 @@ module Devise
|
|
|
119
122
|
end
|
|
120
123
|
|
|
121
124
|
def clear_direct_otp
|
|
122
|
-
|
|
125
|
+
update(direct_otp: nil, direct_otp_sent_at: nil)
|
|
123
126
|
end
|
|
124
127
|
end
|
|
125
128
|
|
|
@@ -3,7 +3,7 @@ module ActionDispatch::Routing
|
|
|
3
3
|
protected
|
|
4
4
|
|
|
5
5
|
def devise_two_factor_authentication(mapping, controllers)
|
|
6
|
-
resource :two_factor_authentication, :only => [:show, :update
|
|
6
|
+
resource :two_factor_authentication, :only => [:show, :update], :path => mapping.path_names[:two_factor_authentication], :controller => controllers[:two_factor_authentication] do
|
|
7
7
|
collection { get "resend_code" }
|
|
8
8
|
end
|
|
9
9
|
end
|
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
module TwoFactorAuthentication
|
|
2
2
|
module Schema
|
|
3
3
|
def second_factor_attempts_count
|
|
4
|
-
|
|
4
|
+
integer :second_factor_attempts_count, :default => 0
|
|
5
5
|
end
|
|
6
6
|
|
|
7
7
|
def encrypted_otp_secret_key
|
|
8
|
-
|
|
8
|
+
string :encrypted_otp_secret_key
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def encrypted_otp_secret_key_iv
|
|
12
|
-
|
|
12
|
+
string :encrypted_otp_secret_key_iv
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def encrypted_otp_secret_key_salt
|
|
16
|
-
|
|
16
|
+
string :encrypted_otp_secret_key_salt
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def direct_otp
|
|
20
|
-
|
|
20
|
+
string :direct_otp
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def direct_otp_sent_at
|
|
24
|
-
|
|
24
|
+
datetime :direct_otp_sent_at
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def totp_timestamp
|
|
28
|
-
|
|
28
|
+
timestamp :totp_timestamp
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
end
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
require 'spec_helper'
|
|
2
2
|
|
|
3
3
|
describe Devise::TwoFactorAuthenticationController, type: :controller do
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
post :update, code: code
|
|
10
|
-
end
|
|
4
|
+
def post_code(code)
|
|
5
|
+
if Rails::VERSION::MAJOR >= 5
|
|
6
|
+
post :update, params: { code: code }
|
|
7
|
+
else
|
|
8
|
+
post :update, code: code
|
|
11
9
|
end
|
|
10
|
+
end
|
|
12
11
|
|
|
12
|
+
describe 'is_fully_authenticated? helper' do
|
|
13
13
|
before do
|
|
14
14
|
sign_in
|
|
15
15
|
end
|
|
@@ -38,4 +38,81 @@ describe Devise::TwoFactorAuthenticationController, type: :controller do
|
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
|
+
|
|
42
|
+
describe 'PUT update' do
|
|
43
|
+
render_views
|
|
44
|
+
|
|
45
|
+
let(:user) { create_user }
|
|
46
|
+
|
|
47
|
+
before do
|
|
48
|
+
sign_in user
|
|
49
|
+
user.send_new_otp
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'responds with 422 and re-renders the form for an invalid code' do
|
|
53
|
+
post_code '00000000'
|
|
54
|
+
|
|
55
|
+
expect(response).to have_http_status(422)
|
|
56
|
+
expect(response.body).to include('Enter the code that was sent to you')
|
|
57
|
+
expect(user.reload.second_factor_attempts_count).to eq(1)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'responds with 422 and signs out when the last attempt fails' do
|
|
61
|
+
user.update!(second_factor_attempts_count: User.max_login_attempts - 1)
|
|
62
|
+
|
|
63
|
+
post_code '00000000'
|
|
64
|
+
|
|
65
|
+
expect(response).to have_http_status(422)
|
|
66
|
+
expect(response.body).to include('Access completely denied')
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'resets the failed attempt counter after a valid code' do
|
|
70
|
+
user.update!(second_factor_attempts_count: 1)
|
|
71
|
+
|
|
72
|
+
post_code user.reload.direct_otp
|
|
73
|
+
|
|
74
|
+
expect(response).to redirect_to(root_path)
|
|
75
|
+
expect(user.reload.second_factor_attempts_count).to eq(0)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
describe 'GET resend_code' do
|
|
80
|
+
let(:user) { create_user }
|
|
81
|
+
|
|
82
|
+
before { sign_in user }
|
|
83
|
+
|
|
84
|
+
it 'sends a new code and redirects back to the form' do
|
|
85
|
+
get :resend_code
|
|
86
|
+
|
|
87
|
+
expect(SMSProvider.messages.size).to eq(1)
|
|
88
|
+
expect(SMSProvider.last_message.body).to eq(user.reload.direct_otp)
|
|
89
|
+
expect(response).to redirect_to(user_two_factor_authentication_path)
|
|
90
|
+
expect(flash[:notice]).to eq('Your authentication code has been sent.')
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
context 'with a second devise scope' do
|
|
95
|
+
let(:secure_user) { create_secure_user }
|
|
96
|
+
|
|
97
|
+
before do
|
|
98
|
+
@request.env['devise.mapping'] = Devise.mappings[:secure_user]
|
|
99
|
+
sign_in secure_user, scope: :secure_user
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'reports the scope of the current resource as not fully authenticated' do
|
|
103
|
+
get :show
|
|
104
|
+
|
|
105
|
+
expect(response).to have_http_status(200)
|
|
106
|
+
expect(subject.is_fully_authenticated?).to eq false
|
|
107
|
+
expect(subject.is_fully_authenticated?(:secure_user)).to eq false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'reports the scope as fully authenticated after a valid code' do
|
|
111
|
+
secure_user.send_new_otp
|
|
112
|
+
post_code secure_user.reload.direct_otp
|
|
113
|
+
|
|
114
|
+
expect(subject.is_fully_authenticated?).to eq true
|
|
115
|
+
expect(subject.is_fully_authenticated?(:secure_user)).to eq true
|
|
116
|
+
end
|
|
117
|
+
end
|
|
41
118
|
end
|