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.
- checksums.yaml +4 -4
- data/.travis.yml +13 -6
- data/CHANGELOG.md +109 -0
- data/Gemfile +8 -2
- data/README.md +182 -54
- data/app/controllers/devise/two_factor_authentication_controller.rb +1 -1
- data/config/locales/fr.yml +7 -0
- data/lib/generators/active_record/templates/migration.rb +6 -11
- data/lib/two_factor_authentication.rb +3 -0
- data/lib/two_factor_authentication/hooks/two_factor_authenticatable.rb +26 -2
- data/lib/two_factor_authentication/models/two_factor_authenticatable.rb +89 -23
- data/lib/two_factor_authentication/schema.rb +12 -4
- data/lib/two_factor_authentication/version.rb +1 -1
- data/spec/controllers/two_factor_authentication_controller_spec.rb +33 -0
- data/spec/features/two_factor_authenticatable_spec.rb +164 -28
- data/spec/generators/active_record/two_factor_authentication_generator_spec.rb +36 -0
- data/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +213 -117
- data/spec/rails_app/app/models/encrypted_user.rb +14 -0
- data/spec/rails_app/app/models/user.rb +1 -2
- data/spec/rails_app/config/environments/test.rb +3 -0
- data/spec/rails_app/config/initializers/devise.rb +3 -1
- data/spec/rails_app/db/migrate/20151224171231_add_encrypted_columns_to_user.rb +9 -0
- data/spec/rails_app/db/migrate/20151224180310_populate_otp_column.rb +19 -0
- data/spec/rails_app/db/migrate/20151228230340_remove_otp_secret_key_from_user.rb +5 -0
- data/spec/rails_app/db/schema.rb +16 -14
- data/spec/spec_helper.rb +1 -0
- data/spec/support/authenticated_model_helper.rb +26 -2
- data/spec/support/controller_helper.rb +16 -0
- data/spec/support/features_spec_helper.rb +24 -1
- data/two_factor_authentication.gemspec +1 -0
- metadata +25 -3
- 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
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
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
|
-
|
16
|
-
|
17
|
-
|
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
|
-
|
21
|
-
|
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
|
25
|
-
|
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
|
-
|
29
|
-
|
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 "
|
32
|
-
expect(
|
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
|
-
|
37
|
-
|
109
|
+
it 'returns uri with issuer option' do
|
110
|
+
require 'cgi'
|
38
111
|
|
39
|
-
|
40
|
-
|
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
|
47
|
-
|
127
|
+
describe '#populate_otp_column' do
|
128
|
+
shared_examples 'populate_otp_column' do |klass|
|
129
|
+
let(:instance) { klass.new }
|
48
130
|
|
49
|
-
|
50
|
-
|
51
|
-
end
|
131
|
+
it 'populates otp_column on create' do
|
132
|
+
expect(instance.otp_secret_key).to be_nil
|
52
133
|
|
53
|
-
|
54
|
-
|
55
|
-
end
|
134
|
+
# populate_otp_column called via before_create
|
135
|
+
instance.run_callbacks :create
|
56
136
|
|
57
|
-
|
58
|
-
|
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
|
-
|
63
|
-
|
64
|
-
|
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
|
-
|
69
|
-
let(:instance) { build_guest_user }
|
144
|
+
instance.populate_otp_column
|
70
145
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
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
|
-
|
78
|
-
|
79
|
-
|
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
|
-
|
86
|
-
let(:instance) { build_guest_user }
|
163
|
+
after { GuestUser.max_login_attempts = @original_max_login_attempts }
|
87
164
|
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
end
|
165
|
+
it 'returns class setting' do
|
166
|
+
expect(instance.max_login_attempts).to eq(3)
|
167
|
+
end
|
92
168
|
|
93
|
-
|
94
|
-
|
95
|
-
|
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
|
-
|
98
|
-
|
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
|
-
|
102
|
-
|
188
|
+
describe '.has_one_time_password' do
|
189
|
+
context 'when encrypted: true option is passed' do
|
190
|
+
let(:instance) { EncryptedUser.new }
|
103
191
|
|
104
|
-
|
105
|
-
|
192
|
+
it 'encrypts otp_secret_key with iv, salt, and encoding' do
|
193
|
+
instance.otp_secret_key = '2z6hxkdwi3uvrnpn'
|
106
194
|
|
107
|
-
|
108
|
-
|
109
|
-
|
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
|
-
|
116
|
-
|
199
|
+
expect(instance.encrypted_otp_secret_key_salt).to match(/.{25}/)
|
200
|
+
end
|
117
201
|
|
118
|
-
|
119
|
-
|
202
|
+
it 'does not encrypt a nil otp_secret_key' do
|
203
|
+
instance.otp_secret_key = nil
|
120
204
|
|
121
|
-
|
205
|
+
expect(instance.encrypted_otp_secret_key).to be_nil
|
122
206
|
|
123
|
-
|
124
|
-
end
|
207
|
+
expect(instance.encrypted_otp_secret_key_iv).to be_nil
|
125
208
|
|
126
|
-
|
127
|
-
|
128
|
-
original_key = instance.otp_secret_key
|
209
|
+
expect(instance.encrypted_otp_secret_key_salt).to be_nil
|
210
|
+
end
|
129
211
|
|
130
|
-
|
212
|
+
it 'does not encrypt an empty otp_secret_key' do
|
213
|
+
instance.otp_secret_key = ''
|
131
214
|
|
132
|
-
|
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
|
-
|
138
|
-
let(:instance) { build_guest_user }
|
217
|
+
expect(instance.encrypted_otp_secret_key_iv).to be_nil
|
139
218
|
|
140
|
-
|
141
|
-
|
142
|
-
GuestUser.max_login_attempts = 3
|
143
|
-
end
|
219
|
+
expect(instance.encrypted_otp_secret_key_salt).to be_nil
|
220
|
+
end
|
144
221
|
|
145
|
-
|
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
|
-
|
148
|
-
|
149
|
-
|
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
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
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
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
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
|
|