two_factor_authentication 1.1.4 → 1.1.5

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +13 -6
  3. data/CHANGELOG.md +109 -0
  4. data/Gemfile +8 -2
  5. data/README.md +182 -54
  6. data/app/controllers/devise/two_factor_authentication_controller.rb +1 -1
  7. data/config/locales/fr.yml +7 -0
  8. data/lib/generators/active_record/templates/migration.rb +6 -11
  9. data/lib/two_factor_authentication.rb +3 -0
  10. data/lib/two_factor_authentication/hooks/two_factor_authenticatable.rb +26 -2
  11. data/lib/two_factor_authentication/models/two_factor_authenticatable.rb +89 -23
  12. data/lib/two_factor_authentication/schema.rb +12 -4
  13. data/lib/two_factor_authentication/version.rb +1 -1
  14. data/spec/controllers/two_factor_authentication_controller_spec.rb +33 -0
  15. data/spec/features/two_factor_authenticatable_spec.rb +164 -28
  16. data/spec/generators/active_record/two_factor_authentication_generator_spec.rb +36 -0
  17. data/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +213 -117
  18. data/spec/rails_app/app/models/encrypted_user.rb +14 -0
  19. data/spec/rails_app/app/models/user.rb +1 -2
  20. data/spec/rails_app/config/environments/test.rb +3 -0
  21. data/spec/rails_app/config/initializers/devise.rb +3 -1
  22. data/spec/rails_app/db/migrate/20151224171231_add_encrypted_columns_to_user.rb +9 -0
  23. data/spec/rails_app/db/migrate/20151224180310_populate_otp_column.rb +19 -0
  24. data/spec/rails_app/db/migrate/20151228230340_remove_otp_secret_key_from_user.rb +5 -0
  25. data/spec/rails_app/db/schema.rb +16 -14
  26. data/spec/spec_helper.rb +1 -0
  27. data/spec/support/authenticated_model_helper.rb +26 -2
  28. data/spec/support/controller_helper.rb +16 -0
  29. data/spec/support/features_spec_helper.rb +24 -1
  30. data/two_factor_authentication.gemspec +1 -0
  31. metadata +25 -3
  32. data/spec/controllers/two_factor_auth_spec.rb +0 -18
@@ -0,0 +1,36 @@
1
+ require 'spec_helper'
2
+
3
+ require 'generators/active_record/two_factor_authentication_generator'
4
+
5
+ describe ActiveRecord::Generators::TwoFactorAuthenticationGenerator, type: :generator do
6
+ destination File.expand_path('../../../../../tmp', __FILE__)
7
+
8
+ before do
9
+ prepare_destination
10
+ end
11
+
12
+ it 'runs all methods in the generator' do
13
+ gen = generator %w(users)
14
+ expect(gen).to receive(:copy_two_factor_authentication_migration)
15
+ gen.invoke_all
16
+ end
17
+
18
+ describe 'the generated files' do
19
+ before do
20
+ run_generator %w(users)
21
+ end
22
+
23
+ describe 'the migration' do
24
+ subject { migration_file('db/migrate/two_factor_authentication_add_to_users.rb') }
25
+
26
+ it { is_expected.to exist }
27
+ it { is_expected.to be_a_migration }
28
+ it { is_expected.to contain /def change/ }
29
+ it { is_expected.to contain /add_column :users, :second_factor_attempts_count, :integer, default: 0/ }
30
+ it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key, :string/ }
31
+ it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key_iv, :string/ }
32
+ it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key_salt, :string/ }
33
+ it { is_expected.to contain /add_index :users, :encrypted_otp_secret_key, unique: true/ }
34
+ end
35
+ end
36
+ end
@@ -1,168 +1,264 @@
1
1
  require 'spec_helper'
2
2
  include AuthenticatedModelHelper
3
3
 
4
- describe Devise::Models::TwoFactorAuthenticatable, '#otp_code' do
5
- let(:instance) { build_guest_user }
6
- subject { instance.otp_code(time) }
7
- let(:time) { 1392852456 }
8
-
9
- it "should return an error if no secret is set" do
10
- expect {
11
- subject
12
- }.to raise_error Exception
4
+ describe Devise::Models::TwoFactorAuthenticatable do
5
+ describe '#otp_code' do
6
+ shared_examples 'otp_code' do |instance|
7
+ subject { instance.otp_code(time) }
8
+ let(:time) { 1_392_852_456 }
9
+
10
+ it 'returns an error if no secret is set' do
11
+ expect { subject }.to raise_error Exception
12
+ end
13
+
14
+ context 'secret is set' do
15
+ before :each do
16
+ instance.otp_secret_key = '2z6hxkdwi3uvrnpn'
17
+ end
18
+
19
+ it 'does not return an error' do
20
+ subject
21
+ end
22
+
23
+ it 'matches Devise configured length' do
24
+ expect(subject.length).to eq(Devise.otp_length)
25
+ end
26
+
27
+ context 'with a known time' do
28
+ let(:time) { 1_392_852_756 }
29
+
30
+ it 'returns a known result' do
31
+ expect(subject).
32
+ to eq('0000000524562202'.split(//).last(Devise.otp_length).join)
33
+ end
34
+ end
35
+
36
+ context 'with a known time yielding a result with less than 6 digits' do
37
+ let(:time) { 1_393_065_856 }
38
+
39
+ it 'returns a known result padded with zeroes' do
40
+ expect(subject).
41
+ to eq('0000001608007672'.split(//).last(Devise.otp_length).join)
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ it_behaves_like 'otp_code', GuestUser.new
48
+ it_behaves_like 'otp_code', EncryptedUser.new
13
49
  end
14
50
 
15
- context "secret is set" do
16
- before :each do
17
- instance.otp_secret_key = "2z6hxkdwi3uvrnpn"
51
+ describe '#authenticate_otp' do
52
+ shared_examples 'authenticate_otp' do |instance|
53
+ before :each do
54
+ instance.otp_secret_key = '2z6hxkdwi3uvrnpn'
55
+ end
56
+
57
+ def do_invoke(code, user)
58
+ user.authenticate_otp(code)
59
+ end
60
+
61
+ it 'authenticates a recently created code' do
62
+ code = instance.otp_code
63
+ expect(do_invoke(code, instance)).to eq(true)
64
+ end
65
+
66
+ it 'does not authenticate an old code' do
67
+ code = instance.otp_code(1.minutes.ago.to_i)
68
+ expect(do_invoke(code, instance)).to eq(false)
69
+ end
18
70
  end
19
71
 
20
- it "should not return an error" do
21
- subject
72
+ it_behaves_like 'authenticate_otp', GuestUser.new
73
+ it_behaves_like 'authenticate_otp', EncryptedUser.new
74
+ end
75
+
76
+ describe '#send_two_factor_authentication_code' do
77
+ let(:instance) { build_guest_user }
78
+
79
+ it 'raises an error by default' do
80
+ expect { instance.send_two_factor_authentication_code }.
81
+ to raise_error(NotImplementedError)
22
82
  end
23
83
 
24
- it "should be configured length" do
25
- expect(subject.length).to eq(Devise.otp_length)
84
+ it 'is overrideable' do
85
+ def instance.send_two_factor_authentication_code
86
+ 'Code sent'
87
+ end
88
+ expect(instance.send_two_factor_authentication_code).to eq('Code sent')
26
89
  end
90
+ end
27
91
 
28
- context "with a known time" do
29
- let(:time) { 1392852756 }
92
+ describe '#provisioning_uri' do
93
+ shared_examples 'provisioning_uri' do |instance|
94
+ before do
95
+ instance.email = 'houdini@example.com'
96
+ instance.run_callbacks :create
97
+ end
30
98
 
31
- it "should return a known result" do
32
- expect(subject).to eq("0000000524562202".split(//).last(Devise.otp_length).join)
99
+ it "returns uri with user's email" do
100
+ expect(instance.provisioning_uri).
101
+ to match(%r{otpauth://totp/houdini@example.com\?secret=\w{16}})
102
+ end
103
+
104
+ it 'returns uri with issuer option' do
105
+ expect(instance.provisioning_uri('houdini')).
106
+ to match(%r{otpauth://totp/houdini\?secret=\w{16}$})
33
107
  end
34
- end
35
108
 
36
- context "with a known time yielding a result with less than 6 digits" do
37
- let(:time) { 1393065856 }
109
+ it 'returns uri with issuer option' do
110
+ require 'cgi'
38
111
 
39
- it "should return a known result padded with zeroes" do
40
- expect(subject).to eq("0000001608007672".split(//).last(Devise.otp_length).join)
112
+ uri = URI.parse(instance.provisioning_uri('houdini', issuer: 'Magic'))
113
+ params = CGI.parse(uri.query)
114
+
115
+ expect(uri.scheme).to eq('otpauth')
116
+ expect(uri.host).to eq('totp')
117
+ expect(uri.path).to eq('/houdini')
118
+ expect(params['issuer'].shift).to eq('Magic')
119
+ expect(params['secret'].shift).to match(/\w{16}/)
41
120
  end
42
121
  end
122
+
123
+ it_behaves_like 'provisioning_uri', GuestUser.new
124
+ it_behaves_like 'provisioning_uri', EncryptedUser.new
43
125
  end
44
- end
45
126
 
46
- describe Devise::Models::TwoFactorAuthenticatable, '#authenticate_otp' do
47
- let(:instance) { build_guest_user }
127
+ describe '#populate_otp_column' do
128
+ shared_examples 'populate_otp_column' do |klass|
129
+ let(:instance) { klass.new }
48
130
 
49
- before :each do
50
- instance.otp_secret_key = "2z6hxkdwi3uvrnpn"
51
- end
131
+ it 'populates otp_column on create' do
132
+ expect(instance.otp_secret_key).to be_nil
52
133
 
53
- def do_invoke code, options = {}
54
- instance.authenticate_otp(code, options)
55
- end
134
+ # populate_otp_column called via before_create
135
+ instance.run_callbacks :create
56
136
 
57
- it "should be able to authenticate a recently created code" do
58
- code = instance.otp_code
59
- expect(do_invoke(code)).to eq(true)
60
- end
137
+ expect(instance.otp_secret_key).to match(/\w{16}/)
138
+ end
61
139
 
62
- it "should not authenticate an old code" do
63
- code = instance.otp_code(1.minutes.ago.to_i)
64
- expect(do_invoke(code)).to eq(false)
65
- end
66
- end
140
+ it 'repopulates otp_column' do
141
+ instance.run_callbacks :create
142
+ original_key = instance.otp_secret_key
67
143
 
68
- describe Devise::Models::TwoFactorAuthenticatable, '#send_two_factor_authentication_code' do
69
- let(:instance) { build_guest_user }
144
+ instance.populate_otp_column
70
145
 
71
- it "should raise an error by default" do
72
- expect {
73
- instance.send_two_factor_authentication_code
74
- }.to raise_error(NotImplementedError)
146
+ expect(instance.otp_secret_key).to match(/\w{16}/)
147
+ expect(instance.otp_secret_key).to_not eq(original_key)
148
+ end
149
+ end
150
+
151
+ it_behaves_like 'populate_otp_column', GuestUser
152
+ it_behaves_like 'populate_otp_column', EncryptedUser
75
153
  end
76
154
 
77
- it "should be overrideable" do
78
- def instance.send_two_factor_authentication_code
79
- "Code sent"
155
+ describe '#max_login_attempts' do
156
+ let(:instance) { build_guest_user }
157
+
158
+ before do
159
+ @original_max_login_attempts = GuestUser.max_login_attempts
160
+ GuestUser.max_login_attempts = 3
80
161
  end
81
- expect(instance.send_two_factor_authentication_code).to eq("Code sent")
82
- end
83
- end
84
162
 
85
- describe Devise::Models::TwoFactorAuthenticatable, '#provisioning_uri' do
86
- let(:instance) { build_guest_user }
163
+ after { GuestUser.max_login_attempts = @original_max_login_attempts }
87
164
 
88
- before do
89
- instance.email = "houdini@example.com"
90
- instance.run_callbacks :create
91
- end
165
+ it 'returns class setting' do
166
+ expect(instance.max_login_attempts).to eq(3)
167
+ end
92
168
 
93
- it "should return uri with user's email" do
94
- expect(instance.provisioning_uri).to match(%r{otpauth://totp/houdini@example.com\?secret=\w{16}})
95
- end
169
+ it 'returns false as boolean' do
170
+ instance.second_factor_attempts_count = nil
171
+ expect(instance.max_login_attempts?).to be_falsey
172
+ instance.second_factor_attempts_count = 0
173
+ expect(instance.max_login_attempts?).to be_falsey
174
+ instance.second_factor_attempts_count = 1
175
+ expect(instance.max_login_attempts?).to be_falsey
176
+ instance.second_factor_attempts_count = 2
177
+ expect(instance.max_login_attempts?).to be_falsey
178
+ end
96
179
 
97
- it "should return uri with issuer option" do
98
- expect(instance.provisioning_uri("houdini")).to match(%r{otpauth://totp/houdini\?secret=\w{16}$})
180
+ it 'returns true as boolean after too many attempts' do
181
+ instance.second_factor_attempts_count = 3
182
+ expect(instance.max_login_attempts?).to be_truthy
183
+ instance.second_factor_attempts_count = 4
184
+ expect(instance.max_login_attempts?).to be_truthy
185
+ end
99
186
  end
100
187
 
101
- it "should return uri with issuer option" do
102
- require 'cgi'
188
+ describe '.has_one_time_password' do
189
+ context 'when encrypted: true option is passed' do
190
+ let(:instance) { EncryptedUser.new }
103
191
 
104
- uri = URI.parse(instance.provisioning_uri("houdini", issuer: 'Magic'))
105
- params = CGI::parse(uri.query)
192
+ it 'encrypts otp_secret_key with iv, salt, and encoding' do
193
+ instance.otp_secret_key = '2z6hxkdwi3uvrnpn'
106
194
 
107
- expect(uri.scheme).to eq("otpauth")
108
- expect(uri.host).to eq("totp")
109
- expect(uri.path).to eq("/houdini")
110
- expect(params['issuer'].shift).to eq('Magic')
111
- expect(params['secret'].shift).to match(%r{\w{16}})
112
- end
113
- end
195
+ expect(instance.encrypted_otp_secret_key).to match(/.{44}/)
196
+
197
+ expect(instance.encrypted_otp_secret_key_iv).to match(/.{24}/)
114
198
 
115
- describe Devise::Models::TwoFactorAuthenticatable, '#populate_otp_column' do
116
- let(:instance) { build_guest_user }
199
+ expect(instance.encrypted_otp_secret_key_salt).to match(/.{25}/)
200
+ end
117
201
 
118
- it "populates otp_column on create" do
119
- expect(instance.otp_secret_key).to be_nil
202
+ it 'does not encrypt a nil otp_secret_key' do
203
+ instance.otp_secret_key = nil
120
204
 
121
- instance.run_callbacks :create # populate_otp_column called via before_create
205
+ expect(instance.encrypted_otp_secret_key).to be_nil
122
206
 
123
- expect(instance.otp_secret_key).to match(%r{\w{16}})
124
- end
207
+ expect(instance.encrypted_otp_secret_key_iv).to be_nil
125
208
 
126
- it "repopulates otp_column" do
127
- instance.run_callbacks :create
128
- original_key = instance.otp_secret_key
209
+ expect(instance.encrypted_otp_secret_key_salt).to be_nil
210
+ end
129
211
 
130
- instance.populate_otp_column
212
+ it 'does not encrypt an empty otp_secret_key' do
213
+ instance.otp_secret_key = ''
131
214
 
132
- expect(instance.otp_secret_key).to match(%r{\w{16}})
133
- expect(instance.otp_secret_key).to_not eq(original_key)
134
- end
135
- end
215
+ expect(instance.encrypted_otp_secret_key).to eq ''
136
216
 
137
- describe Devise::Models::TwoFactorAuthenticatable, '#max_login_attempts' do
138
- let(:instance) { build_guest_user }
217
+ expect(instance.encrypted_otp_secret_key_iv).to be_nil
139
218
 
140
- before do
141
- @original_max_login_attempts = GuestUser.max_login_attempts
142
- GuestUser.max_login_attempts = 3
143
- end
219
+ expect(instance.encrypted_otp_secret_key_salt).to be_nil
220
+ end
144
221
 
145
- after { GuestUser.max_login_attempts = @original_max_login_attempts }
222
+ it 'raises an error when Devise.otp_secret_encryption_key is not set' do
223
+ allow(Devise).to receive(:otp_secret_encryption_key).and_return nil
146
224
 
147
- it "returns class setting" do
148
- expect(instance.max_login_attempts).to eq(3)
149
- end
225
+ # This error is raised by the encryptor gem
226
+ expect { instance.otp_secret_key = '2z6hxkdwi3uvrnpn' }.
227
+ to raise_error ArgumentError
228
+ end
150
229
 
151
- it "returns false as boolean" do
152
- instance.second_factor_attempts_count = nil
153
- expect(instance.max_login_attempts?).to be_falsey
154
- instance.second_factor_attempts_count = 0
155
- expect(instance.max_login_attempts?).to be_falsey
156
- instance.second_factor_attempts_count = 1
157
- expect(instance.max_login_attempts?).to be_falsey
158
- instance.second_factor_attempts_count = 2
159
- expect(instance.max_login_attempts?).to be_falsey
160
- end
230
+ it 'passes in the correct options to Encryptor' do
231
+ instance.otp_secret_key = 'testing'
232
+ iv = instance.encrypted_otp_secret_key_iv
233
+ salt = instance.encrypted_otp_secret_key_salt
234
+
235
+ encrypted = Encryptor.encrypt(
236
+ value: 'testing',
237
+ key: Devise.otp_secret_encryption_key,
238
+ iv: iv.unpack('m').first,
239
+ salt: salt.unpack('m').first
240
+ )
161
241
 
162
- it "returns true as boolean after too many attempts" do
163
- instance.second_factor_attempts_count = 3
164
- expect(instance.max_login_attempts?).to be_truthy
165
- instance.second_factor_attempts_count = 4
166
- expect(instance.max_login_attempts?).to be_truthy
242
+ expect(instance.encrypted_otp_secret_key).to eq [encrypted].pack('m')
243
+ end
244
+
245
+ it 'varies the iv per instance' do
246
+ instance.otp_secret_key = 'testing'
247
+ user2 = EncryptedUser.new
248
+ user2.otp_secret_key = 'testing'
249
+
250
+ expect(user2.encrypted_otp_secret_key_iv).
251
+ to_not eq instance.encrypted_otp_secret_key_iv
252
+ end
253
+
254
+ it 'varies the salt per instance' do
255
+ instance.otp_secret_key = 'testing'
256
+ user2 = EncryptedUser.new
257
+ user2.otp_secret_key = 'testing'
258
+
259
+ expect(user2.encrypted_otp_secret_key_salt).
260
+ to_not eq instance.encrypted_otp_secret_key_salt
261
+ end
262
+ end
167
263
  end
168
264
  end
@@ -0,0 +1,14 @@
1
+ class EncryptedUser
2
+ extend ActiveModel::Callbacks
3
+ include ActiveModel::Validations
4
+ include Devise::Models::TwoFactorAuthenticatable
5
+
6
+ define_model_callbacks :create
7
+ attr_accessor :encrypted_otp_secret_key,
8
+ :encrypted_otp_secret_key_iv,
9
+ :encrypted_otp_secret_key_salt,
10
+ :email,
11
+ :second_factor_attempts_count
12
+
13
+ has_one_time_password(encrypted: true)
14
+ end
@@ -1,7 +1,6 @@
1
1
  class User < ActiveRecord::Base
2
2
  devise :two_factor_authenticatable, :database_authenticatable, :registerable,
3
- :recoverable, :rememberable, :trackable, :validatable,
4
- :two_factor_authenticatable
3
+ :recoverable, :rememberable, :trackable, :validatable
5
4
 
6
5
  has_one_time_password
7
6
 
@@ -35,4 +35,7 @@ Dummy::Application.configure do
35
35
 
36
36
  # Print deprecation notices to the stderr
37
37
  config.active_support.deprecation = :stderr
38
+
39
+ # For testing session variables in Capybara specs
40
+ config.middleware.use RackSessionAccess::Middleware
38
41
  end