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.
Files changed (45) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +77 -0
  3. data/.gitignore +3 -0
  4. data/CHANGELOG.md +37 -2
  5. data/Gemfile +29 -8
  6. data/MIGRATION_GUIDE.md +143 -0
  7. data/README.md +145 -93
  8. data/app/controllers/devise/two_factor_authentication_controller.rb +3 -2
  9. data/app/views/devise/two_factor_authentication/show.html.erb +4 -4
  10. data/config/locales/de.yml +8 -0
  11. data/lib/generators/active_record/templates/migration.rb +9 -1
  12. data/lib/generators/active_record/two_factor_authentication_generator.rb +18 -0
  13. data/lib/generators/two_factor_authentication/two_factor_authentication_generator.rb +1 -1
  14. data/lib/two_factor_authentication/controllers/helpers.rb +7 -3
  15. data/lib/two_factor_authentication/models/two_factor_authenticatable.rb +8 -5
  16. data/lib/two_factor_authentication/routes.rb +1 -1
  17. data/lib/two_factor_authentication/schema.rb +7 -7
  18. data/lib/two_factor_authentication/version.rb +1 -1
  19. data/spec/controllers/two_factor_authentication_controller_spec.rb +84 -7
  20. data/spec/features/two_factor_authenticatable_spec.rb +119 -14
  21. data/spec/generators/active_record/two_factor_authentication_generator_spec.rb +52 -4
  22. data/spec/generators/two_factor_authentication/two_factor_authentication_generator_spec.rb +37 -0
  23. data/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +89 -7
  24. data/spec/lib/two_factor_authentication/schema_spec.rb +41 -0
  25. data/spec/rails_app/app/assets/config/manifest.js +2 -0
  26. data/spec/rails_app/app/controllers/home_controller.rb +6 -1
  27. data/spec/rails_app/app/models/guest_user.rb +1 -1
  28. data/spec/rails_app/app/models/secure_user.rb +13 -0
  29. data/spec/rails_app/config/application.rb +1 -1
  30. data/spec/rails_app/config/environments/test.rb +3 -8
  31. data/spec/rails_app/config/initializers/inflections.rb +4 -0
  32. data/spec/rails_app/config/routes.rb +2 -0
  33. data/spec/rails_app/db/migrate/20260929000000_add_otp_columns_to_users.rb +8 -0
  34. data/spec/rails_app/db/migrate/20260929000001_devise_create_secure_users.rb +22 -0
  35. data/spec/rails_app/db/schema.rb +26 -8
  36. data/spec/rails_app/lib/sms_provider.rb +2 -4
  37. data/spec/requests/two_factor_authentication_helpers_spec.rb +48 -0
  38. data/spec/spec_helper.rb +1 -0
  39. data/spec/support/authenticated_model_helper.rb +5 -30
  40. data/spec/support/controller_helper.rb +5 -4
  41. data/spec/support/features_spec_helper.rb +1 -0
  42. data/two_factor_authentication.gemspec +4 -5
  43. metadata +43 -26
  44. data/.travis.yml +0 -28
  45. data/spec/rails_app/config/initializers/secret_token.rb +0 -7
data/README.md CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/Houdini/two_factor_authentication?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
4
4
 
5
- [![Build Status](https://travis-ci.org/Houdini/two_factor_authentication.svg?branch=master)](https://travis-ci.org/Houdini/two_factor_authentication)
5
+ [![CI](https://github.com/Houdini/two_factor_authentication/actions/workflows/ci.yml/badge.svg)](https://github.com/Houdini/two_factor_authentication/actions/workflows/ci.yml)
6
6
  [![Code Climate](https://codeclimate.com/github/Houdini/two_factor_authentication.svg)](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
- 1. Codes delivered directly to the user
12
- 2. TOTP (Google Authenticator) codes based on a shared secret (HMAC)
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
- Note that Ruby 2.1 or greater is required.
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
- add `unique: true` to the `add_index` line so that it looks like this:
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
- <%= link_to "Sign out", destroy_user_session_path, :method => :delete %>
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
- user.otp_secret_key = ROTP::Base32.random_base32
242
- user.save!
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. Remove `(encrypted: true)` from `has_one_time_password`
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
- Make sure you are passing the 2FA secret codes securely and checking for them upon critical user actions, such as API key updates, user email or pgp pubkey updates, or any other changess to private/secure account-related details. Validate the secret during the initial 2FA key/secret verification by the user also, of course.
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
- For example, a simple account_controller.rb may look something like this:
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
- require 'json'
294
-
295
- class AccountController < ApplicationController
296
- before_action :require_signed_in!
297
- before_action :authenticate_user!
298
- respond_to :html, :json
299
-
300
- def account_API
301
- resp = {}
302
- begin
303
- if(account_params["twoFAKey"] && account_params["twoFASecret"])
304
- current_user.otp_secret_key = account_params["twoFAKey"]
305
- if(current_user.authenticate_totp(account_params["twoFASecret"]))
306
- # user has validated their temporary 2FA code, save it to their account, enable 2FA on this account
307
- current_user.save!
308
- resp['success'] = "passed 2FA validation!"
309
- else
310
- resp['error'] = "failed 2FA validation!"
311
- end
312
- elsif(param[:userAccountStuff] && param[:userAccountWidget])
313
- #before updating important user account stuff and widgets,
314
- #check to see that the 2FA secret has also been passed in, and verify it...
315
- if(account_params["twoFASecret"] && current_user.totp_enabled? && current_user.authenticate_totp(account_params["twoFASecret"]))
316
- # user has passed 2FA checks, do cool user account stuff here
317
- ...
318
- else
319
- # user failed 2FA check! No cool user stuff happens!
320
- resp[error] = 'You failed 2FA validation!'
321
- end
322
-
323
- ...
324
- end
325
- else
326
- resp['error'] = 'unknown format error, not saved!'
327
- end
328
- rescue Exception => e
329
- puts "WARNING: account api threw error : '#{e}' for user #{current_user.username}"
330
- #print "error trace: #{e.backtrace}\n"
331
- resp['error'] = "unanticipated server response"
332
- end
333
- render json: resp.to_json
334
- end
335
-
336
- def account_params
337
- params.require(:twoFA).permit(:userAccountStuff, :userAcountWidget, :twoFAKey, :twoFASecret)
338
- end
339
- end
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
- current_user.otp_secret_key = current_user.generate_totp_secret
358
- current_user.save!
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
- current_user.authenticate_totp('123456')
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"), action: :get %>
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"), action: :get %>
17
+ <%= link_to "Send me a code instead", send("resend_code_#{resource_name}_two_factor_authentication_path") %>
18
18
  <% end %>
19
- <%= link_to "Sign out", send("destroy_#{resource_name}_session_path"), :method => :delete %>
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.exists?(path)
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.present? and request.format.html?
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
- !session["warden.user.user.session"].try(:[], TwoFactorAuthentication::NEED_AUTHENTICATION)
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, :totp_timestamp, :delete_cookie_on_logout
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::Base32.random_base32
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
- update_attributes(
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
- update_attributes(direct_otp: nil, direct_otp_sent_at: nil)
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, :resend_code], :path => mapping.path_names[:two_factor_authentication], :controller => controllers[:two_factor_authentication] do
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
- apply_devise_schema :second_factor_attempts_count, Integer, :default => 0
4
+ integer :second_factor_attempts_count, :default => 0
5
5
  end
6
6
 
7
7
  def encrypted_otp_secret_key
8
- apply_devise_schema :encrypted_otp_secret_key, String
8
+ string :encrypted_otp_secret_key
9
9
  end
10
10
 
11
11
  def encrypted_otp_secret_key_iv
12
- apply_devise_schema :encrypted_otp_secret_key_iv, String
12
+ string :encrypted_otp_secret_key_iv
13
13
  end
14
14
 
15
15
  def encrypted_otp_secret_key_salt
16
- apply_devise_schema :encrypted_otp_secret_key_salt, String
16
+ string :encrypted_otp_secret_key_salt
17
17
  end
18
18
 
19
19
  def direct_otp
20
- apply_devise_schema :direct_otp, String
20
+ string :direct_otp
21
21
  end
22
22
 
23
23
  def direct_otp_sent_at
24
- apply_devise_schema :direct_otp_sent_at, DateTime
24
+ datetime :direct_otp_sent_at
25
25
  end
26
26
 
27
27
  def totp_timestamp
28
- apply_devise_schema :totp_timestamp, Timestamp
28
+ timestamp :totp_timestamp
29
29
  end
30
30
  end
31
31
  end
@@ -1,3 +1,3 @@
1
1
  module TwoFactorAuthentication
2
- VERSION = "2.2.0".freeze
2
+ VERSION = "3.0.0".freeze
3
3
  end
@@ -1,15 +1,15 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  describe Devise::TwoFactorAuthenticationController, type: :controller do
4
- describe 'is_fully_authenticated? helper' do
5
- def post_code(code)
6
- if Rails::VERSION::MAJOR >= 5
7
- post :update, params: { code: code }
8
- else
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