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.
- checksums.yaml +5 -5
- data/.github/workflows/ci.yml +77 -0
- data/.gitignore +3 -0
- data/CHANGELOG.md +37 -2
- data/Gemfile +29 -9
- data/MIGRATION_GUIDE.md +143 -0
- data/README.md +217 -28
- 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 +8 -4
- data/lib/two_factor_authentication/models/two_factor_authenticatable.rb +16 -6
- data/lib/two_factor_authentication/orm/active_record.rb +2 -0
- 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/lib/two_factor_authentication.rb +1 -2
- data/spec/controllers/two_factor_authentication_controller_spec.rb +87 -2
- 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 +97 -10
- 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/20140403184646_devise_create_users.rb +1 -1
- data/spec/rails_app/db/migrate/20140407172619_two_factor_authentication_add_to_users.rb +1 -1
- data/spec/rails_app/db/migrate/20140407215513_add_nickanme_to_users.rb +1 -1
- data/spec/rails_app/db/migrate/20151224171231_add_encrypted_columns_to_user.rb +1 -1
- data/spec/rails_app/db/migrate/20151224180310_populate_otp_column.rb +1 -1
- data/spec/rails_app/db/migrate/20151228230340_remove_otp_secret_key_from_user.rb +1 -1
- data/spec/rails_app/db/migrate/20160209032439_devise_create_admins.rb +1 -1
- 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 +51 -36
- 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/spec/support/totp_helper.rb +1 -1
- data/two_factor_authentication.gemspec +5 -6
- metadata +45 -28
- data/.travis.yml +0 -29
- data/spec/rails_app/config/initializers/secret_token.rb +0 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3f83f45de8b5992a3311c7d0d47479f7b7121c7460925fa8661d8b7eb98f4f72
|
|
4
|
+
data.tar.gz: 4df0c76ae151691cec84f1d036d79b93094826c892d01edef5d0a847a92407af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
data/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,43 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
-
## [
|
|
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
|
-
[
|
|
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
|
|
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 "
|
|
10
|
-
{github: "rails/rails"}
|
|
9
|
+
when "main"
|
|
10
|
+
{github: "rails/rails", branch: "main"}
|
|
11
11
|
when "default"
|
|
12
|
-
"~>
|
|
12
|
+
"~> 8.1.0"
|
|
13
13
|
else
|
|
14
|
-
"
|
|
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
|
-
|
|
20
|
-
|
|
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 '
|
|
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
|
data/MIGRATION_GUIDE.md
ADDED
|
@@ -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
|
[](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
|
|
|
@@ -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
|
-
`:
|
|
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
|
-
|
|
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,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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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.
|
|
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
|
|