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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.codeclimate.yml +21 -0
  3. data/.rubocop.yml +295 -0
  4. data/.travis.yml +4 -5
  5. data/CHANGELOG.md +24 -14
  6. data/README.md +57 -65
  7. data/app/controllers/devise/two_factor_authentication_controller.rb +28 -12
  8. data/app/views/devise/two_factor_authentication/show.html.erb +10 -1
  9. data/config/locales/en.yml +1 -0
  10. data/config/locales/es.yml +8 -0
  11. data/config/locales/fr.yml +1 -0
  12. data/config/locales/ru.yml +1 -0
  13. data/lib/generators/active_record/templates/migration.rb +3 -0
  14. data/lib/two_factor_authentication.rb +9 -0
  15. data/lib/two_factor_authentication/controllers/helpers.rb +1 -1
  16. data/lib/two_factor_authentication/hooks/two_factor_authenticatable.rb +4 -23
  17. data/lib/two_factor_authentication/models/two_factor_authenticatable.rb +68 -19
  18. data/lib/two_factor_authentication/routes.rb +3 -1
  19. data/lib/two_factor_authentication/schema.rb +12 -0
  20. data/lib/two_factor_authentication/version.rb +1 -1
  21. data/spec/controllers/two_factor_authentication_controller_spec.rb +2 -2
  22. data/spec/features/two_factor_authenticatable_spec.rb +36 -73
  23. data/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +137 -80
  24. data/spec/rails_app/app/controllers/home_controller.rb +1 -1
  25. data/spec/rails_app/app/models/admin.rb +6 -0
  26. data/spec/rails_app/app/models/encrypted_user.rb +2 -1
  27. data/spec/rails_app/app/models/guest_user.rb +8 -1
  28. data/spec/rails_app/app/models/user.rb +2 -2
  29. data/spec/rails_app/config/initializers/devise.rb +2 -2
  30. data/spec/rails_app/config/routes.rb +1 -0
  31. data/spec/rails_app/db/migrate/20140403184646_devise_create_users.rb +1 -1
  32. data/spec/rails_app/db/migrate/20160209032439_devise_create_admins.rb +42 -0
  33. data/spec/rails_app/db/schema.rb +19 -1
  34. data/spec/support/authenticated_model_helper.rb +22 -15
  35. data/spec/support/controller_helper.rb +1 -1
  36. data/spec/support/totp_helper.rb +11 -0
  37. data/two_factor_authentication.gemspec +1 -1
  38. 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 '#otp_code' do
6
- shared_examples 'otp_code' do |instance|
7
- subject { instance.otp_code(time) }
8
- let(:time) { 1_392_852_456 }
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
- it 'returns an error if no secret is set' do
11
- expect { subject }.to raise_error Exception
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
- context 'secret is set' do
15
- before :each do
16
- instance.otp_secret_key = '2z6hxkdwi3uvrnpn'
17
- end
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
- it 'does not return an error' do
20
- subject
21
- end
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
- it 'matches Devise configured length' do
24
- expect(subject.length).to eq(Devise.otp_length)
25
- end
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
- context 'with a known time' do
28
- let(:time) { 1_392_852_756 }
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
- it 'returns a known result' do
31
- expect(subject).
32
- to eq('0000000524562202'.split(//).last(Devise.otp_length).join)
33
- end
34
- end
45
+ context 'after generating an OTP' do
46
+ before :each do
47
+ instance.create_direct_otp
48
+ end
35
49
 
36
- context 'with a known time yielding a result with less than 6 digits' do
37
- let(:time) { 1_393_065_856 }
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
- 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
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
- it_behaves_like 'otp_code', GuestUser.new
48
- it_behaves_like 'otp_code', EncryptedUser.new
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 '#authenticate_otp' do
52
- shared_examples 'authenticate_otp' do |instance|
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.authenticate_otp(code)
81
+ user.authenticate_totp(code)
59
82
  end
60
83
 
61
84
  it 'authenticates a recently created code' do
62
- code = instance.otp_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 = instance.otp_code(1.minutes.ago.to_i)
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 'authenticate_otp', GuestUser.new
73
- it_behaves_like 'authenticate_otp', EncryptedUser.new
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
- before do
95
- instance.email = 'houdini@example.com'
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
- 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
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
- it 'returns uri with issuer option' do
105
- expect(instance.provisioning_uri('houdini')).
106
- to match(%r{otpauth://totp/houdini\?secret=\w{16}$})
107
- end
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
- it 'returns uri with issuer option' do
110
- require 'cgi'
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 = URI.parse(instance.provisioning_uri('houdini', issuer: 'Magic'))
113
- params = CGI.parse(uri.query)
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
- 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}/)
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 '#populate_otp_column' do
128
- shared_examples 'populate_otp_column' do |klass|
162
+ describe '#generate_totp_secret' do
163
+ shared_examples 'generate_totp_secret' do |klass|
129
164
  let(:instance) { klass.new }
130
165
 
131
- it 'populates otp_column on create' do
132
- expect(instance.otp_secret_key).to be_nil
166
+ it 'returns a 16 character string' do
167
+ secret = instance.generate_totp_secret
133
168
 
134
- # populate_otp_column called via before_create
135
- instance.run_callbacks :create
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
- expect(instance.otp_secret_key).to match(/\w{16}/)
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 'repopulates otp_column' do
141
- instance.run_callbacks :create
142
- original_key = instance.otp_secret_key
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
- instance.populate_otp_column
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
- expect(instance.otp_secret_key).to match(/\w{16}/)
147
- expect(instance.otp_secret_key).to_not eq(original_key)
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 'populate_otp_column', GuestUser
152
- it_behaves_like 'populate_otp_column', EncryptedUser
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' do
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')
@@ -1,5 +1,5 @@
1
1
  class HomeController < ApplicationController
2
- before_filter :authenticate_user!, only: :dashboard
2
+ before_action :authenticate_user!, only: :dashboard
3
3
 
4
4
  def index
5
5
  end
@@ -0,0 +1,6 @@
1
+ class Admin < ActiveRecord::Base
2
+ # Include default devise modules. Others available are:
3
+ # :confirmable, :lockable, :timeoutable and :omniauthable
4
+ devise :database_authenticatable, :registerable,
5
+ :recoverable, :rememberable, :trackable, :validatable
6
+ end
@@ -8,7 +8,8 @@ class EncryptedUser
8
8
  :encrypted_otp_secret_key_iv,
9
9
  :encrypted_otp_secret_key_salt,
10
10
  :email,
11
- :second_factor_attempts_count
11
+ :second_factor_attempts_count,
12
+ :totp_timestamp
12
13
 
13
14
  has_one_time_password(encrypted: true)
14
15
  end
@@ -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, :second_factor_attempts_count
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: otp_code)
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
- # config.default_scope = :user
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
- # config.sign_out_all_scopes = true
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
@@ -1,4 +1,5 @@
1
1
  Dummy::Application.routes.draw do
2
+ devise_for :admins
2
3
  root to: "home#index"
3
4
 
4
5
  match "/dashboard", to: "home#dashboard", as: :dashboard, via: [:get]
@@ -31,7 +31,7 @@ class DeviseCreateUsers < ActiveRecord::Migration
31
31
  # t.datetime :locked_at
32
32
 
33
33
 
34
- t.timestamps
34
+ t.timestamps null: false
35
35
  end
36
36
 
37
37
  add_index :users, :email, unique: true
@@ -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
@@ -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: 20151228230340) do
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