two_factor_authentication 1.1.5 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
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