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
@@ -22,7 +22,7 @@ class Devise::TwoFactorAuthenticationController < DeviseController
22
22
 
23
23
  if expires_seconds && expires_seconds > 0
24
24
  cookies.signed[TwoFactorAuthentication::REMEMBER_TFA_COOKIE_NAME] = {
25
- value: true,
25
+ value: "#{resource.class}-#{resource.id}",
26
26
  expires: expires_seconds.from_now
27
27
  }
28
28
  end
@@ -0,0 +1,7 @@
1
+ fr:
2
+ devise:
3
+ two_factor_authentication:
4
+ success: "Validation en deux étapes effectuée avec succès."
5
+ attempt_failed: "La connexion a échoué."
6
+ max_login_attempts_reached: "Limite de tentatives atteinte, accès refusé."
7
+ contact_administrator: "Merci de contacter votre administrateur système."
@@ -1,15 +1,10 @@
1
1
  class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Migration
2
- def up
3
- change_table :<%= table_name %> do |t|
4
- t.string :otp_secret_key
5
- t.integer :second_factor_attempts_count, :default => 0
6
- end
2
+ def change
3
+ add_column :<%= table_name %>, :second_factor_attempts_count, :integer, default: 0
4
+ add_column :<%= table_name %>, :encrypted_otp_secret_key, :string
5
+ add_column :<%= table_name %>, :encrypted_otp_secret_key_iv, :string
6
+ add_column :<%= table_name %>, :encrypted_otp_secret_key_salt, :string
7
7
 
8
- add_index :<%= table_name %>, :otp_secret_key, :unique => true
9
- end
10
-
11
- def down
12
- remove_column :<%= table_name %>, :otp_secret_key
13
- remove_column :<%= table_name %>, :second_factor_attempts_count
8
+ add_index :<%= table_name %>, :encrypted_otp_secret_key, unique: true
14
9
  end
15
10
  end
@@ -18,6 +18,9 @@ module Devise
18
18
 
19
19
  mattr_accessor :remember_otp_session_for_seconds
20
20
  @@remember_otp_session_for_seconds = 0
21
+
22
+ mattr_accessor :otp_secret_encryption_key
23
+ @@otp_secret_encryption_key = ''
21
24
  end
22
25
 
23
26
  module TwoFactorAuthentication
@@ -1,8 +1,32 @@
1
1
  Warden::Manager.after_authentication do |user, auth, options|
2
- if user.respond_to?(:need_two_factor_authentication?) &&
3
- !auth.env["action_dispatch.cookies"].signed[TwoFactorAuthentication::REMEMBER_TFA_COOKIE_NAME]
2
+ reset_otp_state_for(user)
3
+
4
+ expected_cookie_value = "#{user.class}-#{user.id}"
5
+ actual_cookie_value = auth.env["action_dispatch.cookies"].signed[TwoFactorAuthentication::REMEMBER_TFA_COOKIE_NAME]
6
+ if actual_cookie_value.nil?
7
+ bypass_by_cookie = false
8
+ else
9
+ bypass_by_cookie = actual_cookie_value == expected_cookie_value
10
+ end
11
+
12
+ if user.respond_to?(:need_two_factor_authentication?) && !bypass_by_cookie
4
13
  if auth.session(options[:scope])[TwoFactorAuthentication::NEED_AUTHENTICATION] = user.need_two_factor_authentication?(auth.request)
5
14
  user.send_two_factor_authentication_code
6
15
  end
7
16
  end
8
17
  end
18
+
19
+ Warden::Manager.before_logout do |user, _auth, _options|
20
+ reset_otp_state_for(user)
21
+ end
22
+
23
+ def reset_otp_state_for(user)
24
+ klass_string = "#{user.class}OtpSender"
25
+ return unless Object.const_defined?(klass_string)
26
+
27
+ klass = Object.const_get(klass_string)
28
+
29
+ otp_sender = klass.new(user)
30
+
31
+ otp_sender.reset_otp_state if otp_sender.respond_to?(:reset_otp_state)
32
+ end
@@ -1,5 +1,6 @@
1
1
  require 'two_factor_authentication/hooks/two_factor_authenticatable'
2
2
  require 'rotp'
3
+ require 'encryptor'
3
4
 
4
5
  module Devise
5
6
  module Models
@@ -8,46 +9,37 @@ module Devise
8
9
 
9
10
  module ClassMethods
10
11
  def has_one_time_password(options = {})
11
-
12
- cattr_accessor :otp_column_name
13
- self.otp_column_name = "otp_secret_key"
14
-
15
12
  include InstanceMethodsOnActivation
13
+ include EncryptionInstanceMethods if options[:encrypted] == true
16
14
 
17
15
  before_create { populate_otp_column }
18
-
19
- if respond_to?(:attributes_protected_by_default)
20
- def self.attributes_protected_by_default #:nodoc:
21
- super + [self.otp_column_name]
22
- end
23
- end
24
16
  end
25
- ::Devise::Models.config(self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length, :remember_otp_session_for_seconds)
17
+
18
+ ::Devise::Models.config(
19
+ self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length,
20
+ :remember_otp_session_for_seconds, :otp_secret_encryption_key)
26
21
  end
27
22
 
28
23
  module InstanceMethodsOnActivation
29
24
  def authenticate_otp(code, options = {})
30
- totp = ROTP::TOTP.new(self.otp_column, { digits: options[:otp_length] || self.class.otp_length })
25
+ totp = ROTP::TOTP.new(
26
+ otp_secret_key, digits: options[:otp_length] || self.class.otp_length
27
+ )
31
28
  drift = options[:drift] || self.class.allowed_otp_drift_seconds
32
29
 
33
30
  totp.verify_with_drift(code, drift)
34
31
  end
35
32
 
36
33
  def otp_code(time = Time.now, options = {})
37
- ROTP::TOTP.new(self.otp_column, { digits: options[:otp_length] || self.class.otp_length }).at(time, true)
34
+ ROTP::TOTP.new(
35
+ otp_secret_key,
36
+ digits: options[:otp_length] || self.class.otp_length
37
+ ).at(time, true)
38
38
  end
39
39
 
40
40
  def provisioning_uri(account = nil, options = {})
41
41
  account ||= self.email if self.respond_to?(:email)
42
- ROTP::TOTP.new(self.otp_column, options).provisioning_uri(account)
43
- end
44
-
45
- def otp_column
46
- self.send(self.class.otp_column_name)
47
- end
48
-
49
- def otp_column=(attr)
50
- self.send("#{self.class.otp_column_name}=", attr)
42
+ ROTP::TOTP.new(otp_secret_key, options).provisioning_uri(account)
51
43
  end
52
44
 
53
45
  def need_two_factor_authentication?(request)
@@ -67,9 +59,83 @@ module Devise
67
59
  end
68
60
 
69
61
  def populate_otp_column
70
- self.otp_column = ROTP::Base32.random_base32
62
+ self.otp_secret_key = ROTP::Base32.random_base32
63
+ end
64
+ end
65
+
66
+ module EncryptionInstanceMethods
67
+ def otp_secret_key
68
+ decrypt(encrypted_otp_secret_key)
69
+ end
70
+
71
+ def otp_secret_key=(value)
72
+ self.encrypted_otp_secret_key = encrypt(value)
73
+ end
74
+
75
+ private
76
+
77
+ def decrypt(encrypted_value)
78
+ return encrypted_value if encrypted_value.blank?
79
+
80
+ encrypted_value = encrypted_value.unpack('m').first
81
+
82
+ value = ::Encryptor.decrypt(encryption_options_for(encrypted_value))
83
+
84
+ if defined?(Encoding)
85
+ encoding = Encoding.default_internal || Encoding.default_external
86
+ value = value.force_encoding(encoding.name)
87
+ end
88
+
89
+ value
90
+ end
91
+
92
+ def encrypt(value)
93
+ return value if value.blank?
94
+
95
+ value = value.to_s
96
+ encrypted_value = ::Encryptor.encrypt(encryption_options_for(value))
97
+
98
+ encrypted_value = [encrypted_value].pack('m')
99
+
100
+ encrypted_value
101
+ end
102
+
103
+ def encryption_options_for(value)
104
+ {
105
+ value: value,
106
+ key: Devise.otp_secret_encryption_key,
107
+ iv: iv_for_attribute,
108
+ salt: salt_for_attribute
109
+ }
110
+ end
111
+
112
+ def iv_for_attribute(algorithm = 'aes-256-cbc')
113
+ iv = encrypted_otp_secret_key_iv
114
+
115
+ if iv.nil?
116
+ algo = OpenSSL::Cipher::Cipher.new(algorithm)
117
+ iv = [algo.random_iv].pack('m')
118
+ self.encrypted_otp_secret_key_iv = iv
119
+ end
120
+
121
+ iv.unpack('m').first if iv.present?
122
+ end
123
+
124
+ def salt_for_attribute
125
+ salt = encrypted_otp_secret_key_salt ||
126
+ self.encrypted_otp_secret_key_salt = generate_random_base64_encoded_salt
127
+
128
+ decode_salt_if_encoded(salt)
71
129
  end
72
130
 
131
+ def generate_random_base64_encoded_salt
132
+ prefix = '_'
133
+ prefix + [SecureRandom.random_bytes].pack('m')
134
+ end
135
+
136
+ def decode_salt_if_encoded(salt)
137
+ salt.slice(0).eql?('_') ? salt.slice(1..-1).unpack('m').first : salt
138
+ end
73
139
  end
74
140
  end
75
141
  end
@@ -1,11 +1,19 @@
1
1
  module TwoFactorAuthentication
2
2
  module Schema
3
- def otp_secret_key
4
- apply_devise_schema :otp_secret_key, String
5
- end
6
-
7
3
  def second_factor_attempts_count
8
4
  apply_devise_schema :second_factor_attempts_count, Integer, :default => 0
9
5
  end
6
+
7
+ def encrypted_otp_secret_key
8
+ apply_devise_schema :encrypted_otp_secret_key, String
9
+ end
10
+
11
+ def encrypted_otp_secret_key_iv
12
+ apply_devise_schema :encrypted_otp_secret_key_iv, String
13
+ end
14
+
15
+ def encrypted_otp_secret_key_salt
16
+ apply_devise_schema :encrypted_otp_secret_key_salt, String
17
+ end
10
18
  end
11
19
  end
@@ -1,3 +1,3 @@
1
1
  module TwoFactorAuthentication
2
- VERSION = "1.1.4".freeze
2
+ VERSION = "1.1.5".freeze
3
3
  end
@@ -0,0 +1,33 @@
1
+ require 'spec_helper'
2
+
3
+ describe Devise::TwoFactorAuthenticationController, type: :controller do
4
+ describe 'is_fully_authenticated? helper' do
5
+ before do
6
+ sign_in
7
+ end
8
+
9
+ context 'after user enters valid OTP code' do
10
+ it 'returns true' do
11
+ post :update, code: controller.current_user.otp_code
12
+
13
+ expect(subject.is_fully_authenticated?).to eq true
14
+ end
15
+ end
16
+
17
+ context 'when user has not entered any OTP yet' do
18
+ it 'returns false' do
19
+ get :show
20
+
21
+ expect(subject.is_fully_authenticated?).to eq false
22
+ end
23
+ end
24
+
25
+ context 'when user enters an invalid OTP' do
26
+ it 'returns false' do
27
+ post :update, code: '12345'
28
+
29
+ expect(subject.is_fully_authenticated?).to eq false
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,47 +1,64 @@
1
1
  require 'spec_helper'
2
+ include AuthenticatedModelHelper
2
3
 
3
4
  feature "User of two factor authentication" do
4
- let(:user) { create_user }
5
+ context 'sending two factor authentication code via SMS' do
6
+ shared_examples 'sends and authenticates code' do |user, type|
7
+ before do
8
+ if type == 'encrypted'
9
+ allow(User).to receive(:has_one_time_password).with(encrypted: true)
10
+ end
11
+ end
5
12
 
6
- scenario "must be logged in" do
7
- visit user_two_factor_authentication_path
13
+ it 'does not send an SMS before the user has signed in' do
14
+ expect(SMSProvider.messages).to be_empty
15
+ end
8
16
 
9
- expect(page).to have_content("Welcome Home")
10
- expect(page).to have_content("You are signed out")
11
- end
17
+ it 'sends code via SMS after sign in' do
18
+ visit new_user_session_path
19
+ complete_sign_in_form_for(user)
12
20
 
13
- scenario "sends two factor authentication code after sign in" do
14
- expect(SMSProvider.messages).to be_empty
21
+ expect(page).to have_content 'Enter your personal code'
15
22
 
16
- visit new_user_session_path
17
- complete_sign_in_form_for(user)
23
+ expect(SMSProvider.messages.size).to eq(1)
24
+ message = SMSProvider.last_message
25
+ expect(message.to).to eq(user.phone_number)
26
+ expect(message.body).to eq(user.otp_code)
27
+ end
18
28
 
19
- expect(page).to have_content "Enter your personal code"
29
+ it 'authenticates a valid OTP code' do
30
+ visit new_user_session_path
31
+ complete_sign_in_form_for(user)
20
32
 
21
- expect(SMSProvider.messages.size).to eq(1)
22
- message = SMSProvider.last_message
23
- expect(message.to).to eq(user.phone_number)
24
- expect(message.body).to eq(user.otp_code)
25
- end
33
+ expect(page).to have_content('You are signed in as Marissa')
26
34
 
27
- context "when logged in" do
35
+ fill_in 'code', with: user.otp_code
36
+ click_button 'Submit'
28
37
 
29
- background do
30
- login_as user
38
+ within('.flash.notice') do
39
+ expect(page).to have_content('Two factor authentication successful.')
40
+ end
41
+
42
+ expect(current_path).to eq root_path
43
+ end
31
44
  end
32
45
 
33
- scenario "can fill in TFA code" do
34
- visit user_two_factor_authentication_path
46
+ it_behaves_like 'sends and authenticates code', create_user('not_encrypted')
47
+ it_behaves_like 'sends and authenticates code', create_user, 'encrypted'
48
+ end
35
49
 
36
- expect(page).to have_content("You are signed in as Marissa")
37
- expect(page).to have_content("Enter your personal code")
50
+ scenario "must be logged in" do
51
+ visit user_two_factor_authentication_path
38
52
 
39
- fill_in "code", with: user.otp_code
40
- click_button "Submit"
53
+ expect(page).to have_content("Welcome Home")
54
+ expect(page).to have_content("You are signed out")
55
+ end
41
56
 
42
- within(".flash.notice") do
43
- expect(page).to have_content("Two factor authentication successful.")
44
- end
57
+ context "when logged in" do
58
+ let(:user) { create_user }
59
+
60
+ background do
61
+ login_as user
45
62
  end
46
63
 
47
64
  scenario "is redirected to TFA when path requires authentication" do
@@ -121,6 +138,125 @@ feature "User of two factor authentication" do
121
138
  expect(page).to have_content("You are signed in as Marissa")
122
139
  expect(page).to have_content("Enter your personal code")
123
140
  end
141
+
142
+ scenario 'TFA should be different for different users' do
143
+ visit user_two_factor_authentication_path
144
+ fill_in 'code', with: user.otp_code
145
+ click_button 'Submit'
146
+
147
+ tfa_cookie1 = get_tfa_cookie()
148
+
149
+ logout
150
+ reset_session!
151
+
152
+ user2 = create_user()
153
+ login_as(user2)
154
+ visit user_two_factor_authentication_path
155
+ fill_in 'code', with: user2.otp_code
156
+ click_button 'Submit'
157
+
158
+ tfa_cookie2 = get_tfa_cookie()
159
+
160
+ expect(tfa_cookie1).not_to eq tfa_cookie2
161
+ end
162
+
163
+ scenario 'TFA should be unique for specific user' do
164
+ visit user_two_factor_authentication_path
165
+ fill_in 'code', with: user.otp_code
166
+ click_button 'Submit'
167
+
168
+ tfa_cookie1 = get_tfa_cookie()
169
+
170
+ logout
171
+ reset_session!
172
+
173
+ user2 = create_user()
174
+ set_tfa_cookie(tfa_cookie1)
175
+ login_as(user2)
176
+ visit dashboard_path
177
+ expect(page).to have_content('Enter your personal code')
178
+ end
179
+ end
180
+
181
+ it 'sets the warden session need_two_factor_authentication key to true' do
182
+ session_hash = { 'need_two_factor_authentication' => true }
183
+
184
+ expect(page.get_rack_session_key('warden.user.user.session')).to eq session_hash
185
+ end
186
+ end
187
+
188
+ describe 'signing in' do
189
+ let(:user) { create_user }
190
+
191
+ scenario 'when UserOtpSender#reset_otp_state is defined' do
192
+ klass = stub_const 'UserOtpSender', Class.new
193
+
194
+ klass.class_eval do
195
+ def reset_otp_state; end
196
+ end
197
+
198
+ otp_sender = instance_double(UserOtpSender)
199
+ expect(UserOtpSender).to receive(:new).with(user).and_return(otp_sender)
200
+ expect(otp_sender).to receive(:reset_otp_state)
201
+
202
+ visit new_user_session_path
203
+ complete_sign_in_form_for(user)
204
+ end
205
+
206
+ scenario 'when UserOtpSender#reset_otp_state is not defined' do
207
+ klass = stub_const 'UserOtpSender', Class.new
208
+
209
+ klass.class_eval do
210
+ def reset_otp_state; end
211
+ end
212
+
213
+ otp_sender = instance_double(UserOtpSender)
214
+ allow(otp_sender).to receive(:respond_to?).with(:reset_otp_state).and_return(false)
215
+
216
+ expect(UserOtpSender).to receive(:new).with(user).and_return(otp_sender)
217
+ expect(otp_sender).to_not receive(:reset_otp_state)
218
+
219
+ visit new_user_session_path
220
+ complete_sign_in_form_for(user)
221
+ end
222
+ end
223
+
224
+ describe 'signing out' do
225
+ let(:user) { create_user }
226
+
227
+ scenario 'when UserOtpSender#reset_otp_state is defined' do
228
+ visit new_user_session_path
229
+ complete_sign_in_form_for(user)
230
+
231
+ klass = stub_const 'UserOtpSender', Class.new
232
+ klass.class_eval do
233
+ def reset_otp_state; end
234
+ end
235
+
236
+ otp_sender = instance_double(UserOtpSender)
237
+
238
+ expect(UserOtpSender).to receive(:new).with(user).and_return(otp_sender)
239
+ expect(otp_sender).to receive(:reset_otp_state)
240
+
241
+ visit destroy_user_session_path
242
+ end
243
+
244
+ scenario 'when UserOtpSender#reset_otp_state is not defined' do
245
+ visit new_user_session_path
246
+ complete_sign_in_form_for(user)
247
+
248
+ klass = stub_const 'UserOtpSender', Class.new
249
+ klass.class_eval do
250
+ def reset_otp_state; end
251
+ end
252
+
253
+ otp_sender = instance_double(UserOtpSender)
254
+ allow(otp_sender).to receive(:respond_to?).with(:reset_otp_state).and_return(false)
255
+
256
+ expect(UserOtpSender).to receive(:new).with(user).and_return(otp_sender)
257
+ expect(otp_sender).to_not receive(:reset_otp_state)
258
+
259
+ visit destroy_user_session_path
124
260
  end
125
261
  end
126
262
  end