devise-multi-factor 3.1.8 → 3.2.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/app/controllers/devise/totp_controller.rb +5 -5
- data/app/controllers/devise/two_factor_authentication_controller.rb +4 -2
- data/config/locales/en.yml +3 -0
- data/lib/devise_multi_factor/hooks/two_factor_authenticatable.rb +2 -3
- data/lib/devise_multi_factor/remember_tfa_cookie.rb +39 -0
- data/lib/devise_multi_factor/version.rb +1 -1
- data/lib/devise_multi_factor.rb +1 -0
- data/spec/controllers/two_factor_authentication_controller_spec.rb +1 -1
- data/spec/features/two_factor_authenticatable_spec.rb +32 -32
- data/spec/lib/devise_multi_factor/tfa_remember_cookie_spec.rb +135 -0
- metadata +5 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b69e7dd9a398b8cfebfd6c3b1e17e4c94603f2097e56a54f78a5f4ce07ce9751
|
4
|
+
data.tar.gz: 76f657bc840dc7b9b3d48fb3fe66876194ba4481e6849aac1b8f28b3a22eae52
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f1dc861603dd878a0caddf74a316f7c385e3d103a0049de7cf5d1f2baadb29c7b7c1a1c25fa2c42a865c01eac178d3bd92621a53b278f6ec4f6c1f3fa0ec11d2
|
7
|
+
data.tar.gz: 513a0cb006edb6613a7c3e0af9cf1ab8594ed2841429b09d7b34e538c2a8a2d614f001aa86c1b7a87ef8946afddfdbcae16beda276cd437d8c0ca61fa84b0741
|
@@ -16,11 +16,11 @@ class Devise::TotpController < DeviseController
|
|
16
16
|
if resource.enroll_totp!(@otp_secret, params[:otp_attempt])
|
17
17
|
after_two_factor_enroll_success_for(resource)
|
18
18
|
else
|
19
|
-
flash.now[:error] = '
|
20
|
-
render_enroll_form
|
19
|
+
flash.now[:error] = I18n.t('devise.totp_setup.invalid_code')
|
20
|
+
render_enroll_form(status: :unprocessable_entity)
|
21
21
|
end
|
22
22
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
23
|
-
redirect_to send("new_#{resource_name}_two_factor_authentication_path"), flash: { error: '
|
23
|
+
redirect_to send("new_#{resource_name}_two_factor_authentication_path"), flash: { error: I18n.t('devise.totp_setup.invalid_signature') }
|
24
24
|
end
|
25
25
|
|
26
26
|
def show
|
@@ -40,9 +40,9 @@ class Devise::TotpController < DeviseController
|
|
40
40
|
.to_data_url
|
41
41
|
end
|
42
42
|
|
43
|
-
def render_enroll_form
|
43
|
+
def render_enroll_form(status: :ok)
|
44
44
|
@qr_code = generate_qr_code(@otp_secret)
|
45
|
-
render :new
|
45
|
+
render :new, status: status
|
46
46
|
end
|
47
47
|
|
48
48
|
def verifier
|
@@ -45,9 +45,11 @@ class Devise::TwoFactorAuthenticationController < DeviseController
|
|
45
45
|
expires_seconds = resource.class.remember_otp_session_for_seconds
|
46
46
|
|
47
47
|
if expires_seconds && expires_seconds > 0
|
48
|
+
expires_at = expires_seconds.seconds.from_now
|
48
49
|
cookies.signed[DeviseMultiFactor::REMEMBER_TFA_COOKIE_NAME] = {
|
49
|
-
|
50
|
-
|
50
|
+
value: DeviseMultiFactor::RememberTFACookie.new
|
51
|
+
.generate_cookie_data(resource, expires_at: expires_at),
|
52
|
+
expires: expires_at,
|
51
53
|
}
|
52
54
|
end
|
53
55
|
end
|
data/config/locales/en.yml
CHANGED
@@ -6,3 +6,6 @@ en:
|
|
6
6
|
max_login_attempts_reached: "Access completely denied as you have reached your attempts limit"
|
7
7
|
contact_administrator: "Please contact your system administrator."
|
8
8
|
code_has_been_sent: "Your authentication code has been sent."
|
9
|
+
totp_setup:
|
10
|
+
invalid_code: "The authenticator code provided was invalid!"
|
11
|
+
invalid_signature: "There has been a problem in the configuration process, please try again."
|
@@ -1,8 +1,7 @@
|
|
1
1
|
Warden::Manager.after_authentication do |resource, auth, options|
|
2
2
|
if auth.env["action_dispatch.cookies"]
|
3
|
-
|
4
|
-
|
5
|
-
bypass_by_cookie = actual_cookie_value == expected_cookie_value
|
3
|
+
cookie_value = auth.env["action_dispatch.cookies"].signed[DeviseMultiFactor::REMEMBER_TFA_COOKIE_NAME]
|
4
|
+
bypass_by_cookie = DeviseMultiFactor::RememberTFACookie.new.valid_cookie_data?(resource, cookie_value)
|
6
5
|
end
|
7
6
|
|
8
7
|
if resource.respond_to?(:need_two_factor_authentication?) && !bypass_by_cookie
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module DeviseMultiFactor
|
2
|
+
class RememberTFACookie
|
3
|
+
|
4
|
+
def generate_cookie_data(resource, expires_at:)
|
5
|
+
{ 'data' => generate_resource_data(resource) }
|
6
|
+
.merge('expires_at' => expires_at)
|
7
|
+
.to_json
|
8
|
+
end
|
9
|
+
|
10
|
+
def valid_cookie_data?(resource, cookie_data)
|
11
|
+
return false if cookie_data.nil?
|
12
|
+
|
13
|
+
parsed_data = JSON.parse(cookie_data)
|
14
|
+
expires_at = parse_time(parsed_data['expires_at'])
|
15
|
+
return false if expires_at.nil? || expires_at < Time.current
|
16
|
+
|
17
|
+
expected_data = generate_resource_data(resource)
|
18
|
+
parsed_data['data'] == expected_data
|
19
|
+
rescue JSON::ParserError
|
20
|
+
false
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def generate_resource_data(resource)
|
26
|
+
{
|
27
|
+
'resource_name' => resource.class.to_s,
|
28
|
+
'resource_id' => resource.public_send(Devise.second_factor_resource_id),
|
29
|
+
'remember_tfa_token' => resource.try(:remember_tfa_token) || '',
|
30
|
+
}
|
31
|
+
end
|
32
|
+
|
33
|
+
def parse_time(time_str)
|
34
|
+
Time.parse(time_str)
|
35
|
+
rescue StandardError
|
36
|
+
nil
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
data/lib/devise_multi_factor.rb
CHANGED
@@ -52,5 +52,6 @@ Devise.add_module :totp_enrollable, model: 'devise_multi_factor/models/totp_enro
|
|
52
52
|
|
53
53
|
require 'devise_multi_factor/orm/active_record' if defined?(ActiveRecord::Base)
|
54
54
|
require 'devise_multi_factor/routes'
|
55
|
+
require 'devise_multi_factor/remember_tfa_cookie'
|
55
56
|
require 'devise_multi_factor/models/two_factor_authenticatable'
|
56
57
|
require 'devise_multi_factor/rails'
|
@@ -1,7 +1,7 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
3
|
describe Devise::TwoFactorAuthenticationController, type: :controller do
|
4
|
-
describe 'is_fully_authenticated?
|
4
|
+
describe '#is_fully_authenticated?' do
|
5
5
|
def post_code(code)
|
6
6
|
if Rails::VERSION::MAJOR >= 5
|
7
7
|
post :update, params: { code: code }
|
@@ -1,7 +1,7 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
include AuthenticatedModelHelper
|
3
3
|
|
4
|
-
feature
|
4
|
+
feature 'User of two factor authentication' do
|
5
5
|
context 'sending two factor authentication code via SMS' do
|
6
6
|
shared_examples 'sends and authenticates code' do |user, type|
|
7
7
|
before do
|
@@ -48,62 +48,62 @@ feature "User of two factor authentication" do
|
|
48
48
|
it_behaves_like 'sends and authenticates code', create_user, 'encrypted'
|
49
49
|
end
|
50
50
|
|
51
|
-
scenario
|
51
|
+
scenario 'must be logged in' do
|
52
52
|
visit user_two_factor_authentication_path
|
53
53
|
|
54
|
-
expect(page).to have_content(
|
55
|
-
expect(page).to have_content(
|
54
|
+
expect(page).to have_content('Welcome Home')
|
55
|
+
expect(page).to have_content('You are signed out')
|
56
56
|
end
|
57
57
|
|
58
|
-
context
|
58
|
+
context 'when logged in' do
|
59
59
|
let(:user) { create_user }
|
60
60
|
|
61
61
|
background do
|
62
62
|
login_as user
|
63
63
|
end
|
64
64
|
|
65
|
-
scenario
|
66
|
-
visit dashboard_path +
|
65
|
+
scenario 'is redirected to TFA when path requires authentication' do
|
66
|
+
visit dashboard_path + '?A=param%20a&B=param%20b'
|
67
67
|
|
68
|
-
expect(page).to_not have_content(
|
68
|
+
expect(page).to_not have_content('Your Personal Dashboard')
|
69
69
|
|
70
|
-
fill_in
|
71
|
-
click_button
|
70
|
+
fill_in 'code', with: SMSProvider.last_message.body
|
71
|
+
click_button 'Submit'
|
72
72
|
|
73
|
-
expect(page).to have_content(
|
74
|
-
expect(page).to have_content(
|
75
|
-
expect(page).to have_content(
|
76
|
-
expect(page).to have_content(
|
73
|
+
expect(page).to have_content('Your Personal Dashboard')
|
74
|
+
expect(page).to have_content('You are signed in as Marissa')
|
75
|
+
expect(page).to have_content('Param A is param a')
|
76
|
+
expect(page).to have_content('Param B is param b')
|
77
77
|
end
|
78
78
|
|
79
|
-
scenario
|
79
|
+
scenario 'is locked out after max failed attempts' do
|
80
80
|
visit user_two_factor_authentication_path
|
81
81
|
|
82
82
|
max_attempts = User.max_login_attempts
|
83
83
|
|
84
84
|
max_attempts.times do
|
85
|
-
fill_in
|
86
|
-
click_button
|
85
|
+
fill_in 'code', with: "incorrect#{rand(100)}"
|
86
|
+
click_button 'Submit'
|
87
87
|
|
88
|
-
within(
|
89
|
-
expect(page).to have_content(
|
88
|
+
within('.flash.alert') do
|
89
|
+
expect(page).to have_content('Attempt failed')
|
90
90
|
end
|
91
91
|
end
|
92
92
|
|
93
|
-
expect(page).to have_content(
|
94
|
-
expect(page).to have_content(
|
93
|
+
expect(page).to have_content('Access completely denied')
|
94
|
+
expect(page).to have_content('You are signed out')
|
95
95
|
end
|
96
96
|
|
97
|
-
scenario
|
97
|
+
scenario 'cannot retry authentication after max attempts' do
|
98
98
|
user.update_attribute(:second_factor_attempts_count, User.max_login_attempts)
|
99
99
|
|
100
100
|
visit user_two_factor_authentication_path
|
101
101
|
|
102
|
-
expect(page).to have_content(
|
103
|
-
expect(page).to have_content(
|
102
|
+
expect(page).to have_content('Access completely denied')
|
103
|
+
expect(page).to have_content('You are signed out')
|
104
104
|
end
|
105
105
|
|
106
|
-
describe
|
106
|
+
describe 'rememberable TFA' do
|
107
107
|
before do
|
108
108
|
@original_remember_otp_session_for_seconds = User.remember_otp_session_for_seconds
|
109
109
|
User.remember_otp_session_for_seconds = 30.days
|
@@ -120,11 +120,11 @@ feature "User of two factor authentication" do
|
|
120
120
|
|
121
121
|
login_as user
|
122
122
|
visit dashboard_path
|
123
|
-
expect(page).to have_content(
|
124
|
-
expect(page).to have_content(
|
123
|
+
expect(page).to have_content('Your Personal Dashboard')
|
124
|
+
expect(page).to have_content('You are signed in as Marissa')
|
125
125
|
end
|
126
126
|
|
127
|
-
scenario
|
127
|
+
scenario 'requires TFA code again after 30 days' do
|
128
128
|
sms_sign_in
|
129
129
|
|
130
130
|
logout
|
@@ -132,8 +132,8 @@ feature "User of two factor authentication" do
|
|
132
132
|
Timecop.travel(30.days.from_now)
|
133
133
|
login_as user
|
134
134
|
visit dashboard_path
|
135
|
-
expect(page).to have_content(
|
136
|
-
expect(page).to have_content(
|
135
|
+
expect(page).to have_content('You are signed in as Marissa')
|
136
|
+
expect(page).to have_content('Enter the code that was sent to you')
|
137
137
|
end
|
138
138
|
|
139
139
|
scenario 'TFA should be different for different users' do
|
@@ -172,7 +172,7 @@ feature "User of two factor authentication" do
|
|
172
172
|
set_tfa_cookie(tfa_cookie1)
|
173
173
|
login_as(user2)
|
174
174
|
visit dashboard_path
|
175
|
-
expect(page).to have_content(
|
175
|
+
expect(page).to have_content('Enter the code that was sent to you')
|
176
176
|
end
|
177
177
|
|
178
178
|
scenario 'Delete cookie when user logs out if enabled' do
|
@@ -184,7 +184,7 @@ feature "User of two factor authentication" do
|
|
184
184
|
login_as user
|
185
185
|
|
186
186
|
visit dashboard_path
|
187
|
-
expect(page).to have_content(
|
187
|
+
expect(page).to have_content('Enter the code that was sent to you')
|
188
188
|
end
|
189
189
|
end
|
190
190
|
|
@@ -0,0 +1,135 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
class MockUser
|
4
|
+
def id
|
5
|
+
15
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
class MockUserWithRememberTFAToken
|
10
|
+
def id
|
11
|
+
45
|
12
|
+
end
|
13
|
+
|
14
|
+
def remember_tfa_token
|
15
|
+
'generated_token'
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
describe DeviseMultiFactor::RememberTFACookie do
|
20
|
+
subject(:remember_tfa_cookie) { described_class.new }
|
21
|
+
|
22
|
+
describe '#generate_cookie_data' do
|
23
|
+
describe 'when resource does not define remember_tfa_token method' do
|
24
|
+
let(:resource) { MockUser.new }
|
25
|
+
|
26
|
+
it 'returns cookie value with expiration date' do
|
27
|
+
result = remember_tfa_cookie.generate_cookie_data(
|
28
|
+
resource,
|
29
|
+
expires_at: Time.utc(2022, 1, 17, 19, 28, 0),
|
30
|
+
)
|
31
|
+
|
32
|
+
expect(JSON.parse(result)).to eql(
|
33
|
+
'data' => {
|
34
|
+
'resource_name' => 'MockUser',
|
35
|
+
'resource_id' => 15,
|
36
|
+
'remember_tfa_token' => '',
|
37
|
+
},
|
38
|
+
'expires_at' => '2022-01-17T19:28:00.000Z',
|
39
|
+
)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
describe 'when resource defines remember_tfa_token method' do
|
44
|
+
let(:resource) { MockUserWithRememberTFAToken.new }
|
45
|
+
|
46
|
+
it 'returns cookie value with expiration date and tfa remember token' do
|
47
|
+
result = remember_tfa_cookie.generate_cookie_data(
|
48
|
+
resource,
|
49
|
+
expires_at: Time.utc(2022, 1, 17, 19, 28, 0),
|
50
|
+
)
|
51
|
+
|
52
|
+
expect(JSON.parse(result)).to eql(
|
53
|
+
'data' => {
|
54
|
+
'resource_name' => 'MockUserWithRememberTFAToken',
|
55
|
+
'resource_id' => 45,
|
56
|
+
'remember_tfa_token' => 'generated_token',
|
57
|
+
},
|
58
|
+
'expires_at' => '2022-01-17T19:28:00.000Z',
|
59
|
+
)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
describe '#valid_cookie_data?' do
|
65
|
+
let(:cookie_data) do
|
66
|
+
{
|
67
|
+
'data' => {
|
68
|
+
'resource_name' => resource_name,
|
69
|
+
'resource_id' => resource_id,
|
70
|
+
'remember_tfa_token' => remember_tfa_token,
|
71
|
+
},
|
72
|
+
'expires_at' => '2022-01-17T19:28:00.000Z',
|
73
|
+
}.to_json
|
74
|
+
end
|
75
|
+
let(:resource_name) { 'MockUserWithRememberTFAToken' }
|
76
|
+
let(:resource_id) { 45 }
|
77
|
+
let(:remember_tfa_token) { 'generated_token' }
|
78
|
+
let(:resource) { MockUserWithRememberTFAToken.new }
|
79
|
+
|
80
|
+
describe 'when cookie data has expired' do
|
81
|
+
it 'returns false' do
|
82
|
+
Timecop.freeze(Time.utc(2022, 1, 17, 19, 29, 0)) do
|
83
|
+
result = remember_tfa_cookie.valid_cookie_data?(resource, cookie_data)
|
84
|
+
expect(result).to be(false)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
describe 'when cookie data has not expired' do
|
90
|
+
let(:date_before_expiration) { Time.utc(2022, 1, 17, 19, 27, 0) }
|
91
|
+
|
92
|
+
describe 'when resource class does not match' do
|
93
|
+
let(:resource_name) { 'MockUser' }
|
94
|
+
|
95
|
+
it 'returns false' do
|
96
|
+
Timecop.freeze(date_before_expiration) do
|
97
|
+
result = remember_tfa_cookie.valid_cookie_data?(resource, cookie_data)
|
98
|
+
expect(result).to be(false)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
describe 'when resource id does not match' do
|
104
|
+
let(:resource_id) { 46 }
|
105
|
+
|
106
|
+
it 'returns false' do
|
107
|
+
Timecop.freeze(date_before_expiration) do
|
108
|
+
result = remember_tfa_cookie.valid_cookie_data?(resource, cookie_data)
|
109
|
+
expect(result).to be(false)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
describe 'when remember tfa token does not match' do
|
115
|
+
let(:remember_tfa_token) { '' }
|
116
|
+
|
117
|
+
it 'returns false' do
|
118
|
+
Timecop.freeze(date_before_expiration) do
|
119
|
+
result = remember_tfa_cookie.valid_cookie_data?(resource, cookie_data)
|
120
|
+
expect(result).to be(false)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
describe 'when all cookie data matches' do
|
126
|
+
it 'returns true' do
|
127
|
+
Timecop.freeze(date_before_expiration) do
|
128
|
+
result = remember_tfa_cookie.valid_cookie_data?(resource, cookie_data)
|
129
|
+
expect(result).to be(true)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: devise-multi-factor
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 3.
|
4
|
+
version: 3.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Dmitrii Golub
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date:
|
12
|
+
date: 2022-01-17 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: rails
|
@@ -220,6 +220,7 @@ files:
|
|
220
220
|
- lib/devise_multi_factor/models/two_factor_authenticatable.rb
|
221
221
|
- lib/devise_multi_factor/orm/active_record.rb
|
222
222
|
- lib/devise_multi_factor/rails.rb
|
223
|
+
- lib/devise_multi_factor/remember_tfa_cookie.rb
|
223
224
|
- lib/devise_multi_factor/routes.rb
|
224
225
|
- lib/devise_multi_factor/schema.rb
|
225
226
|
- lib/devise_multi_factor/version.rb
|
@@ -230,6 +231,7 @@ files:
|
|
230
231
|
- spec/features/two_factor_authenticatable_spec.rb
|
231
232
|
- spec/generators/active_record/devise_multi_factor_generator_spec.rb
|
232
233
|
- spec/lib/devise_multi_factor/models/two_factor_authenticatable_spec.rb
|
234
|
+
- spec/lib/devise_multi_factor/tfa_remember_cookie_spec.rb
|
233
235
|
- spec/rails_app/.gitignore
|
234
236
|
- spec/rails_app/README.md
|
235
237
|
- spec/rails_app/Rakefile
|
@@ -308,7 +310,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
308
310
|
- !ruby/object:Gem::Version
|
309
311
|
version: '0'
|
310
312
|
requirements: []
|
311
|
-
rubygems_version: 3.0.3
|
313
|
+
rubygems_version: 3.0.3.1
|
312
314
|
signing_key:
|
313
315
|
specification_version: 4
|
314
316
|
summary: Two factor authentication plugin for devise
|