two_factor_authentication 2.1.1 → 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 (55) 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 -9
  6. data/MIGRATION_GUIDE.md +143 -0
  7. data/README.md +217 -28
  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 +8 -4
  15. data/lib/two_factor_authentication/models/two_factor_authenticatable.rb +16 -6
  16. data/lib/two_factor_authentication/orm/active_record.rb +2 -0
  17. data/lib/two_factor_authentication/routes.rb +1 -1
  18. data/lib/two_factor_authentication/schema.rb +7 -7
  19. data/lib/two_factor_authentication/version.rb +1 -1
  20. data/lib/two_factor_authentication.rb +1 -2
  21. data/spec/controllers/two_factor_authentication_controller_spec.rb +87 -2
  22. data/spec/features/two_factor_authenticatable_spec.rb +119 -14
  23. data/spec/generators/active_record/two_factor_authentication_generator_spec.rb +52 -4
  24. data/spec/generators/two_factor_authentication/two_factor_authentication_generator_spec.rb +37 -0
  25. data/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +97 -10
  26. data/spec/lib/two_factor_authentication/schema_spec.rb +41 -0
  27. data/spec/rails_app/app/assets/config/manifest.js +2 -0
  28. data/spec/rails_app/app/controllers/home_controller.rb +6 -1
  29. data/spec/rails_app/app/models/guest_user.rb +1 -1
  30. data/spec/rails_app/app/models/secure_user.rb +13 -0
  31. data/spec/rails_app/config/application.rb +1 -1
  32. data/spec/rails_app/config/environments/test.rb +3 -8
  33. data/spec/rails_app/config/initializers/inflections.rb +4 -0
  34. data/spec/rails_app/config/routes.rb +2 -0
  35. data/spec/rails_app/db/migrate/20140403184646_devise_create_users.rb +1 -1
  36. data/spec/rails_app/db/migrate/20140407172619_two_factor_authentication_add_to_users.rb +1 -1
  37. data/spec/rails_app/db/migrate/20140407215513_add_nickanme_to_users.rb +1 -1
  38. data/spec/rails_app/db/migrate/20151224171231_add_encrypted_columns_to_user.rb +1 -1
  39. data/spec/rails_app/db/migrate/20151224180310_populate_otp_column.rb +1 -1
  40. data/spec/rails_app/db/migrate/20151228230340_remove_otp_secret_key_from_user.rb +1 -1
  41. data/spec/rails_app/db/migrate/20160209032439_devise_create_admins.rb +1 -1
  42. data/spec/rails_app/db/migrate/20260929000000_add_otp_columns_to_users.rb +8 -0
  43. data/spec/rails_app/db/migrate/20260929000001_devise_create_secure_users.rb +22 -0
  44. data/spec/rails_app/db/schema.rb +51 -36
  45. data/spec/rails_app/lib/sms_provider.rb +2 -4
  46. data/spec/requests/two_factor_authentication_helpers_spec.rb +48 -0
  47. data/spec/spec_helper.rb +1 -0
  48. data/spec/support/authenticated_model_helper.rb +5 -30
  49. data/spec/support/controller_helper.rb +5 -4
  50. data/spec/support/features_spec_helper.rb +1 -0
  51. data/spec/support/totp_helper.rb +1 -1
  52. data/two_factor_authentication.gemspec +5 -6
  53. metadata +45 -28
  54. data/.travis.yml +0 -29
  55. data/spec/rails_app/config/initializers/secret_token.rb +0 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: ba9192cf04aafc95a917e76b6efef8217fb22152
4
- data.tar.gz: fe60bb1323aead63cb3712857e88bf4eaab08cfc
2
+ SHA256:
3
+ metadata.gz: 3f83f45de8b5992a3311c7d0d47479f7b7121c7460925fa8661d8b7eb98f4f72
4
+ data.tar.gz: 4df0c76ae151691cec84f1d036d79b93094826c892d01edef5d0a847a92407af
5
5
  SHA512:
6
- metadata.gz: 9a3d4c7cd0bb5eb3af1bc322ee3cad89a38bb8316178a9050dc5cce11f006930f3c3b916b41e29a1594f3acbc360814d831c6816e453504c21bd93b00b0834d4
7
- data.tar.gz: e651f87940c8fc7d55b653c010c30da565cd18e2bcfd7708dc12c657dd953199f389aae800973b1a5bbfb6d09fdadf5d7689e3b28fe11403f7df3fcece5f2e15
6
+ metadata.gz: cef6991e8c5032e5779ee0c5ec0348ad1b0010f947748b49da40b42dabb9e2d83ff43106b6be09acae3acbf3cefa20fbc1ef257d10012098d287d03dbd94635a
7
+ data.tar.gz: 509142892d180c5fc74d9da25166a12e45e6570893386a5bba70feb50e91d80212b67ee78dd46451c8d58e7d88a37ed6f081872519bf919fa3757f257fdfdd02
@@ -0,0 +1,77 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ test:
12
+ name: Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }}
13
+ runs-on: ubuntu-latest
14
+ continue-on-error: ${{ matrix.rails == 'main' }}
15
+ timeout-minutes: 20
16
+
17
+ strategy:
18
+ fail-fast: false
19
+ matrix:
20
+ include:
21
+ - ruby: "2.3"
22
+ rails: "5.2"
23
+ - ruby: "2.4"
24
+ rails: "5.2"
25
+ - ruby: "2.5"
26
+ rails: "5.2"
27
+ - ruby: "2.5"
28
+ rails: "6.0"
29
+ - ruby: "2.7"
30
+ rails: "6.0"
31
+ - ruby: "2.5"
32
+ rails: "6.1"
33
+ - ruby: "3.1"
34
+ rails: "6.1"
35
+ - ruby: "2.7"
36
+ rails: "7.0"
37
+ - ruby: "3.2"
38
+ rails: "7.0"
39
+ - ruby: "2.7"
40
+ rails: "7.1"
41
+ - ruby: "3.3"
42
+ rails: "7.1"
43
+ - ruby: "3.1"
44
+ rails: "7.2"
45
+ - ruby: "4.0"
46
+ rails: "7.2"
47
+ - ruby: "3.2"
48
+ rails: "8.0"
49
+ - ruby: "4.0"
50
+ rails: "8.0"
51
+ - ruby: "3.2"
52
+ rails: "8.1"
53
+ - ruby: "4.0"
54
+ rails: "8.1"
55
+ - ruby: "4.0"
56
+ rails: main
57
+
58
+ env:
59
+ RAILS_VERSION: ${{ matrix.rails }}
60
+
61
+ steps:
62
+ - name: Check out repository
63
+ uses: actions/checkout@v7
64
+ with:
65
+ persist-credentials: false
66
+
67
+ - name: Set up Ruby
68
+ uses: ruby/setup-ruby@v1
69
+ with:
70
+ ruby-version: ${{ matrix.ruby }}
71
+ bundler-cache: true
72
+
73
+ - name: Set up test database
74
+ run: bundle exec rake app:db:setup
75
+
76
+ - name: Run specs
77
+ run: bundle exec rake spec
data/.gitignore CHANGED
@@ -21,3 +21,6 @@ dump.rdb
21
21
  .rbenv-version
22
22
  .ruby-gemset
23
23
  .ruby-version
24
+
25
+ # Generator spec output
26
+ /tmp
data/CHANGELOG.md CHANGED
@@ -1,8 +1,43 @@
1
1
  # Change Log
2
2
 
3
- ## [Unreleased](https://github.com/Houdini/two_factor_authentication/tree/HEAD)
3
+ ## [v3.0.0](https://github.com/Houdini/two_factor_authentication/tree/v3.0.0) (2026-10-01)
4
+ [Full Changelog](https://github.com/Houdini/two_factor_authentication/compare/v2.2.0...v3.0.0)
4
5
 
5
- [Full Changelog](https://github.com/Houdini/two_factor_authentication/compare/v1.1.5...HEAD)
6
+ See the [Migration Guide](MIGRATION_GUIDE.md#upgrading-from-220-to-300) for upgrade steps.
7
+
8
+ **Breaking changes:**
9
+
10
+ - Require Rails 5.0 or newer (was 3.1.1). Tested on Ruby 2.3–4.0 with Rails 5.2 through 8.1
11
+ - `is_fully_authenticated?` checks the current Devise scope (or `Devise.default_scope`) instead of always `:user`, and accepts a scope: `is_fully_authenticated?(:admin)`. Before, it returned `true` for other models before the second factor had been entered
12
+ - A rejected code or reaching the attempt limit now responds with 422 instead of 200, so Turbo renders the error
13
+ - JSON requests that still need the second factor get a 401 with a `{"redirect_to": ...}` body instead of an empty 401 [\#143](https://github.com/Houdini/two_factor_authentication/pull/143) ([Kevinrob](https://github.com/Kevinrob))
14
+
15
+ **Fixed bugs:**
16
+
17
+ - Replace `update_attributes` (removed in Rails 6.1) with `update`, which broke `create_direct_otp`, `send_new_otp` and `clear_direct_otp`
18
+ - Store `totp_timestamp` as a `Time`; PostgreSQL rejected the Integer, so every TOTP login failed
19
+ - The generator writes a versioned migration (`ActiveRecord::Migration[x.y]`), which Rails 5.1+ requires
20
+ - The generator no longer uses `File.exists?` (removed in Ruby 3.2) and finds the model relative to the destination root
21
+ - Remove `resend_code` from the route's `only:`, which Rails 8.1 rejects
22
+ - The schema table helpers (`t.second_factor_attempts_count`, `t.totp_timestamp`, ...) no longer call the removed `apply_devise_schema`
23
+ - Remove `totp_timestamp` from the Devise model config; `User.totp_timestamp` raised `NoMethodError`
24
+ - "Sign out" on the two factor page uses `button_to` with `Devise.sign_out_via`, so it works without rails-ujs
25
+ - Support ROTP 5.0, which renamed `ROTP::Base32.random_base32` to `random` [\#171](https://github.com/Houdini/two_factor_authentication/pull/171) ([jaspervandenberg](https://github.com/jaspervandenberg))
26
+
27
+ **Implemented enhancements:**
28
+
29
+ - Add the encrypted OTP secret index concurrently on PostgreSQL [\#197](https://github.com/Houdini/two_factor_authentication/pull/197) ([Lackoftactics](https://github.com/Lackoftactics))
30
+ - Autofocus the code field [\#174](https://github.com/Houdini/two_factor_authentication/pull/174) ([gustavokitman](https://github.com/gustavokitman))
31
+ - Add German translations [\#166](https://github.com/Houdini/two_factor_authentication/pull/166) ([JanBussieck](https://github.com/JanBussieck))
32
+
33
+ **Other changes:**
34
+
35
+ - Replace Travis CI with GitHub Actions
36
+ - Test TOTP sign-in, resending the code, the attempt counter reset and an encrypted-secret model end to end
37
+ - README updates [\#168](https://github.com/Houdini/two_factor_authentication/pull/168) ([MarkFChavez](https://github.com/MarkFChavez)), [\#139](https://github.com/Houdini/two_factor_authentication/pull/139) ([rmm5t](https://github.com/rmm5t))
38
+
39
+ ## [v2.0](https://github.com/Houdini/two_factor_authentication/tree/v2.0) (2017-05-12)
40
+ [Full Changelog](https://github.com/Houdini/two_factor_authentication/compare/v1.1.5...v2.0)
6
41
 
7
42
  **Merged pull requests:**
8
43
 
data/Gemfile CHANGED
@@ -1,31 +1,51 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
- # Specify your gem's dependencies in devise_ip_filter.gemspec
3
+ # Specify your gem's dependencies in two_factor_authentication.gemspec
4
4
  gemspec
5
5
 
6
6
  rails_version = ENV["RAILS_VERSION"] || "default"
7
7
 
8
8
  rails = case rails_version
9
- when "master"
10
- {github: "rails/rails"}
9
+ when "main"
10
+ {github: "rails/rails", branch: "main"}
11
11
  when "default"
12
- "~> 4.1"
12
+ "~> 8.1.0"
13
13
  else
14
- "~> #{rails_version}"
14
+ requirement = rails_version.split('.').length == 2 ? "#{rails_version}.0" : rails_version
15
+ "~> #{requirement}"
15
16
  end
16
17
 
17
18
  gem "rails", rails
18
19
 
19
- if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.2.0')
20
- gem "test-unit", "~> 3.0"
20
+ # Rails main freezes controller default_url_options; rspec-rails 8.0.4 still
21
+ # mutates it in feature specs. Drop once a release includes rspec/rspec-rails#2907.
22
+ gem "rspec-rails", github: "rspec/rspec-rails", branch: "main" if rails_version == "main"
23
+
24
+ ruby_version = Gem::Version.new(RUBY_VERSION)
25
+
26
+ gem "test-unit", "~> 3.0"
27
+
28
+ if ruby_version < Gem::Version.new('2.5.0')
29
+ gem 'nokogiri', '~> 1.10.10'
30
+ elsif ruby_version < Gem::Version.new('2.6.0')
31
+ gem 'nokogiri', '~> 1.12.5'
21
32
  end
22
33
 
34
+ gem 'loofah', '< 2.21' if ruby_version < Gem::Version.new('2.5.0')
35
+ gem 'psych', '< 5' if rails_version == '7.1' && ruby_version < Gem::Version.new('3.0.0')
36
+
23
37
  group :test, :development do
24
- gem 'sqlite3'
38
+ gem 'ostruct' if ruby_version >= Gem::Version.new('4.0.0')
39
+ case rails_version
40
+ when '5.2', '6.0', '6.1', '7.0'
41
+ gem 'sqlite3', '~> 1.4'
42
+ else
43
+ gem 'sqlite3'
44
+ end
45
+ gem 'sprockets-rails'
25
46
  end
26
47
 
27
48
  group :test do
28
49
  gem 'rack_session_access'
29
50
  gem 'ammeter'
30
- gem 'pry'
31
51
  end
@@ -0,0 +1,143 @@
1
+ # Migration Guide
2
+
3
+ ## Upgrading from 2.2.0 to 3.0.0
4
+
5
+ Version 3.0.0 brings the gem up to date with current Ruby, Rails and Devise, and
6
+ fixes several bugs that made 2.2.0 unusable on Rails 6.1+, Ruby 3.2+ and
7
+ PostgreSQL. Most apps only need to bump the gem version, but a few behaviours
8
+ changed. Go through the checklist below.
9
+
10
+ ### 1. Check your Ruby and Rails versions
11
+
12
+ - **Rails 5.0 or newer is now required** (it was `>= 3.1.1`). If you are on
13
+ Rails 3.x or 4.x, stay on 2.2.0.
14
+ - The gem is tested on Ruby 2.3 – 4.0 with Rails 5.2, 6.0, 6.1, 7.0, 7.1,
15
+ 7.2, 8.0 and 8.1. Rails 5.0 and 5.1 are allowed by the gemspec but are not
16
+ tested.
17
+
18
+ ### 2. Fix old unversioned migrations
19
+
20
+ Migrations generated by 2.2.0 and earlier inherit from
21
+ `ActiveRecord::Migration` without a version. Rails 5.1+ refuses to run them,
22
+ which breaks `db:migrate` on a fresh database (for example in CI). Add the
23
+ Rails version the migration was written for:
24
+
25
+ ```ruby
26
+ # Before
27
+ class TwoFactorAuthenticationAddToUsers < ActiveRecord::Migration
28
+
29
+ # After
30
+ class TwoFactorAuthenticationAddToUsers < ActiveRecord::Migration[5.0]
31
+ ```
32
+
33
+ Migrations that have already run are not executed again, so this only matters
34
+ when the database is rebuilt from migrations.
35
+
36
+ The generator (`rails g two_factor_authentication MODEL`) now writes a
37
+ versioned migration. On PostgreSQL it also builds the
38
+ `encrypted_otp_secret_key` index with `algorithm: :concurrently` and
39
+ `disable_ddl_transaction!` so adding it does not lock the table.
40
+
41
+ ### 3. Pass a scope to `is_fully_authenticated?` for non-`:user` models
42
+
43
+ `is_fully_authenticated?` used to read the `:user` warden session no matter
44
+ which model was signed in. For any other model (e.g. `Admin`) it returned
45
+ `true` before the second factor had been entered, so a
46
+ `confirm_two_factor_authenticated` guard did not protect that model.
47
+
48
+ It now checks:
49
+
50
+ 1. the scope of the current Devise controller (`resource_name`), or
51
+ 2. `Devise.default_scope` outside Devise controllers, or
52
+ 3. the scope you pass in.
53
+
54
+ Inside a Devise controller, such as the `RegistrationsController` guard in the
55
+ README, nothing changes for `:user` and other models are now protected. In your
56
+ own controllers, pass the scope explicitly if you have more than one 2FA model,
57
+ or if your default scope is not the model you want to check:
58
+
59
+ ```ruby
60
+ # Before: always checked :user
61
+ return if is_fully_authenticated?
62
+
63
+ # After
64
+ return if is_fully_authenticated?(:admin)
65
+ ```
66
+
67
+ ### 4. Expect `422` from the code form
68
+
69
+ When a submitted code is wrong, or the attempt limit is reached, the
70
+ `update` action now responds with **422 Unprocessable Entity** instead of 200.
71
+ Turbo (the default since Rails 7) ignores a 200 response to a form submission,
72
+ so the error page was never shown.
73
+
74
+ - Update any request or controller specs that expect a 200 after a wrong code.
75
+ - If you override `after_two_factor_fail_for` in a custom controller, render
76
+ with `status: 422` as well:
77
+
78
+ ```ruby
79
+ def after_two_factor_fail_for(resource)
80
+ # ...
81
+ render :show, status: 422
82
+ end
83
+ ```
84
+
85
+ ### 5. Update overridden views
86
+
87
+ If you copied `app/views/devise/two_factor_authentication/show.html.erb` into
88
+ your app, your copy does not get these changes:
89
+
90
+ - **Sign out** now uses `button_to` with `Devise.sign_out_via`.
91
+ `link_to ..., method: :delete` needs `rails-ujs`, which Rails 7+ apps no
92
+ longer ship, so the link sends a GET and fails.
93
+
94
+ ```erb
95
+ <%# Before %>
96
+ <%= link_to "Sign out", destroy_user_session_path, :method => :delete %>
97
+
98
+ <%# After %>
99
+ <%= button_to "Sign out", destroy_user_session_path, :method => :delete %>
100
+ ```
101
+
102
+ - The code field is autofocused: `text_field_tag :code, '', autofocus: true`.
103
+ - The stray `action: :get` option was removed from the "Resend Code" links.
104
+
105
+ ### 6. Check JSON clients
106
+
107
+ When a signed-in user still needs the second factor, a JSON request used to
108
+ get an empty `401`. It now gets a `401` with a body that tells the client
109
+ where to send the user:
110
+
111
+ ```json
112
+ { "redirect_to": "/users/two_factor_authentication" }
113
+ ```
114
+
115
+ The user's return location is set to `root_path(format: :html)`, so **your app
116
+ must define a `root` route** to use this. HTML requests are still redirected,
117
+ and every other format still gets an empty `401`.
118
+
119
+ ### 7. Things that now work without changes
120
+
121
+ These were broken in 2.2.0 and need no action, but you can remove any
122
+ workarounds you added for them:
123
+
124
+ - `create_direct_otp`, `send_new_otp` and `clear_direct_otp` no longer raise
125
+ `NoMethodError` on Rails 6.1+ (they called the removed `update_attributes`).
126
+ - A successful TOTP login no longer fails on PostgreSQL with
127
+ `PG::DatetimeFieldOverflow`. `totp_timestamp` is now stored as a `Time`;
128
+ existing values keep working.
129
+ - The schema helpers (`t.second_factor_attempts_count`, `t.totp_timestamp`,
130
+ ...) work again in `create_table` and `change_table`.
131
+ - `rails g two_factor_authentication MODEL` works on Ruby 3.2+ and no longer
132
+ has to be run from the app root.
133
+ - The routes load on Rails 8.1 (which rejected `resend_code` in `only:`).
134
+ - ROTP 5.0, which renamed `ROTP::Base32.random_base32` to `random`, is
135
+ supported.
136
+ - `User.totp_timestamp` is no longer defined as a class method. It read a
137
+ Devise setting that does not exist and always raised; the
138
+ `totp_timestamp` column on each record is unaffected.
139
+
140
+ ## Upgrading from 1.x to 2.x
141
+
142
+ See [Upgrading from version 1.X to 2.X](README.md#upgrading-from-version-1x-to-2x)
143
+ in the README.
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
 
@@ -54,7 +54,7 @@ migration in `db/migrate/`, which will add the following columns to your table:
54
54
  #### Manual initial setup
55
55
 
56
56
  If you prefer to set up the model and migration manually, add the
57
- `:two_factor_authentication` option to your existing devise options, such as:
57
+ `:two_factor_authenticatable` option to your existing devise options, such as:
58
58
 
59
59
  ```ruby
60
60
  devise :database_authenticatable, :registerable, :recoverable, :rememberable,
@@ -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,25 +235,26 @@ steps:
224
235
 
225
236
  Open the generated file, and replace its contents with the following:
226
237
  ```ruby
227
- class PopulateEncryptedOtpFields < ActiveRecord::Migration
228
- def up
229
- User.reset_column_information
230
-
231
- User.find_each do |user|
232
- user.otp_secret_key = user.read_attribute('otp_secret_key')
233
- user.save!
234
- end
235
- end
236
-
237
- def down
238
- User.reset_column_information
239
-
240
- User.find_each do |user|
241
- user.otp_secret_key = ROTP::Base32.random_base32
242
- user.save!
243
- end
244
- end
245
- end
238
+ class PopulateEncryptedOtpFields < ActiveRecord::Migration[8.1]
239
+ def up
240
+ User.reset_column_information
241
+
242
+ User.find_each do |user|
243
+ user.otp_secret_key = user.read_attribute('otp_secret_key')
244
+ user.save!
245
+ end
246
+ end
247
+
248
+ def down
249
+ User.reset_column_information
250
+
251
+ User.find_each do |user|
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)
255
+ end
256
+ end
257
+ end
246
258
  ```
247
259
 
248
260
  5. Generate a migration to remove the `:otp_secret_key` column:
@@ -255,14 +267,191 @@ 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
+
287
+ #### Critical Security Note! Add before_action to your user registration controllers
288
+
289
+ You should have a file registrations_controller.rb in your controllers folder
290
+ to overwrite/customize user registrations. It should include the lines below, for 2FA protection of user model updates, meaning that users can only access the users/edit page after confirming 2FA fully, not simply by logging in. Otherwise the entire 2FA system can be bypassed!
291
+
292
+ ```ruby
293
+ class RegistrationsController < Devise::RegistrationsController
294
+ before_action :confirm_two_factor_authenticated, except: [:new, :create, :cancel]
295
+
296
+ protected
297
+
298
+ def confirm_two_factor_authenticated
299
+ return if is_fully_authenticated?
300
+
301
+ flash[:error] = t('devise.errors.messages.user_not_authenticated')
302
+ redirect_to user_two_factor_authentication_url
303
+ end
304
+ end
305
+ ```
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
+
321
+ #### Critical Security Note! Add 2FA validation to your custom user actions
322
+
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.
327
+
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:
331
+
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.
404
+
405
+
266
406
  ### Example App
267
407
 
268
408
  [TwoFactorAuthenticationExample](https://github.com/Houdini/TwoFactorAuthenticationExample)
409
+
410
+
411
+ ### Example user actions
412
+
413
+ to use an ENV VAR for the 2FA encryption key:
414
+
415
+ config.otp_secret_encryption_key = ENV['OTP_SECRET_ENCRYPTION_KEY']
416
+
417
+ to set up TOTP for Google Authenticator for user:
418
+
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.
422
+
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.
425
+
426
+ additional note:
427
+
428
+ ```
429
+ current_user.otp_secret_key
430
+ ```
431
+
432
+ This returns the OTP secret key in plaintext for the user (if you have set the env var) in the console
433
+ the string used for generating the QR given to the user for their Google Auth is something like:
434
+
435
+ otpauth://totp/LABEL?secret=p6wwetjnkjnrcmpd (example secret used here)
436
+
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
438
+
439
+ to set TOTP to DISABLED for a user account:
440
+
441
+ After verifying the existing factor as described above:
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
+ ```
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