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
@@ -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:
|
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
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
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 %>, :
|
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
|
@@ -1,8 +1,32 @@
|
|
1
1
|
Warden::Manager.after_authentication do |user, auth, options|
|
2
|
-
|
3
|
-
|
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
|
-
|
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(
|
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(
|
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(
|
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.
|
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
|
@@ -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
|
-
|
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
|
-
|
7
|
-
|
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
|
-
|
10
|
-
|
11
|
-
|
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
|
-
|
14
|
-
expect(SMSProvider.messages).to be_empty
|
21
|
+
expect(page).to have_content 'Enter your personal code'
|
15
22
|
|
16
|
-
|
17
|
-
|
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
|
-
|
29
|
+
it 'authenticates a valid OTP code' do
|
30
|
+
visit new_user_session_path
|
31
|
+
complete_sign_in_form_for(user)
|
20
32
|
|
21
|
-
|
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
|
-
|
35
|
+
fill_in 'code', with: user.otp_code
|
36
|
+
click_button 'Submit'
|
28
37
|
|
29
|
-
|
30
|
-
|
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
|
-
|
34
|
-
|
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
|
-
|
37
|
-
|
50
|
+
scenario "must be logged in" do
|
51
|
+
visit user_two_factor_authentication_path
|
38
52
|
|
39
|
-
|
40
|
-
|
53
|
+
expect(page).to have_content("Welcome Home")
|
54
|
+
expect(page).to have_content("You are signed out")
|
55
|
+
end
|
41
56
|
|
42
|
-
|
43
|
-
|
44
|
-
|
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
|