two_factor_authentication 1.1.5 → 2.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 +4 -4
- data/.codeclimate.yml +21 -0
- data/.rubocop.yml +295 -0
- data/.travis.yml +4 -5
- data/CHANGELOG.md +24 -14
- data/README.md +57 -65
- data/app/controllers/devise/two_factor_authentication_controller.rb +28 -12
- data/app/views/devise/two_factor_authentication/show.html.erb +10 -1
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +8 -0
- data/config/locales/fr.yml +1 -0
- data/config/locales/ru.yml +1 -0
- data/lib/generators/active_record/templates/migration.rb +3 -0
- data/lib/two_factor_authentication.rb +9 -0
- data/lib/two_factor_authentication/controllers/helpers.rb +1 -1
- data/lib/two_factor_authentication/hooks/two_factor_authenticatable.rb +4 -23
- data/lib/two_factor_authentication/models/two_factor_authenticatable.rb +68 -19
- data/lib/two_factor_authentication/routes.rb +3 -1
- data/lib/two_factor_authentication/schema.rb +12 -0
- data/lib/two_factor_authentication/version.rb +1 -1
- data/spec/controllers/two_factor_authentication_controller_spec.rb +2 -2
- data/spec/features/two_factor_authenticatable_spec.rb +36 -73
- data/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +137 -80
- data/spec/rails_app/app/controllers/home_controller.rb +1 -1
- data/spec/rails_app/app/models/admin.rb +6 -0
- data/spec/rails_app/app/models/encrypted_user.rb +2 -1
- data/spec/rails_app/app/models/guest_user.rb +8 -1
- data/spec/rails_app/app/models/user.rb +2 -2
- data/spec/rails_app/config/initializers/devise.rb +2 -2
- data/spec/rails_app/config/routes.rb +1 -0
- data/spec/rails_app/db/migrate/20140403184646_devise_create_users.rb +1 -1
- data/spec/rails_app/db/migrate/20160209032439_devise_create_admins.rb +42 -0
- data/spec/rails_app/db/schema.rb +19 -1
- data/spec/support/authenticated_model_helper.rb +22 -15
- data/spec/support/controller_helper.rb +1 -1
- data/spec/support/totp_helper.rb +11 -0
- data/two_factor_authentication.gemspec +1 -1
- metadata +74 -7
@@ -2,121 +2,156 @@ require 'spec_helper'
|
|
2
2
|
include AuthenticatedModelHelper
|
3
3
|
|
4
4
|
describe Devise::Models::TwoFactorAuthenticatable do
|
5
|
-
describe '#
|
6
|
-
|
7
|
-
|
8
|
-
|
5
|
+
describe '#create_direct_otp' do
|
6
|
+
let(:instance) { build_guest_user }
|
7
|
+
|
8
|
+
it 'set direct_otp field' do
|
9
|
+
expect(instance.direct_otp).to be_nil
|
10
|
+
instance.create_direct_otp
|
11
|
+
expect(instance.direct_otp).not_to be_nil
|
12
|
+
end
|
9
13
|
|
10
|
-
|
11
|
-
|
14
|
+
it 'set direct_otp_send_at field to current time' do
|
15
|
+
Timecop.freeze() do
|
16
|
+
instance.create_direct_otp
|
17
|
+
expect(instance.direct_otp_sent_at).to eq(Time.now)
|
12
18
|
end
|
19
|
+
end
|
13
20
|
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
21
|
+
it 'honors .direct_otp_length' do
|
22
|
+
expect(instance.class).to receive(:direct_otp_length).and_return(10)
|
23
|
+
instance.create_direct_otp
|
24
|
+
expect(instance.direct_otp.length).to equal(10)
|
18
25
|
|
19
|
-
|
20
|
-
|
21
|
-
|
26
|
+
expect(instance.class).to receive(:direct_otp_length).and_return(6)
|
27
|
+
instance.create_direct_otp
|
28
|
+
expect(instance.direct_otp.length).to equal(6)
|
29
|
+
end
|
22
30
|
|
23
|
-
|
24
|
-
|
25
|
-
|
31
|
+
it "honors 'direct_otp_length' in options paramater" do
|
32
|
+
instance.create_direct_otp(length: 8)
|
33
|
+
expect(instance.direct_otp.length).to equal(8)
|
34
|
+
instance.create_direct_otp(length: 10)
|
35
|
+
expect(instance.direct_otp.length).to equal(10)
|
36
|
+
end
|
37
|
+
end
|
26
38
|
|
27
|
-
|
28
|
-
|
39
|
+
describe '#authenticate_direct_otp' do
|
40
|
+
let(:instance) { build_guest_user }
|
41
|
+
it 'fails if no direct_otp has been set' do
|
42
|
+
expect(instance.authenticate_direct_otp('12345')).to eq(false)
|
43
|
+
end
|
29
44
|
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
end
|
45
|
+
context 'after generating an OTP' do
|
46
|
+
before :each do
|
47
|
+
instance.create_direct_otp
|
48
|
+
end
|
35
49
|
|
36
|
-
|
37
|
-
|
50
|
+
it 'accepts correct OTP' do
|
51
|
+
Timecop.freeze(Time.now + instance.class.direct_otp_valid_for - 1.second)
|
52
|
+
expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(true)
|
53
|
+
end
|
38
54
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
end
|
43
|
-
end
|
55
|
+
it 'rejects invalid OTP' do
|
56
|
+
Timecop.freeze(Time.now + instance.class.direct_otp_valid_for - 1.second)
|
57
|
+
expect(instance.authenticate_direct_otp('12340')).to eq(false)
|
44
58
|
end
|
45
|
-
end
|
46
59
|
|
47
|
-
|
48
|
-
|
60
|
+
it 'rejects expired OTP' do
|
61
|
+
Timecop.freeze(Time.now + instance.class.direct_otp_valid_for + 1.second)
|
62
|
+
expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(false)
|
63
|
+
end
|
64
|
+
|
65
|
+
it 'prevents code re-use' do
|
66
|
+
expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(true)
|
67
|
+
expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(false)
|
68
|
+
end
|
69
|
+
end
|
49
70
|
end
|
50
71
|
|
51
|
-
describe '#
|
52
|
-
shared_examples '
|
72
|
+
describe '#authenticate_totp' do
|
73
|
+
shared_examples 'authenticate_totp' do |instance|
|
53
74
|
before :each do
|
54
75
|
instance.otp_secret_key = '2z6hxkdwi3uvrnpn'
|
76
|
+
instance.totp_timestamp = nil
|
77
|
+
@totp_helper = TotpHelper.new(instance.otp_secret_key, instance.class.otp_length)
|
55
78
|
end
|
56
79
|
|
57
80
|
def do_invoke(code, user)
|
58
|
-
user.
|
81
|
+
user.authenticate_totp(code)
|
59
82
|
end
|
60
83
|
|
61
84
|
it 'authenticates a recently created code' do
|
62
|
-
code =
|
85
|
+
code = @totp_helper.totp_code
|
63
86
|
expect(do_invoke(code, instance)).to eq(true)
|
64
87
|
end
|
65
88
|
|
66
89
|
it 'does not authenticate an old code' do
|
67
|
-
code =
|
90
|
+
code = @totp_helper.totp_code(1.minutes.ago.to_i)
|
91
|
+
expect(do_invoke(code, instance)).to eq(false)
|
92
|
+
end
|
93
|
+
|
94
|
+
it 'prevents code reuse' do
|
95
|
+
code = @totp_helper.totp_code
|
96
|
+
expect(do_invoke(code, instance)).to eq(true)
|
68
97
|
expect(do_invoke(code, instance)).to eq(false)
|
69
98
|
end
|
70
99
|
end
|
71
100
|
|
72
|
-
it_behaves_like '
|
73
|
-
it_behaves_like '
|
101
|
+
it_behaves_like 'authenticate_totp', GuestUser.new
|
102
|
+
it_behaves_like 'authenticate_totp', EncryptedUser.new
|
74
103
|
end
|
75
104
|
|
76
105
|
describe '#send_two_factor_authentication_code' do
|
77
106
|
let(:instance) { build_guest_user }
|
78
107
|
|
79
108
|
it 'raises an error by default' do
|
80
|
-
expect { instance.send_two_factor_authentication_code }.
|
109
|
+
expect { instance.send_two_factor_authentication_code(123) }.
|
81
110
|
to raise_error(NotImplementedError)
|
82
111
|
end
|
83
112
|
|
84
113
|
it 'is overrideable' do
|
85
|
-
def instance.send_two_factor_authentication_code
|
114
|
+
def instance.send_two_factor_authentication_code(code)
|
86
115
|
'Code sent'
|
87
116
|
end
|
88
|
-
expect(instance.send_two_factor_authentication_code).to eq('Code sent')
|
117
|
+
expect(instance.send_two_factor_authentication_code(123)).to eq('Code sent')
|
89
118
|
end
|
90
119
|
end
|
91
120
|
|
92
121
|
describe '#provisioning_uri' do
|
122
|
+
|
93
123
|
shared_examples 'provisioning_uri' do |instance|
|
94
|
-
|
95
|
-
instance.
|
96
|
-
instance.run_callbacks :create
|
124
|
+
it 'fails until generate_totp_secret is called' do
|
125
|
+
expect { instance.provisioning_uri }.to raise_error(Exception)
|
97
126
|
end
|
98
127
|
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
128
|
+
describe 'with secret set' do
|
129
|
+
before do
|
130
|
+
instance.email = 'houdini@example.com'
|
131
|
+
instance.otp_secret_key = instance.generate_totp_secret
|
132
|
+
end
|
103
133
|
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
134
|
+
it "returns uri with user's email" do
|
135
|
+
expect(instance.provisioning_uri).
|
136
|
+
to match(%r{otpauth://totp/houdini@example.com\?secret=\w{16}})
|
137
|
+
end
|
108
138
|
|
109
|
-
|
110
|
-
|
139
|
+
it 'returns uri with issuer option' do
|
140
|
+
expect(instance.provisioning_uri('houdini')).
|
141
|
+
to match(%r{otpauth://totp/houdini\?secret=\w{16}$})
|
142
|
+
end
|
111
143
|
|
112
|
-
uri
|
113
|
-
|
144
|
+
it 'returns uri with issuer option' do
|
145
|
+
require 'cgi'
|
146
|
+
uri = URI.parse(instance.provisioning_uri('houdini', issuer: 'Magic'))
|
147
|
+
params = CGI.parse(uri.query)
|
114
148
|
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
149
|
+
expect(uri.scheme).to eq('otpauth')
|
150
|
+
expect(uri.host).to eq('totp')
|
151
|
+
expect(uri.path).to eq('/Magic:houdini')
|
152
|
+
expect(params['issuer'].shift).to eq('Magic')
|
153
|
+
expect(params['secret'].shift).to match(/\w{16}/)
|
154
|
+
end
|
120
155
|
end
|
121
156
|
end
|
122
157
|
|
@@ -124,32 +159,50 @@ describe Devise::Models::TwoFactorAuthenticatable do
|
|
124
159
|
it_behaves_like 'provisioning_uri', EncryptedUser.new
|
125
160
|
end
|
126
161
|
|
127
|
-
describe '#
|
128
|
-
shared_examples '
|
162
|
+
describe '#generate_totp_secret' do
|
163
|
+
shared_examples 'generate_totp_secret' do |klass|
|
129
164
|
let(:instance) { klass.new }
|
130
165
|
|
131
|
-
it '
|
132
|
-
|
166
|
+
it 'returns a 16 character string' do
|
167
|
+
secret = instance.generate_totp_secret
|
133
168
|
|
134
|
-
|
135
|
-
|
169
|
+
expect(secret).to match(/\w{16}/)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
it_behaves_like 'generate_totp_secret', GuestUser
|
174
|
+
it_behaves_like 'generate_totp_secret', EncryptedUser
|
175
|
+
end
|
176
|
+
|
177
|
+
describe '#confirm_totp_secret' do
|
178
|
+
shared_examples 'confirm_totp_secret' do |klass|
|
179
|
+
let(:instance) { klass.new }
|
180
|
+
let(:secret) { instance.generate_totp_secret }
|
181
|
+
let(:totp_helper) { TotpHelper.new(secret, instance.class.otp_length) }
|
136
182
|
|
137
|
-
|
183
|
+
it 'populates otp_secret_key column when given correct code' do
|
184
|
+
instance.confirm_totp_secret(secret, totp_helper.totp_code)
|
185
|
+
|
186
|
+
expect(instance.otp_secret_key).to match(secret)
|
138
187
|
end
|
139
188
|
|
140
|
-
it '
|
141
|
-
instance.
|
142
|
-
|
189
|
+
it 'does not populate otp_secret_key when when given incorrect code' do
|
190
|
+
instance.confirm_totp_secret(secret, '123')
|
191
|
+
expect(instance.otp_secret_key).to be_nil
|
192
|
+
end
|
143
193
|
|
144
|
-
|
194
|
+
it 'returns true when given correct code' do
|
195
|
+
expect(instance.confirm_totp_secret(secret, totp_helper.totp_code)).to be true
|
196
|
+
end
|
145
197
|
|
146
|
-
|
147
|
-
expect(instance.
|
198
|
+
it 'returns false when given incorrect code' do
|
199
|
+
expect(instance.confirm_totp_secret(secret, '123')).to be false
|
148
200
|
end
|
201
|
+
|
149
202
|
end
|
150
203
|
|
151
|
-
it_behaves_like '
|
152
|
-
it_behaves_like '
|
204
|
+
it_behaves_like 'confirm_totp_secret', GuestUser
|
205
|
+
it_behaves_like 'confirm_totp_secret', EncryptedUser
|
153
206
|
end
|
154
207
|
|
155
208
|
describe '#max_login_attempts' do
|
@@ -227,16 +280,20 @@ describe Devise::Models::TwoFactorAuthenticatable do
|
|
227
280
|
to raise_error ArgumentError
|
228
281
|
end
|
229
282
|
|
230
|
-
it 'passes in the correct options to Encryptor
|
283
|
+
it 'passes in the correct options to Encryptor.
|
284
|
+
We test here output of
|
285
|
+
Devise::Models::TwoFactorAuthenticatable::EncryptionInstanceMethods.encryption_options_for' do
|
231
286
|
instance.otp_secret_key = 'testing'
|
232
287
|
iv = instance.encrypted_otp_secret_key_iv
|
233
288
|
salt = instance.encrypted_otp_secret_key_salt
|
234
289
|
|
290
|
+
# it's important here to put the same crypto algorithm from that method
|
235
291
|
encrypted = Encryptor.encrypt(
|
236
292
|
value: 'testing',
|
237
293
|
key: Devise.otp_secret_encryption_key,
|
238
294
|
iv: iv.unpack('m').first,
|
239
|
-
salt: salt.unpack('m').first
|
295
|
+
salt: salt.unpack('m').first,
|
296
|
+
algorithm: 'aes-256-cbc'
|
240
297
|
)
|
241
298
|
|
242
299
|
expect(instance.encrypted_otp_secret_key).to eq [encrypted].pack('m')
|
@@ -4,7 +4,14 @@ class GuestUser
|
|
4
4
|
include Devise::Models::TwoFactorAuthenticatable
|
5
5
|
|
6
6
|
define_model_callbacks :create
|
7
|
-
attr_accessor :otp_secret_key, :email,
|
7
|
+
attr_accessor :direct_otp, :direct_otp_sent_at, :otp_secret_key, :email,
|
8
|
+
:second_factor_attempts_count, :totp_timestamp
|
9
|
+
|
10
|
+
def update_attributes(attrs)
|
11
|
+
attrs.each do |key, value|
|
12
|
+
send(key.to_s + '=', value)
|
13
|
+
end
|
14
|
+
end
|
8
15
|
|
9
16
|
has_one_time_password
|
10
17
|
end
|
@@ -4,8 +4,8 @@ class User < ActiveRecord::Base
|
|
4
4
|
|
5
5
|
has_one_time_password
|
6
6
|
|
7
|
-
def send_two_factor_authentication_code
|
8
|
-
SMSProvider.send_message(to: phone_number, body:
|
7
|
+
def send_two_factor_authentication_code(code)
|
8
|
+
SMSProvider.send_message(to: phone_number, body: code)
|
9
9
|
end
|
10
10
|
|
11
11
|
def phone_number
|
@@ -206,11 +206,11 @@ Devise.setup do |config|
|
|
206
206
|
|
207
207
|
# Configure the default scope given to Warden. By default it's the first
|
208
208
|
# devise role declared in your routes (usually :user).
|
209
|
-
|
209
|
+
config.default_scope = :user
|
210
210
|
|
211
211
|
# Set this configuration to false if you want /users/sign_out to sign out
|
212
212
|
# only the current scope. By default, Devise signs out all scopes.
|
213
|
-
|
213
|
+
config.sign_out_all_scopes = false
|
214
214
|
|
215
215
|
# ==> Navigation configuration
|
216
216
|
# Lists the formats that should be treated as navigational. Formats like
|
@@ -0,0 +1,42 @@
|
|
1
|
+
class DeviseCreateAdmins < ActiveRecord::Migration
|
2
|
+
def change
|
3
|
+
create_table(:admins) do |t|
|
4
|
+
## Database authenticatable
|
5
|
+
t.string :email, null: false, default: ""
|
6
|
+
t.string :encrypted_password, null: false, default: ""
|
7
|
+
|
8
|
+
## Recoverable
|
9
|
+
t.string :reset_password_token
|
10
|
+
t.datetime :reset_password_sent_at
|
11
|
+
|
12
|
+
## Rememberable
|
13
|
+
t.datetime :remember_created_at
|
14
|
+
|
15
|
+
## Trackable
|
16
|
+
t.integer :sign_in_count, default: 0, null: false
|
17
|
+
t.datetime :current_sign_in_at
|
18
|
+
t.datetime :last_sign_in_at
|
19
|
+
t.string :current_sign_in_ip
|
20
|
+
t.string :last_sign_in_ip
|
21
|
+
|
22
|
+
## Confirmable
|
23
|
+
# t.string :confirmation_token
|
24
|
+
# t.datetime :confirmed_at
|
25
|
+
# t.datetime :confirmation_sent_at
|
26
|
+
# t.string :unconfirmed_email # Only if using reconfirmable
|
27
|
+
|
28
|
+
## Lockable
|
29
|
+
# t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
|
30
|
+
# t.string :unlock_token # Only if unlock strategy is :email or :both
|
31
|
+
# t.datetime :locked_at
|
32
|
+
|
33
|
+
|
34
|
+
t.timestamps null: false
|
35
|
+
end
|
36
|
+
|
37
|
+
add_index :admins, :email, unique: true
|
38
|
+
add_index :admins, :reset_password_token, unique: true
|
39
|
+
# add_index :admins, :confirmation_token, unique: true
|
40
|
+
# add_index :admins, :unlock_token, unique: true
|
41
|
+
end
|
42
|
+
end
|
data/spec/rails_app/db/schema.rb
CHANGED
@@ -11,7 +11,25 @@
|
|
11
11
|
#
|
12
12
|
# It's strongly recommended that you check this file into your version control system.
|
13
13
|
|
14
|
-
ActiveRecord::Schema.define(version:
|
14
|
+
ActiveRecord::Schema.define(version: 20160209032439) do
|
15
|
+
|
16
|
+
create_table "admins", force: :cascade do |t|
|
17
|
+
t.string "email", default: "", null: false
|
18
|
+
t.string "encrypted_password", default: "", null: false
|
19
|
+
t.string "reset_password_token"
|
20
|
+
t.datetime "reset_password_sent_at"
|
21
|
+
t.datetime "remember_created_at"
|
22
|
+
t.integer "sign_in_count", default: 0, null: false
|
23
|
+
t.datetime "current_sign_in_at"
|
24
|
+
t.datetime "last_sign_in_at"
|
25
|
+
t.string "current_sign_in_ip"
|
26
|
+
t.string "last_sign_in_ip"
|
27
|
+
t.datetime "created_at", null: false
|
28
|
+
t.datetime "updated_at", null: false
|
29
|
+
end
|
30
|
+
|
31
|
+
add_index "admins", ["email"], name: "index_admins_on_email", unique: true
|
32
|
+
add_index "admins", ["reset_password_token"], name: "index_admins_on_reset_password_token", unique: true
|
15
33
|
|
16
34
|
create_table "users", force: :cascade do |t|
|
17
35
|
t.string "email", default: "", null: false
|