devise-two-factor 4.1.1 → 6.4.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +6 -0
  3. data/.github/workflows/ci.yml +3 -18
  4. data/.github/workflows/push.yml +28 -0
  5. data/.markdownlint.json +6 -0
  6. data/Appraisals +9 -34
  7. data/CHANGELOG.md +59 -0
  8. data/README.md +110 -67
  9. data/Rakefile +2 -0
  10. data/SECURITY.md +5 -0
  11. data/UPGRADING.md +218 -2
  12. data/devise-two-factor.gemspec +8 -15
  13. data/gemfiles/{rails_4.2.gemfile → rails_7.2.gemfile} +2 -2
  14. data/gemfiles/{rails_5.0.gemfile → rails_8.0.gemfile} +2 -2
  15. data/gemfiles/{rails_4.1.gemfile → rails_8.1.gemfile} +2 -2
  16. data/lib/devise-two-factor.rb +12 -5
  17. data/lib/devise_two_factor/models/two_factor_authenticatable.rb +41 -29
  18. data/lib/devise_two_factor/models/two_factor_backupable.rb +6 -2
  19. data/lib/devise_two_factor/spec_helpers/two_factor_authenticatable_shared_examples.rb +6 -18
  20. data/lib/devise_two_factor/spec_helpers/two_factor_backupable_shared_examples.rb +53 -24
  21. data/lib/devise_two_factor/strategies/two_factor_authenticatable.rb +8 -2
  22. data/lib/devise_two_factor/strategies/two_factor_backupable.rb +6 -4
  23. data/lib/devise_two_factor/version.rb +1 -1
  24. data/lib/generators/devise_two_factor/devise_two_factor_generator.rb +2 -7
  25. data/spec/devise/models/two_factor_authenticatable_spec.rb +11 -69
  26. data/spec/devise/models/two_factor_backupable_spec.rb +11 -2
  27. data/spec/spec_helper.rb +0 -1
  28. metadata +42 -134
  29. checksums.yaml.gz.sig +0 -0
  30. data/certs/tinfoil-cacert.pem +0 -41
  31. data/certs/tinfoilsecurity-gems-cert.pem +0 -35
  32. data/gemfiles/rails_5.1.gemfile +0 -8
  33. data/gemfiles/rails_5.2.gemfile +0 -8
  34. data/gemfiles/rails_6.0.gemfile +0 -8
  35. data/gemfiles/rails_6.1.gemfile +0 -8
  36. data/gemfiles/rails_7.0.gemfile +0 -8
  37. data.tar.gz.sig +0 -0
  38. metadata.gz.sig +0 -0
data/UPGRADING.md CHANGED
@@ -1,4 +1,220 @@
1
- # Guide to upgrading from 2.x to 3.x
1
+ # Upgrading
2
+
3
+ ## Upgrading from 5.x to 6.x
4
+
5
+ ### save!
6
+
7
+ `consume_otp!` and `invalidate_otp_backup_code!` now call `save!` instead of `save` (or nothing at all in the case of `invalidate_otp_backup_code!`). If you manually called `save`/`save!` after calling `invalidate_otp_backup_code!` you may be able to remove it.
8
+
9
+ ### Secret Lengths
10
+
11
+ The `otp_secret_length` and `otp_backup_code_length` options have changed to be the number of random bytes that are generated.
12
+ If you had configured these values you may want to change them if you wish to keep the same output length.
13
+
14
+ `otp_secret_length` now has a default value of 20, generating a 160 bit secret key with an output length length of 32 bytes.
15
+
16
+ `otp_backup_code_length` now has a default value of 16, generating a 32 byte backup code.
17
+
18
+ ## Upgrading from 4.x to 5.x
19
+
20
+ ### Background
21
+
22
+ #### Database columns in version 4.x and older
23
+
24
+ Versions 4.x and older stored the OTP secret in an attribute called `encrypted_otp_secret` using the [attr_encrypted](https://github.com/attr-encrypted/attr_encrypted) gem. This gem is currently unmaintained which is part of the motivation for moving to Rails encrypted attributes. This attribute was backed by three database columns:
25
+
26
+ ```
27
+ encrypted_otp_secret
28
+ encrypted_otp_secret_iv
29
+ encrypted_otp_secret_salt
30
+ ```
31
+
32
+ Two other columns were also created:
33
+
34
+ ```
35
+ consumed_timestep
36
+ otp_required_for_login
37
+ ```
38
+
39
+ A fresh install of 4.x would create all five of the database columns above.
40
+
41
+ #### Database columns in version 5.x and later
42
+
43
+ Versions 5+ of this gem uses a single [Rails 7+ encrypted attribute](https://edgeguides.rubyonrails.org/active_record_encryption.html) named `otp_secret`to store the OTP secret in the database table (usually `users` but will be whatever model you picked).
44
+
45
+ A fresh install of 5+ will add the following columns to your `users` table:
46
+
47
+ ```bash
48
+ otp_secret # this replaces encrypted_otp_secret, encrypted_otp_secret_iv, encrypted_otp_secret_salt
49
+ consumed_timestep
50
+ otp_required_for_login
51
+ ```
52
+
53
+ We have attempted to make the upgrade as painless as possible but unfortunately because of the secret storage change, it cannot be as simple as `bundle update devise-two-factor` :heart:
54
+
55
+ ### Assumptions
56
+
57
+ This guide assumes you are upgrading an existing Rails 6 app (with `devise` and `devise-two-factor`) to Rails 7.
58
+
59
+ This gem must be upgraded **as part of a Rails 7 upgrade**. See [the official Rails upgrading guide](https://guides.rubyonrails.org/upgrading_ruby_on_rails.html) for an overview of upgrading Rails.
60
+
61
+ ### Phase 1: Upgrading devise-two-factor as part of Rails 7 upgrade
62
+
63
+ 1. Update the version constraint for Rails in your `Gemfile` to your desired version e.g. `gem "rails", "~> 7.0.3"`
64
+ 1. Run `bundle install` and resolve any issues with dependencies.
65
+ 1. Update the version constraint for `devise-two-factor` in your `Gemfile` to the the latest version (must be at least 5.x e.g. `~> 5.0`
66
+ 1. Run `./bin/rails app:update` as per the [Rails upgrade guide](https://guides.rubyonrails.org/upgrading_ruby_on_rails.html) and tweak the output as required for your app.
67
+ 1. Run `./bin/rails db:migrate` to update your DB based on the changes made by `app:update`
68
+ 1. Add a new `otp_secret` attribute to your user model
69
+ ```bash
70
+ # TODO: replace 'User' in the migration name with the name of your user model
71
+ ./bin/rails g migration AddOtpSecretToUser otp_secret:string
72
+ ./bin/rails db:migrate
73
+ ```
74
+ 1. Add a `legacy_otp_secret` method to your user model e.g. `User`.
75
+ * This method is used by the gem to find and decode the OTP secret from the legacy database columns.
76
+ * The implementation shown below works if you set up devise-two-factor with the settings suggested in the [OLD README](https://github.com/devise-two-factor/devise-two-factor/blob/8d74f5ee45594bf00e60d5d49eb6fcde82c2d2ba/README.md).
77
+ * If you have customised the encryption scheme used to store the OTP secret then you will need to update this method to match.
78
+ * If you are unsure, you should try the method below as is, and if you can still sign in users with OTP enabled then all is well.
79
+ ```ruby
80
+ class User
81
+ # ...
82
+
83
+ private
84
+
85
+ ##
86
+ # Decrypt and return the `encrypted_otp_secret` attribute which was used in
87
+ # prior versions of devise-two-factor
88
+ # @return [String] The decrypted OTP secret
89
+ def legacy_otp_secret
90
+ return nil unless self[:encrypted_otp_secret]
91
+ return nil unless self.class.otp_secret_encryption_key
92
+
93
+ hmac_iterations = 2000 # a default set by the Encryptor gem
94
+ key = self.class.otp_secret_encryption_key
95
+ salt = Base64.decode64(encrypted_otp_secret_salt)
96
+ iv = Base64.decode64(encrypted_otp_secret_iv)
97
+
98
+ raw_cipher_text = Base64.decode64(encrypted_otp_secret)
99
+ # The last 16 bytes of the ciphertext are the authentication tag - we use
100
+ # Galois Counter Mode which is an authenticated encryption mode
101
+ cipher_text = raw_cipher_text[0..-17]
102
+ auth_tag = raw_cipher_text[-16..-1]
103
+
104
+ # this algorithm lifted from
105
+ # https://github.com/attr-encrypted/encryptor/blob/master/lib/encryptor.rb#L54
106
+
107
+ # create an OpenSSL object which will decrypt the AES cipher with 256 bit
108
+ # keys in Galois Counter Mode (GCM). See
109
+ # https://ruby.github.io/openssl/OpenSSL/Cipher.html
110
+ cipher = OpenSSL::Cipher.new('aes-256-gcm')
111
+
112
+ # tell the cipher we want to decrypt. Symmetric algorithms use a very
113
+ # similar process for encryption and decryption, hence the same object can
114
+ # do both.
115
+ cipher.decrypt
116
+
117
+ # Use a Password-Based Key Derivation Function to generate the key actually
118
+ # used for encryption from the key we got as input.
119
+ cipher.key = OpenSSL::PKCS5.pbkdf2_hmac_sha1(key, salt, hmac_iterations, cipher.key_len)
120
+
121
+ # set the Initialization Vector (IV)
122
+ cipher.iv = iv
123
+
124
+ # The tag must be set after calling Cipher#decrypt, Cipher#key= and
125
+ # Cipher#iv=, but before calling Cipher#final. After all decryption is
126
+ # performed, the tag is verified automatically in the call to Cipher#final.
127
+ #
128
+ # If the auth_tag does not verify, then #final will raise OpenSSL::Cipher::CipherError
129
+ cipher.auth_tag = auth_tag
130
+
131
+ # auth_data must be set after auth_tag has been set when decrypting See
132
+ # http://ruby-doc.org/stdlib-2.0.0/libdoc/openssl/rdoc/OpenSSL/Cipher.html#method-i-auth_data-3D
133
+ # we are not adding any authenticated data but OpenSSL docs say this should
134
+ # still be called.
135
+ cipher.auth_data = ''
136
+
137
+ # #update is (somewhat confusingly named) the method which actually
138
+ # performs the decryption on the given chunk of data. Our OTP secret is
139
+ # short so we only need to call it once.
140
+ #
141
+ # It is very important that we call #final because:
142
+ #
143
+ # 1. The authentication tag is checked during the call to #final
144
+ # 2. Block based cipher modes (e.g. CBC) work on fixed size chunks. We need
145
+ # to call #final to get it to process the last chunk properly. The output
146
+ # of #final should be appended to the decrypted value. This isn't
147
+ # required for streaming cipher modes but including it is a best practice
148
+ # so that your code will continue to function correctly even if you later
149
+ # change to a block cipher mode.
150
+ cipher.update(cipher_text) + cipher.final
151
+ end
152
+ end
153
+ ```
154
+ 2. Set up [Rails encrypted secrets](https://edgeguides.rubyonrails.org/active_record_encryption.html)
155
+ ```bash
156
+ ./bin/rails db:encryption:init
157
+ # capture the output and put in encrypted credentials via
158
+ ./bin/rails credentials:edit
159
+ ```
160
+ 3. Complete your Rails 7 upgrade (making whatever other changes are required)
161
+
162
+ You can now deploy your upgraded application and devise-two-factor should work as before.
163
+
164
+ This gem will fall back to **reading** the OTP secret from the legacy columns if it cannot find one in the new `otp_secret` column. When you **write** a new OTP secret it will always be written to the new `otp_secret` column.
165
+
166
+ ### Phase 2: Clean up
167
+
168
+ This "clean up" phase can happen at the same time as your initial deployment but teams managing existing apps will likely want to do clean-up as separate, later deployments.
169
+
170
+ 1. Create a rake task to copy the OTP secret for each user from the legacy column to the new `otp_secret` column. This prepares the way for us to remove the legacy columns in a later step.
171
+ ```ruby
172
+ # lib/tasks/devise_two_factor_migration.rake
173
+
174
+ # Use this as a starting point for your task to migrate your user's OTP secrets.
175
+ namespace :devise_two_factor do
176
+ desc "Copy devise_two_factor OTP secret from old format to new format"
177
+ task copy_otp_secret_to_rails7_encrypted_attr: [:environment] do
178
+ # TODO: change User to your user model
179
+ User.find_each do |user| # find_each finds in batches of 1,000 by default
180
+ otp_secret = user.otp_secret # read from otp_secret column, fall back to legacy columns if new column is empty
181
+ puts "Processing #{user.email}"
182
+ user.update!(otp_secret: otp_secret)
183
+ end
184
+ end
185
+ end
186
+ ```
187
+ 1. Remove the `#legacy_otp_secret` method from your user model (e.g. `User`) because it is no longer required.
188
+ 1. Remove the now unused legacy columns from the database. This assumes you have run a rake task as in the previous step to migrate all the legacy stored secrets to the new storage.
189
+ ```bash
190
+ # TODO: replace 'Users' in migration name with the name of your user model
191
+ ./bin/rails g migration RemoveLegacyDeviseTwoFactorSecretsFromUsers
192
+ ```
193
+ which generates
194
+ ```ruby
195
+ class RemoveLegacyDeviseTwoFactorSecretsFromUsers < ActiveRecord::Migration[7.0]
196
+ def change
197
+ # TODO: change :users to whatever your users table is
198
+
199
+ # WARNING: Only run this when you are confident you have copied the OTP
200
+ # secret for ALL users from `encrypted_otp_secret` to `otp_secret`!
201
+ remove_column :users, :encrypted_otp_secret
202
+ remove_column :users, :encrypted_otp_secret_iv
203
+ remove_column :users, :encrypted_otp_secret_salt
204
+ end
205
+ end
206
+ ```
207
+ 1. Remove `otp_secret_encryption_key` from the model setup. This also assumes you successfully ran the rake task in step 1.
208
+ ```ruby
209
+ # from this:
210
+ devise :two_factor_authenticatable,
211
+ otp_secret_encryption_key: ENV['YOUR_ENCRYPTION_KEY_HERE']
212
+
213
+ # to this:
214
+ devise :two_factor_authenticatable
215
+ ```
216
+
217
+ ## Upgrading from 2.x to 3.x
2
218
 
3
219
  Pull request #76 allows for compatibility with `attr_encrypted` 3.0, which should be used due to a security vulnerability discovered in 2.0.
4
220
 
@@ -18,7 +234,7 @@ class User < ActiveRecord::Base
18
234
  :otp_secret_encryption_key => ENV['DEVISE_TWO_FACTOR_ENCRYPTION_KEY']
19
235
  ```
20
236
 
21
- # Guide to upgrading from 1.x to 2.x
237
+ ## Upgrading from 1.x to 2.x
22
238
 
23
239
  Pull request #43 added a new field to protect against "shoulder-surfing" attacks. If upgrading, you'll need to add the `:consumed_timestep` column to your `Users` model.
24
240
 
@@ -5,26 +5,19 @@ Gem::Specification.new do |s|
5
5
  s.name = 'devise-two-factor'
6
6
  s.version = DeviseTwoFactor::VERSION.dup
7
7
  s.platform = Gem::Platform::RUBY
8
- s.licenses = ['MIT']
8
+ s.license = 'MIT'
9
9
  s.summary = 'Barebones two-factor authentication with Devise'
10
- s.email = 'engineers@tinfoilsecurity.com'
11
- s.homepage = 'https://github.com/tinfoil/devise-two-factor'
12
- s.description = 'Barebones two-factor authentication with Devise'
13
- s.authors = ['Shane Wilton']
10
+ s.homepage = 'https://github.com/devise-two-factor/devise-two-factor'
11
+ s.description = 'Devise-Two-Factor is a minimalist extension to Devise which offers support for two-factor authentication through the TOTP scheme.'
12
+ s.authors = ['Quinn Wilton']
14
13
 
15
- s.cert_chain = [
16
- 'certs/tinfoil-cacert.pem',
17
- 'certs/tinfoilsecurity-gems-cert.pem'
18
- ]
19
- s.signing_key = File.expand_path("~/.ssh/tinfoilsecurity-gems-key.pem") if $0 =~ /gem\z/
20
14
  s.files = `git ls-files`.split("\n").delete_if { |x| x.match('demo/*') }
21
15
  s.test_files = `git ls-files -- spec/*`.split("\n")
22
16
  s.require_paths = ['lib']
23
17
 
24
- s.add_runtime_dependency 'railties', '~> 7.0'
25
- s.add_runtime_dependency 'activesupport', '~> 7.0'
26
- s.add_runtime_dependency 'attr_encrypted', '>= 1.3', '< 5', '!= 2'
27
- s.add_runtime_dependency 'devise', '~> 4.0'
18
+ s.add_runtime_dependency 'railties', '>= 7.2', '< 8.2'
19
+ s.add_runtime_dependency 'activesupport', '>= 7.2', '< 8.2'
20
+ s.add_runtime_dependency 'devise', '>= 4.0', '< 6.0'
28
21
  s.add_runtime_dependency 'rotp', '~> 6.0'
29
22
 
30
23
  s.add_development_dependency 'activemodel'
@@ -32,5 +25,5 @@ Gem::Specification.new do |s|
32
25
  s.add_development_dependency 'bundler', '> 1.0'
33
26
  s.add_development_dependency 'rspec', '> 3'
34
27
  s.add_development_dependency 'simplecov'
35
- s.add_development_dependency 'faker'
28
+ s.add_development_dependency 'rake', '~> 13'
36
29
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- gem "railties", "~> 4.2"
6
- gem "activesupport", "~> 4.2"
5
+ gem "railties", "~> 7.2.0"
6
+ gem "activesupport", "~> 7.2.0"
7
7
 
8
8
  gemspec path: "../"
@@ -2,7 +2,7 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- gem "railties", "~> 5.0"
6
- gem "activesupport", "~> 5.0"
5
+ gem "railties", "~> 8.0.0"
6
+ gem "activesupport", "~> 8.0.0"
7
7
 
8
8
  gemspec path: "../"
@@ -2,7 +2,7 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- gem "railties", "~> 4.1"
6
- gem "activesupport", "~> 4.1"
5
+ gem "railties", "8.1.0"
6
+ gem "activesupport", "8.1.0"
7
7
 
8
8
  gemspec path: "../"
@@ -1,22 +1,29 @@
1
+ require 'logger'
1
2
  require 'devise'
2
3
  require 'devise_two_factor/models'
3
4
  require 'devise_two_factor/strategies'
4
5
 
5
6
  module Devise
6
- # The length of generated OTP secrets
7
+ # The length of randomly generated OTP shared secret (in bytes).
8
+ # The secrets will be base32-encoded and have a length 1.6 times the configured value.
7
9
  mattr_accessor :otp_secret_length
8
- @@otp_secret_length = 24
10
+ @@otp_secret_length = 20
9
11
 
10
12
  # The number of seconds before and after the current
11
13
  # time for which codes will be accepted
12
14
  mattr_accessor :otp_allowed_drift
13
15
  @@otp_allowed_drift = 30
14
16
 
15
- # The key used to encrypt OTP secrets in the database
17
+ # The key used to encrypt OTP secrets in the database in legacy installs.
16
18
  mattr_accessor :otp_secret_encryption_key
17
19
  @@otp_secret_encryption_key = nil
18
20
 
19
- # The length of all generated OTP backup codes
21
+ # These options are passed to the Rails 7+ encrypted attribute
22
+ mattr_accessor :otp_encrypted_attribute_options
23
+ @@otp_encrypted_attribute_options = {}
24
+
25
+ # The length of randomly generated OTP backup codes (in bytes).
26
+ # The codes will be hex-encoded and have a length twice the configured value.
20
27
  mattr_accessor :otp_backup_code_length
21
28
  @@otp_backup_code_length = 16
22
29
 
@@ -27,7 +34,7 @@ module Devise
27
34
  end
28
35
 
29
36
  Devise.add_module(:two_factor_authenticatable, :route => :session, :strategy => true,
30
- :controller => :sessions, :model => true)
37
+ :controller => :sessions, :model => true, :insert_at => 0)
31
38
 
32
39
  Devise.add_module(:two_factor_backupable, :route => :session, :strategy => true,
33
40
  :controller => :sessions, :model => true)
@@ -7,25 +7,28 @@ module Devise
7
7
  include Devise::Models::DatabaseAuthenticatable
8
8
 
9
9
  included do
10
- unless %i[otp_secret otp_secret=].all? { |attr| method_defined?(attr) }
11
- require 'attr_encrypted'
12
-
13
- unless singleton_class.ancestors.include?(AttrEncrypted)
14
- extend AttrEncrypted
15
- end
16
-
17
- unless attr_encrypted?(:otp_secret)
18
- attr_encrypted :otp_secret,
19
- :key => self.otp_secret_encryption_key,
20
- :mode => :per_attribute_iv_and_salt unless self.attr_encrypted?(:otp_secret)
21
- end
22
- end
23
-
10
+ encrypts :otp_secret, **splattable_encrypted_attr_options
24
11
  attr_accessor :otp_attempt
25
12
  end
26
13
 
14
+ def otp_secret
15
+ # return the OTP secret stored as a Rails encrypted attribute if it
16
+ # exists. Otherwise return OTP secret stored by the `attr_encrypted` gem
17
+ return self[:otp_secret] if self[:otp_secret]
18
+
19
+ legacy_otp_secret
20
+ end
21
+
22
+ ##
23
+ # Decrypt and return the `encrypted_otp_secret` attribute which was used in
24
+ # prior versions of devise-two-factor
25
+ # See: # https://github.com/tinfoil/devise-two-factor/blob/main/UPGRADING.md
26
+ def legacy_otp_secret
27
+ nil
28
+ end
29
+
27
30
  def self.required_fields(klass)
28
- [:encrypted_otp_secret, :encrypted_otp_secret_iv, :encrypted_otp_secret_salt, :consumed_timestep]
31
+ [:otp_secret, :consumed_timestep]
29
32
  end
30
33
 
31
34
  # This defaults to the model's otp_secret
@@ -38,12 +41,11 @@ module Devise
38
41
 
39
42
  if self.consumed_timestep
40
43
  # reconstruct the timestamp of the last consumed timestep
41
- after_timestamp = self.consumed_timestep * otp.interval
44
+ after_timestamp = self.consumed_timestep * totp.interval
42
45
  end
43
46
 
44
- if totp.verify(code.gsub(/\s+/, ""), drift_behind: self.class.otp_allowed_drift, drift_ahead: self.class.otp_allowed_drift, after: after_timestamp)
45
- return consume_otp!
46
- 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
47
49
 
48
50
  false
49
51
  end
@@ -56,11 +58,6 @@ module Devise
56
58
  otp.at(Time.now)
57
59
  end
58
60
 
59
- # ROTP's TOTP#timecode is private, so we duplicate it here
60
- def current_otp_timestep
61
- Time.now.utc.to_i / otp.interval
62
- end
63
-
64
61
  def otp_provisioning_uri(account, options = {})
65
62
  otp_secret = options[:otp_secret] || self.otp_secret
66
63
  ROTP::TOTP.new(otp_secret, options).provisioning_uri(account)
@@ -75,10 +72,14 @@ module Devise
75
72
 
76
73
  # An OTP cannot be used more than once in a given timestep
77
74
  # Storing timestep of last valid OTP is sufficient to satisfy this requirement
78
- def consume_otp!
79
- if self.consumed_timestep != current_otp_timestep
80
- self.consumed_timestep = current_otp_timestep
81
- return save(validate: false)
75
+ def consume_otp!(otp, timestamp)
76
+ timestep = timestamp / otp.interval
77
+
78
+ if self.consumed_timestep != timestep
79
+ self.consumed_timestep = timestep
80
+ save!(validate: false)
81
+
82
+ return true
82
83
  end
83
84
 
84
85
  false
@@ -87,10 +88,21 @@ module Devise
87
88
  module ClassMethods
88
89
  Devise::Models.config(self, :otp_secret_length,
89
90
  :otp_allowed_drift,
91
+ :otp_encrypted_attribute_options,
90
92
  :otp_secret_encryption_key)
91
93
 
94
+ # Generates an OTP secret of the specified length, returning it after Base32 encoding.
92
95
  def generate_otp_secret(otp_secret_length = self.otp_secret_length)
93
- ROTP::Base32.random_base32(otp_secret_length)
96
+ ROTP::Base32.random(otp_secret_length)
97
+ end
98
+
99
+ # Return value will be splatted with ** so return a version of the
100
+ # encrypted attribute options which is always a Hash.
101
+ # @return [Hash]
102
+ def splattable_encrypted_attr_options
103
+ return {} if otp_encrypted_attribute_options.nil?
104
+
105
+ otp_encrypted_attribute_options
94
106
  end
95
107
  end
96
108
  end
@@ -20,7 +20,7 @@ module Devise
20
20
  code_length = self.class.otp_backup_code_length
21
21
 
22
22
  number_of_codes.times do
23
- codes << SecureRandom.hex(code_length / 2) # Hexstring has length 2*n
23
+ codes << SecureRandom.hex(code_length)
24
24
  end
25
25
 
26
26
  hashed_codes = codes.map { |code| Devise::Encryptor.digest(self.class, code) }
@@ -30,15 +30,19 @@ module Devise
30
30
  end
31
31
 
32
32
  # Returns true and invalidates the given code
33
- # iff that code is a valid backup code.
33
+ # if that code is a valid backup code.
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
 
40
43
  codes.delete(backup_code)
41
44
  self.otp_backup_codes = codes
45
+ save!(validate: false)
42
46
  return true
43
47
  end
44
48
 
@@ -8,25 +8,13 @@ RSpec.shared_examples 'two_factor_authenticatable' do
8
8
 
9
9
  describe 'required_fields' do
10
10
  it 'should have the attr_encrypted fields for otp_secret' do
11
- expect(Devise::Models::TwoFactorAuthenticatable.required_fields(subject.class)).to contain_exactly(:encrypted_otp_secret, :encrypted_otp_secret_iv, :encrypted_otp_secret_salt, :consumed_timestep)
11
+ expect(Devise::Models::TwoFactorAuthenticatable.required_fields(subject.class)).to contain_exactly(:otp_secret, :consumed_timestep)
12
12
  end
13
13
  end
14
14
 
15
15
  describe '#otp_secret' do
16
- it 'should be of the configured length' do
17
- expect(subject.otp_secret.length).to eq(subject.class.otp_secret_length)
18
- end
19
-
20
- it 'stores the encrypted otp_secret' do
21
- expect(subject.encrypted_otp_secret).to_not be_nil
22
- end
23
-
24
- it 'stores an iv for otp_secret' do
25
- expect(subject.encrypted_otp_secret_iv).to_not be_nil
26
- end
27
-
28
- it 'stores a salt for otp_secret' do
29
- expect(subject.encrypted_otp_secret_salt).to_not be_nil
16
+ it 'should be of the expected length' do
17
+ expect(subject.otp_secret.length).to eq(subject.class.otp_secret_length*8/5)
30
18
  end
31
19
  end
32
20
 
@@ -137,15 +125,15 @@ RSpec.shared_examples 'two_factor_authenticatable' do
137
125
 
138
126
  describe '#otp_provisioning_uri' do
139
127
  let(:otp_secret_length) { subject.class.otp_secret_length }
140
- let(:account) { Faker::Internet.email }
128
+ let(:account) { 'user@host.example' }
141
129
  let(:issuer) { 'Tinfoil' }
142
130
 
143
131
  it 'should return uri with specified account' do
144
- expect(subject.otp_provisioning_uri(account)).to match(%r{otpauth://totp/#{CGI.escape(account)}\?secret=\w{#{otp_secret_length}}})
132
+ expect(subject.otp_provisioning_uri(account)).to match(%r{otpauth://totp/#{CGI.escape(account)}\?secret=\w{#{otp_secret_length*8/5}}})
145
133
  end
146
134
 
147
135
  it 'should return uri with issuer option' do
148
- expect(subject.otp_provisioning_uri(account, issuer: issuer)).to match(%r{otpauth://totp/#{issuer}:#{CGI.escape(account)}\?.*secret=\w{#{otp_secret_length}}(&|$)})
136
+ expect(subject.otp_provisioning_uri(account, issuer: issuer)).to match(%r{otpauth://totp/#{issuer}:#{CGI.escape(account)}\?.*secret=\w{#{otp_secret_length*8/5}}(&|$)})
149
137
  expect(subject.otp_provisioning_uri(account, issuer: issuer)).to match(%r{otpauth://totp/#{issuer}:#{CGI.escape(account)}\?.*issuer=#{issuer}(&|$)})
150
138
  end
151
139
  end
@@ -17,7 +17,7 @@ RSpec.shared_examples 'two_factor_backupable' do
17
17
 
18
18
  it 'generates recovery codes of the correct length' do
19
19
  @plaintext_codes.each do |code|
20
- expect(code.length).to eq(subject.class.otp_backup_code_length)
20
+ expect(code.length).to eq(subject.class.otp_backup_code_length*2)
21
21
  end
22
22
  end
23
23
 
@@ -34,7 +34,7 @@ RSpec.shared_examples 'two_factor_backupable' do
34
34
  end
35
35
 
36
36
  context 'with existing recovery codes' do
37
- let(:old_codes) { Faker::Lorem.words }
37
+ let(:old_codes) { ['adam', 'betty', 'charles'] }
38
38
  let(:old_codes_hashed) { old_codes.map { |x| Devise::Encryptor.digest(subject.class, x) } }
39
39
 
40
40
  before do
@@ -49,38 +49,67 @@ 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
+ # Do not run when DB adapter handles array assignment correctly
111
+ it "raises a meaningful error", unless: -> { subject.otp_backup_codes.is_a?(Array) } do
112
+ expect { subject.invalidate_otp_backup_code!("flork") }.to raise_error(TypeError)
84
113
  end
85
114
  end
86
115
  end