devise-two-factor 6.1.0 → 6.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84d4fba8bbdcee4f8d8b00c5d2662dc5e8d78cc60e7f0502c7e0e9d5fc93a4d4
4
- data.tar.gz: 33a77876910f588992917ebb123148f26d82b26fbe713f944e59f3b720c4978c
3
+ metadata.gz: 4513e96354e689136149f21dbd447785cc74113a66725bed6153e44758a1709b
4
+ data.tar.gz: 9e83133ebdc4166577129276188688780aeec065a5fead7861073496210215e9
5
5
  SHA512:
6
- metadata.gz: d2f69c9f760278e3a1a40d5913b786468d9d4d12b54e93dd5d5437cfb5edc669f21a56953ea817d66722cbe305190762dcb86cef80009a0fff718e86a39726be
7
- data.tar.gz: e3086e4034e6208e064f81e5845b1027b5e7c19d38ddd4788c615fe1a837772f1330aba234a30fb3f7f9cc549dbed3864da9f70249aef04b9efa066c8284733c
6
+ metadata.gz: a119f4f3f825409f6cea77f934fe7e35fa741dfb1b242c33a59dd523eb43ef2270b3dda80d1403f4baf7ade03271bbd4b145d21c6c390eeb223e00f0aadb6d29
7
+ data.tar.gz: 0f31866cb9c1523ff94095d2b43cf819d2a06a658a2dad94383d93e4c6c47d9e791092090cf8063f351c7727df6f2146e4bf22bf5a204c0b1f80aaf8a4fec3fb
@@ -12,17 +12,21 @@ jobs:
12
12
  fail-fast: false
13
13
  matrix:
14
14
  # Due to https://github.com/actions/runner/issues/849, we should quote versions
15
- ruby: ['3.1', '3.2', '3.3', 'truffleruby-head']
16
- rails: ['7.0', '7.1', '7.2', '8.0']
15
+ ruby: ['3.1', '3.2', '3.3', '3.4', 'truffleruby-head']
16
+ rails: ['7.0', '7.1', '7.2', '8.0', '8.1']
17
17
  exclude:
18
18
  - ruby: '3.1'
19
19
  rails: '8.0'
20
+ - ruby: '3.1'
21
+ rails: '8.1'
22
+ - ruby: '3.4'
23
+ rails: '7.0'
20
24
 
21
25
  name: Ruby ${{ matrix.ruby }}, Rails ${{ matrix.rails }}
22
26
  env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps
23
27
  BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails }}.gemfile
24
28
  steps:
25
- - uses: actions/checkout@v4
29
+ - uses: actions/checkout@v6
26
30
  - name: Set up Ruby
27
31
  uses: ruby/setup-ruby@v1
28
32
  with:
@@ -17,7 +17,7 @@ jobs:
17
17
 
18
18
  steps:
19
19
  # Set up
20
- - uses: actions/checkout@v4
20
+ - uses: actions/checkout@v6
21
21
  - name: Set up Ruby
22
22
  uses: ruby/setup-ruby@v1
23
23
  with:
data/Appraisals CHANGED
@@ -17,3 +17,8 @@ appraise "rails-8.0" do
17
17
  gem 'railties', '~> 8.0.0'
18
18
  gem 'activesupport', '~> 8.0.0'
19
19
  end
20
+
21
+ appraise "rails-8.1" do
22
+ gem 'railties', '8.1.0'
23
+ gem 'activesupport', '8.1.0'
24
+ end
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 6.3.0
6
+
7
+ - Fixed timing to be consistent when Devise paranoid mode is active.
8
+
9
+ ## 6.2.0
10
+
11
+ - Rails 8.1 support
12
+
13
+ ## 6.1.0
14
+
15
+ - Rails 8 support
16
+
5
17
  ## 6.0.0
6
18
 
7
19
  **Breaking Changes**
data/README.md CHANGED
@@ -82,7 +82,7 @@ This generator will:
82
82
 
83
83
  1. Edit `app/models/MODEL.rb` (where MODEL is your model name):
84
84
  * add the `:two_factor_authenticatable` devise module
85
- * remove the `:database_authenticatable` if present because it is incompatible with `:two_factor_authenticatable`
85
+ * remove the `:database_authenticatable` devise module, if present; having both modules enabled will lead to issues described below.
86
86
  1. Add a Warden config block to your Devise initializer, which enables the strategies required for two-factor authentication.
87
87
 
88
88
  Remember to apply the new migration after you run the generator:
@@ -107,9 +107,9 @@ Next you need to whitelist `:otp_attempt` as a permitted parameter in Devise `:s
107
107
  end
108
108
  ```
109
109
 
110
- Finally you should verify that `:database_authenticatable` is **not** being loaded by your model. The generator will try to remove it, but if you have a non-standard Devise setup, this step may fail.
110
+ Finally you should verify that `:database_authenticatable` is **not** being loaded by your model. The generator will try to remove it, but if you have a non-standard Devise setup, this step may fail. `:two_factor_authenticatable` includes all of `:database_authenticatable`'s functionality; it will still allow login without two-factor authentication until you enable it on your model's records with `otp_required_for_login`.
111
111
 
112
- **Loading both `:database_authenticatable` and `:two_factor_authenticatable` in a model is a security issue** It will allow users to bypass two-factor authenticatable due to the way Warden handles cascading strategies!
112
+ **Loading both `:database_authenticatable` and `:two_factor_authenticatable` in a model is a security issue.** It will allow users to bypass two-factor authentication regardless of how `otp_required_for_login` is set due to the way Warden handles cascading strategies!
113
113
 
114
114
  ## Designing Your Workflow
115
115
 
@@ -155,10 +155,7 @@ At Tinfoil Security, we opted to use the excellent [rqrcode-rails3](https://gith
155
155
  If you decide to do this you'll need to generate a URI to act as the source for the QR code. This can be done using the `User#otp_provisioning_uri` method.
156
156
 
157
157
  ```ruby
158
- issuer = 'Your App'
159
- label = "#{issuer}:#{current_user.email}"
160
-
161
- current_user.otp_provisioning_uri(label, issuer: issuer)
158
+ current_user.otp_provisioning_uri(current_user.email, issuer: 'Your App')
162
159
 
163
160
  # > "otpauth://totp/Your%20App:user@example.com?secret=[otp_secret]&issuer=Your+App"
164
161
  ```
@@ -236,7 +233,7 @@ class AddDeviseTwoFactorBackupableToUsers < ActiveRecord::Migration
236
233
  end
237
234
  ```
238
235
 
239
- #### MySQL
236
+ #### MySQL, SQL Server, other databases without an array string type
240
237
 
241
238
  ```ruby
242
239
  # migration
@@ -15,9 +15,9 @@ Gem::Specification.new do |s|
15
15
  s.test_files = `git ls-files -- spec/*`.split("\n")
16
16
  s.require_paths = ['lib']
17
17
 
18
- s.add_runtime_dependency 'railties', '>= 7.0', '< 8.1'
19
- s.add_runtime_dependency 'activesupport', '>= 7.0', '< 8.1'
20
- s.add_runtime_dependency 'devise', '~> 4.0'
18
+ s.add_runtime_dependency 'railties', '>= 7.0', '< 8.2'
19
+ s.add_runtime_dependency 'activesupport', '>= 7.0', '< 8.2'
20
+ s.add_runtime_dependency 'devise', '>= 4.0', '< 5.0'
21
21
  s.add_runtime_dependency 'rotp', '~> 6.0'
22
22
 
23
23
  s.add_development_dependency 'activemodel'
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "railties", "8.1.0"
6
+ gem "activesupport", "8.1.0"
7
+
8
+ gemspec path: "../"
@@ -1,3 +1,4 @@
1
+ require 'logger'
1
2
  require 'devise'
2
3
  require 'devise_two_factor/models'
3
4
  require 'devise_two_factor/strategies'
@@ -41,12 +41,11 @@ module Devise
41
41
 
42
42
  if self.consumed_timestep
43
43
  # reconstruct the timestamp of the last consumed timestep
44
- after_timestamp = self.consumed_timestep * otp.interval
44
+ after_timestamp = self.consumed_timestep * totp.interval
45
45
  end
46
46
 
47
- if totp.verify(code.gsub(/\s+/, ""), drift_behind: self.class.otp_allowed_drift, drift_ahead: self.class.otp_allowed_drift, after: after_timestamp)
48
- return consume_otp!
49
- end
47
+ timestamp = totp.verify(code.gsub(/\s+/, ""), drift_behind: self.class.otp_allowed_drift, drift_ahead: self.class.otp_allowed_drift, after: after_timestamp)
48
+ return consume_otp!(totp, timestamp) if timestamp
50
49
 
51
50
  false
52
51
  end
@@ -59,11 +58,6 @@ module Devise
59
58
  otp.at(Time.now)
60
59
  end
61
60
 
62
- # ROTP's TOTP#timecode is private, so we duplicate it here
63
- def current_otp_timestep
64
- Time.now.utc.to_i / otp.interval
65
- end
66
-
67
61
  def otp_provisioning_uri(account, options = {})
68
62
  otp_secret = options[:otp_secret] || self.otp_secret
69
63
  ROTP::TOTP.new(otp_secret, options).provisioning_uri(account)
@@ -78,10 +72,13 @@ module Devise
78
72
 
79
73
  # An OTP cannot be used more than once in a given timestep
80
74
  # Storing timestep of last valid OTP is sufficient to satisfy this requirement
81
- def consume_otp!
82
- if self.consumed_timestep != current_otp_timestep
83
- self.consumed_timestep = current_otp_timestep
75
+ def consume_otp!(otp, timestamp)
76
+ timestep = timestamp / otp.interval
77
+
78
+ if self.consumed_timestep != timestep
79
+ self.consumed_timestep = timestep
84
80
  save!(validate: false)
81
+
85
82
  return true
86
83
  end
87
84
 
@@ -34,6 +34,9 @@ module Devise
34
34
  def invalidate_otp_backup_code!(code)
35
35
  codes = self.otp_backup_codes || []
36
36
 
37
+ # Should we still have some other kind of non iterable result, terminate.
38
+ raise TypeError.new("`otp_backup_codes` is expected to be an Array, got #{codes.class.name}. Hint: If your database does not support arrays, does your model correctly `serialize :otp_backup_codes, Array`?") unless codes.is_a?(Array)
39
+
37
40
  codes.each do |backup_code|
38
41
  next unless Devise::Encryptor.compare(self.class, backup_code, code)
39
42
 
@@ -49,38 +49,66 @@ RSpec.shared_examples 'two_factor_backupable' do
49
49
  end
50
50
 
51
51
  describe '#invalidate_otp_backup_code!' do
52
- before do
53
- @plaintext_codes = subject.generate_otp_backup_codes!
54
- end
55
52
 
56
- context 'given an invalid recovery code' do
57
- it 'returns false' do
58
- expect(subject.invalidate_otp_backup_code!('password')).to be false
59
- end
60
- end
61
53
 
62
- context 'given a valid recovery code' do
63
- it 'returns true' do
64
- @plaintext_codes.each do |code|
65
- expect(subject.invalidate_otp_backup_code!(code)).to be true
54
+ describe "#invalidate_otp_backup_code!" do
55
+ context "with no backup codes" do
56
+ it "does nothing" do
57
+ expect(subject.invalidate_otp_backup_code!("foo")).to be false
66
58
  end
67
59
  end
68
60
 
69
- it 'invalidates that recovery code' do
70
- code = @plaintext_codes.sample
61
+ context "with an array of backup codes, newly generated" do
62
+ before do
63
+ @plaintext_codes = subject.generate_otp_backup_codes!
64
+ end
65
+
66
+ context 'given an invalid recovery code' do
67
+ it 'returns false' do
68
+ expect(subject.invalidate_otp_backup_code!('password')).to be false
69
+ end
70
+ end
71
+
72
+ context 'given a valid recovery code' do
73
+ it 'returns true' do
74
+ @plaintext_codes.each do |code|
75
+ expect(subject.invalidate_otp_backup_code!(code)).to be true
76
+ end
77
+ end
71
78
 
72
- subject.invalidate_otp_backup_code!(code)
73
- expect(subject.invalidate_otp_backup_code!(code)).to be false
79
+ it 'invalidates that recovery code' do
80
+ code = @plaintext_codes.sample
81
+
82
+ subject.invalidate_otp_backup_code!(code)
83
+ expect(subject.invalidate_otp_backup_code!(code)).to be false
84
+ end
85
+
86
+ it 'does not invalidate the other recovery codes' do
87
+ code = @plaintext_codes.sample
88
+ subject.invalidate_otp_backup_code!(code)
89
+
90
+ @plaintext_codes.delete(code)
91
+
92
+ @plaintext_codes.each do |code|
93
+ expect(subject.invalidate_otp_backup_code!(code)).to be true
94
+ end
95
+ end
96
+ end
74
97
  end
75
98
 
76
- it 'does not invalidate the other recovery codes' do
77
- code = @plaintext_codes.sample
78
- subject.invalidate_otp_backup_code!(code)
99
+ context "with backup codes as a string" do
100
+ before do
101
+ @plaintext_codes = subject.generate_otp_backup_codes!
79
102
 
80
- @plaintext_codes.delete(code)
103
+ # Simulates database adapters that don't understand `t.string :otp_backup_codes, type: array` properly
104
+ # such as SQL Server; and have just returned the serialized string still.
105
+ # and the user not having done:
106
+ # `serialize :otp_backup_codes, Array` in their model
107
+ subject.otp_backup_codes = subject.otp_backup_codes.to_json
108
+ end
81
109
 
82
- @plaintext_codes.each do |code|
83
- expect(subject.invalidate_otp_backup_code!(code)).to be true
110
+ it "raises a meaningful error" do
111
+ expect { subject.invalidate_otp_backup_code!("flork") }.to raise_error(TypeError)
84
112
  end
85
113
  end
86
114
  end
@@ -4,12 +4,18 @@ module Devise
4
4
 
5
5
  def authenticate!
6
6
  resource = mapping.to.find_for_database_authentication(authentication_hash)
7
+
8
+ hashed = false
7
9
  # We authenticate in two cases:
8
10
  # 1. The password and the OTP are correct
9
11
  # 2. The password is correct, and OTP is not required for login
10
12
  # We check the OTP, then defer to DatabaseAuthenticatable
11
- if validate(resource) { validate_otp(resource) }
13
+ if validate(resource) { hashed = true; validate_otp(resource) }
12
14
  super
15
+ else
16
+ # Paranoid mode: do the expensive hash even when resource is nil,
17
+ # to avoid timing-based user enumeration.
18
+ mapping.to.new.password = password if !hashed && Devise.paranoid
13
19
  end
14
20
 
15
21
  fail(Devise.paranoid ? :invalid : :not_found_in_database) unless resource
@@ -5,7 +5,7 @@ module Devise
5
5
  def authenticate!
6
6
  resource = mapping.to.find_for_database_authentication(authentication_hash)
7
7
 
8
- if validate(resource) { resource.invalidate_otp_backup_code!(params[scope]['otp_attempt']) }
8
+ if validate(resource) { validate_backup_code(resource) }
9
9
  super
10
10
  end
11
11
 
@@ -15,6 +15,11 @@ module Devise
15
15
  # but database authenticatable automatically halts on a bad password
16
16
  @halted = false if @result == :failure
17
17
  end
18
+
19
+ def validate_backup_code(resource)
20
+ return if params[scope].nil? || params[scope]['otp_attempt'].nil?
21
+ resource.invalidate_otp_backup_code!(params[scope]['otp_attempt'])
22
+ end
18
23
  end
19
24
  end
20
25
  end
@@ -1,3 +1,3 @@
1
1
  module DeviseTwoFactor
2
- VERSION = '6.1.0'.freeze
2
+ VERSION = '6.3.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: devise-two-factor
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.1.0
4
+ version: 6.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Quinn Wilton
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-11-11 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: railties
@@ -19,7 +18,7 @@ dependencies:
19
18
  version: '7.0'
20
19
  - - "<"
21
20
  - !ruby/object:Gem::Version
22
- version: '8.1'
21
+ version: '8.2'
23
22
  type: :runtime
24
23
  prerelease: false
25
24
  version_requirements: !ruby/object:Gem::Requirement
@@ -29,7 +28,7 @@ dependencies:
29
28
  version: '7.0'
30
29
  - - "<"
31
30
  - !ruby/object:Gem::Version
32
- version: '8.1'
31
+ version: '8.2'
33
32
  - !ruby/object:Gem::Dependency
34
33
  name: activesupport
35
34
  requirement: !ruby/object:Gem::Requirement
@@ -39,7 +38,7 @@ dependencies:
39
38
  version: '7.0'
40
39
  - - "<"
41
40
  - !ruby/object:Gem::Version
42
- version: '8.1'
41
+ version: '8.2'
43
42
  type: :runtime
44
43
  prerelease: false
45
44
  version_requirements: !ruby/object:Gem::Requirement
@@ -49,21 +48,27 @@ dependencies:
49
48
  version: '7.0'
50
49
  - - "<"
51
50
  - !ruby/object:Gem::Version
52
- version: '8.1'
51
+ version: '8.2'
53
52
  - !ruby/object:Gem::Dependency
54
53
  name: devise
55
54
  requirement: !ruby/object:Gem::Requirement
56
55
  requirements:
57
- - - "~>"
56
+ - - ">="
58
57
  - !ruby/object:Gem::Version
59
58
  version: '4.0'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '5.0'
60
62
  type: :runtime
61
63
  prerelease: false
62
64
  version_requirements: !ruby/object:Gem::Requirement
63
65
  requirements:
64
- - - "~>"
66
+ - - ">="
65
67
  - !ruby/object:Gem::Version
66
68
  version: '4.0'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '5.0'
67
72
  - !ruby/object:Gem::Dependency
68
73
  name: rotp
69
74
  requirement: !ruby/object:Gem::Requirement
@@ -164,7 +169,6 @@ dependencies:
164
169
  version: '13'
165
170
  description: Devise-Two-Factor is a minimalist extension to Devise which offers support
166
171
  for two-factor authentication through the TOTP scheme.
167
- email:
168
172
  executables: []
169
173
  extensions: []
170
174
  extra_rdoc_files: []
@@ -189,6 +193,7 @@ files:
189
193
  - gemfiles/rails_7.1.gemfile
190
194
  - gemfiles/rails_7.2.gemfile
191
195
  - gemfiles/rails_8.0.gemfile
196
+ - gemfiles/rails_8.1.gemfile
192
197
  - lib/devise-two-factor.rb
193
198
  - lib/devise_two_factor/models.rb
194
199
  - lib/devise_two_factor/models/two_factor_authenticatable.rb
@@ -208,7 +213,6 @@ homepage: https://github.com/devise-two-factor/devise-two-factor
208
213
  licenses:
209
214
  - MIT
210
215
  metadata: {}
211
- post_install_message:
212
216
  rdoc_options: []
213
217
  require_paths:
214
218
  - lib
@@ -223,8 +227,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
223
227
  - !ruby/object:Gem::Version
224
228
  version: '0'
225
229
  requirements: []
226
- rubygems_version: 3.5.22
227
- signing_key:
230
+ rubygems_version: 4.0.3
228
231
  specification_version: 4
229
232
  summary: Barebones two-factor authentication with Devise
230
233
  test_files: