devise-security 0.14.3 → 0.15.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/README.md +114 -58
- data/app/controllers/devise/password_expired_controller.rb +10 -1
- data/app/views/devise/paranoid_verification_code/show.html.erb +3 -3
- data/app/views/devise/password_expired/show.html.erb +5 -5
- data/config/locales/by.yml +48 -0
- data/config/locales/cs.yml +40 -0
- data/config/locales/de.yml +12 -2
- data/config/locales/en.yml +12 -1
- data/config/locales/es.yml +9 -9
- data/config/locales/fa.yml +40 -0
- data/config/locales/hi.yml +41 -0
- data/config/locales/it.yml +34 -4
- data/config/locales/ja.yml +1 -1
- data/config/locales/nl.yml +40 -0
- data/config/locales/pt.yml +40 -0
- data/config/locales/ru.yml +48 -0
- data/config/locales/uk.yml +48 -0
- data/config/locales/zh_CN.yml +40 -0
- data/config/locales/zh_TW.yml +40 -0
- data/lib/devise-security/controllers/helpers.rb +59 -50
- data/lib/devise-security/hooks/password_expirable.rb +2 -0
- data/lib/devise-security/hooks/session_limitable.rb +13 -7
- data/lib/devise-security/models/password_expirable.rb +5 -1
- data/lib/devise-security/models/session_limitable.rb +8 -1
- data/lib/devise-security/validators/password_complexity_validator.rb +4 -2
- data/lib/devise-security/version.rb +1 -1
- data/lib/generators/devise_security/install_generator.rb +2 -2
- data/test/controllers/test_password_expired_controller.rb +111 -16
- data/test/dummy/app/assets/config/manifest.js +3 -0
- data/test/dummy/config/routes.rb +3 -3
- data/test/dummy/log/test.log +1799 -0
- data/test/integration/test_password_expirable_workflow.rb +57 -0
- data/test/orm/active_record.rb +4 -1
- data/test/support/integration_helpers.rb +1 -1
- data/test/test_complexity_validator.rb +12 -0
- data/test/test_helper.rb +10 -3
- data/test/test_install_generator.rb +10 -0
- data/test/test_session_limitable.rb +17 -0
- data/test/tmp/config/initializers/devise-security.rb +44 -0
- data/test/tmp/config/locales/devise.security_extension.de.yml +38 -0
- data/test/tmp/config/locales/devise.security_extension.en.yml +40 -0
- data/test/tmp/config/locales/devise.security_extension.es.yml +29 -0
- data/test/tmp/config/locales/devise.security_extension.fa.yml +40 -0
- data/test/tmp/config/locales/devise.security_extension.fr.yml +29 -0
- data/test/tmp/config/locales/devise.security_extension.it.yml +40 -0
- data/test/tmp/config/locales/devise.security_extension.ja.yml +29 -0
- data/test/tmp/config/locales/devise.security_extension.nl.yml +40 -0
- data/test/tmp/config/locales/devise.security_extension.pt.yml +40 -0
- data/test/tmp/config/locales/devise.security_extension.ru.yml +48 -0
- data/test/tmp/config/locales/devise.security_extension.tr.yml +17 -0
- data/test/tmp/config/locales/devise.security_extension.uk.yml +48 -0
- data/test/tmp/config/locales/devise.security_extension.zh_CN.yml +40 -0
- metadata +152 -118
- data/.codeclimate.yml +0 -63
- data/.document +0 -5
- data/.gitignore +0 -43
- data/.mdlrc +0 -1
- data/.rubocop.yml +0 -64
- data/.ruby-version +0 -1
- data/.travis.yml +0 -39
- data/Appraisals +0 -35
- data/Gemfile +0 -10
- data/Rakefile +0 -27
- data/devise-security.gemspec +0 -50
- data/gemfiles/rails_4.2_stable.gemfile +0 -16
- data/gemfiles/rails_5.0_stable.gemfile +0 -15
- data/gemfiles/rails_5.1_stable.gemfile +0 -15
- data/gemfiles/rails_5.2_stable.gemfile +0 -15
- data/gemfiles/rails_6.0_beta.gemfile +0 -15
- data/test/dummy/app/models/.gitkeep +0 -0
@@ -0,0 +1,40 @@
|
|
1
|
+
zh_TW:
|
2
|
+
errors:
|
3
|
+
messages:
|
4
|
+
taken_in_past: '曾被使用過。'
|
5
|
+
equal_to_current_password: '必須與目前密碼不同。'
|
6
|
+
password_complexity:
|
7
|
+
digit:
|
8
|
+
one: 必須包含至少一個數字
|
9
|
+
other: 必須包含至少 %{count} 個數字
|
10
|
+
lower:
|
11
|
+
one: 必須包含至少一個小寫字母
|
12
|
+
other: 必須包含至少 %{count} 個小寫字母
|
13
|
+
symbol:
|
14
|
+
one: 必須包含至少一個特殊符號
|
15
|
+
other: 必須包含至少 %{count} 個特殊符號
|
16
|
+
upper:
|
17
|
+
one: 必須包含至少一個大寫字母
|
18
|
+
other: 必須包含至少 %{count} 個大寫字母
|
19
|
+
devise:
|
20
|
+
invalid_captcha: '輸入的驗證碼無效。'
|
21
|
+
invalid_security_question: '安全問題答案無效。'
|
22
|
+
paranoid_verify:
|
23
|
+
code_required: '請輸入由我們客服團隊提供的代碼'
|
24
|
+
paranoid_verification_code:
|
25
|
+
show:
|
26
|
+
submit_verification_code: 送出驗證碼
|
27
|
+
verification_code: 驗證碼
|
28
|
+
submit: 送出
|
29
|
+
password_expired:
|
30
|
+
updated: '你的新密碼已儲存'
|
31
|
+
change_required: '你的密碼已經過期,請更新密碼。'
|
32
|
+
show:
|
33
|
+
renew_your_password: 更新你的密碼
|
34
|
+
current_password: 目前密碼
|
35
|
+
new_password: 新密碼
|
36
|
+
new_password_confirmation: 確認新密碼
|
37
|
+
change_my_password: 更改我的密碼
|
38
|
+
failure:
|
39
|
+
session_limited: '你的登入憑證已在另一個瀏覽器上被使用,請重新登入以在此瀏覽器繼續使用。'
|
40
|
+
expired: '你的帳號因過久沒使用而已經過期,請洽網站管理員。'
|
@@ -40,71 +40,80 @@ module DeviseSecurity
|
|
40
40
|
|
41
41
|
# controller instance methods
|
42
42
|
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
43
|
+
private
|
44
|
+
|
45
|
+
# Called as a `before_action` on all actions on any controller that uses
|
46
|
+
# this helper. If the user's session is marked as having an expired
|
47
|
+
# password we double check in case it has been changed by another process,
|
48
|
+
# then redirect to the password change url.
|
49
|
+
#
|
50
|
+
# @note `Warden::Manager.after_authentication` is run AFTER this method
|
51
|
+
#
|
52
|
+
# @note Once the warden session has `'password_expired'` set to `false`,
|
53
|
+
# it will **never** be checked again until the user re-logs in.
|
54
|
+
def handle_password_change
|
55
|
+
return if warden.nil?
|
56
|
+
|
57
|
+
if !devise_controller? &&
|
58
|
+
!ignore_password_expire? &&
|
59
|
+
!request.format.nil? &&
|
60
|
+
request.format.html?
|
61
|
+
Devise.mappings.keys.flatten.any? do |scope|
|
62
|
+
if signed_in?(scope) && warden.session(scope)['password_expired'] == true
|
63
|
+
if send(:"current_#{scope}").try(:need_change_password?)
|
64
|
+
store_location_for(scope, request.original_fullpath) if request.get?
|
65
|
+
redirect_for_password_change(scope)
|
66
|
+
else
|
67
|
+
warden.session(scope)['password_expired'] = false
|
60
68
|
end
|
61
69
|
end
|
62
70
|
end
|
63
71
|
end
|
72
|
+
end
|
64
73
|
|
65
|
-
|
66
|
-
|
67
|
-
|
74
|
+
# lookup if extra (paranoid) code verification is needed
|
75
|
+
def handle_paranoid_verification
|
76
|
+
return if warden.nil?
|
68
77
|
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
end
|
78
|
+
if !devise_controller? && !request.format.nil? && request.format.html?
|
79
|
+
Devise.mappings.keys.flatten.any? do |scope|
|
80
|
+
if signed_in?(scope) && warden.session(scope)['paranoid_verify']
|
81
|
+
store_location_for(scope, request.original_fullpath) if request.get?
|
82
|
+
redirect_for_paranoid_verification scope
|
83
|
+
return
|
76
84
|
end
|
77
85
|
end
|
78
86
|
end
|
87
|
+
end
|
79
88
|
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
89
|
+
# redirect for password update with alert message
|
90
|
+
def redirect_for_password_change(scope)
|
91
|
+
redirect_to change_password_required_path_for(scope), alert: I18n.t('change_required', { scope: 'devise.password_expired' })
|
92
|
+
end
|
84
93
|
|
85
|
-
|
86
|
-
|
87
|
-
|
94
|
+
def redirect_for_paranoid_verification(scope)
|
95
|
+
redirect_to paranoid_verification_code_path_for(scope), alert: I18n.t('code_required', { scope: 'devise.paranoid_verify' })
|
96
|
+
end
|
88
97
|
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
98
|
+
# path for change password
|
99
|
+
def change_password_required_path_for(resource_or_scope = nil)
|
100
|
+
scope = Devise::Mapping.find_scope!(resource_or_scope)
|
101
|
+
change_path = "#{scope}_password_expired_path"
|
102
|
+
send(change_path)
|
103
|
+
end
|
95
104
|
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
105
|
+
def paranoid_verification_code_path_for(resource_or_scope = nil)
|
106
|
+
scope = Devise::Mapping.find_scope!(resource_or_scope)
|
107
|
+
change_path = "#{scope}_paranoid_verification_code_path"
|
108
|
+
send(change_path)
|
109
|
+
end
|
101
110
|
|
102
|
-
|
111
|
+
protected
|
103
112
|
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
113
|
+
# allow to overwrite for some special handlings
|
114
|
+
def ignore_password_expire?
|
115
|
+
false
|
116
|
+
end
|
108
117
|
end
|
109
118
|
end
|
110
119
|
end
|
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
# @note This happens after
|
4
|
+
# {DeviseSecurity::Controller::Helpers#handle_password_change}
|
3
5
|
Warden::Manager.after_authentication do |record, warden, options|
|
4
6
|
if record.respond_to?(:need_change_password?)
|
5
7
|
warden.session(options[:scope])['password_expired'] = record.need_change_password?
|
@@ -4,7 +4,9 @@
|
|
4
4
|
# user is explicitly set (with set_user) and on authentication. Retrieving the
|
5
5
|
# user from session (:fetch) does not trigger it.
|
6
6
|
Warden::Manager.after_set_user except: :fetch do |record, warden, options|
|
7
|
-
if record.
|
7
|
+
if record.devise_modules.include?(:session_limitable) &&
|
8
|
+
warden.authenticated?(options[:scope]) &&
|
9
|
+
!record.skip_session_limitable?
|
8
10
|
unique_session_id = Devise.friendly_token
|
9
11
|
warden.session(options[:scope])['unique_session_id'] = unique_session_id
|
10
12
|
record.update_unique_session_id!(unique_session_id)
|
@@ -19,13 +21,17 @@ Warden::Manager.after_set_user only: :fetch do |record, warden, options|
|
|
19
21
|
scope = options[:scope]
|
20
22
|
env = warden.request.env
|
21
23
|
|
22
|
-
if record.
|
23
|
-
|
24
|
-
|
25
|
-
|
24
|
+
if record.devise_modules.include?(:session_limitable) &&
|
25
|
+
warden.authenticated?(scope) &&
|
26
|
+
options[:store] != false
|
27
|
+
if record.unique_session_id != warden.session(scope)['unique_session_id'] &&
|
28
|
+
!env['devise.skip_session_limitable'] &&
|
29
|
+
!record.skip_session_limitable?
|
30
|
+
Rails.logger.warn do
|
31
|
+
'[devise-security][session_limitable] session id mismatch: '\
|
26
32
|
"expected=#{record.unique_session_id.inspect} "\
|
27
|
-
"actual=#{warden.session(scope)['unique_session_id'].inspect}"
|
28
|
-
|
33
|
+
"actual=#{warden.session(scope)['unique_session_id'].inspect}"
|
34
|
+
end
|
29
35
|
warden.raw_session.clear
|
30
36
|
warden.logout(scope)
|
31
37
|
throw :warden, scope: scope, message: :session_limited
|
@@ -92,7 +92,11 @@ module Devise::Models
|
|
92
92
|
# Update +password_changed_at+ for new records and changed passwords.
|
93
93
|
# @note called as a +before_save+ hook
|
94
94
|
def update_password_changed
|
95
|
-
|
95
|
+
if defined?(will_save_change_to_attribute?)
|
96
|
+
return unless (new_record? || will_save_change_to_encrypted_password?) && !will_save_change_to_password_changed_at?
|
97
|
+
else
|
98
|
+
return unless (new_record? || encrypted_password_changed?) && !password_changed_at_changed?
|
99
|
+
end
|
96
100
|
|
97
101
|
self.password_changed_at = Time.zone.now
|
98
102
|
end
|
@@ -21,11 +21,18 @@ module Devise
|
|
21
21
|
# @raise [Devise::Models::Compatibility::NotPersistedError] if record is unsaved
|
22
22
|
def update_unique_session_id!(unique_session_id)
|
23
23
|
raise Devise::Models::Compatibility::NotPersistedError, 'cannot update a new record' unless persisted?
|
24
|
+
|
24
25
|
update_attribute_without_validatons_or_callbacks(:unique_session_id, unique_session_id).tap do
|
25
|
-
Rails.logger.debug { "[devise-security][session_limitable] unique_session_id=#{unique_session_id}"}
|
26
|
+
Rails.logger.debug { "[devise-security][session_limitable] unique_session_id=#{unique_session_id}" }
|
26
27
|
end
|
27
28
|
end
|
28
29
|
|
30
|
+
# Should session_limitable be skipped for this instance?
|
31
|
+
# @return [Boolean]
|
32
|
+
# @return [false] by default. This can be overridden by application logic as necessary.
|
33
|
+
def skip_session_limitable?
|
34
|
+
false
|
35
|
+
end
|
29
36
|
end
|
30
37
|
end
|
31
38
|
end
|
@@ -3,17 +3,19 @@
|
|
3
3
|
# Password complexity validator
|
4
4
|
# Options:
|
5
5
|
# - digit: minimum number of digits in the validated string
|
6
|
+
# - digits: minimum number of digits in the validated string
|
6
7
|
# - lower: minimum number of lower-case letters in the validated string
|
7
8
|
# - symbol: minimum number of punctuation characters or symbols in the validated string
|
9
|
+
# - symbols: minimum number of punctuation characters or symbols in the validated string
|
8
10
|
# - upper: minimum number of upper-case letters in the validated string
|
9
11
|
class DeviseSecurity::PasswordComplexityValidator < ActiveModel::EachValidator
|
10
12
|
PATTERNS = {
|
11
13
|
digit: /\p{Digit}/,
|
12
14
|
digits: /\p{Digit}/,
|
13
15
|
lower: /\p{Lower}/,
|
14
|
-
upper: /\p{Upper}/,
|
15
16
|
symbol: /\p{Punct}|\p{S}/,
|
16
|
-
symbols: /\p{Punct}|\p{S}
|
17
|
+
symbols: /\p{Punct}|\p{S}/,
|
18
|
+
upper: /\p{Upper}/
|
17
19
|
}.freeze
|
18
20
|
|
19
21
|
def validate_each(record, attribute, value)
|
@@ -3,8 +3,8 @@
|
|
3
3
|
module DeviseSecurity
|
4
4
|
module Generators
|
5
5
|
# Generator for Rails to create or append to a Devise initializer.
|
6
|
-
class InstallGenerator < Rails::Generators::Base
|
7
|
-
LOCALES = %w[en es
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
7
|
+
LOCALES = %w[by cs de en es fa fr hi it ja nl pt ru tr uk zh_CN zh_TW].freeze
|
8
8
|
|
9
9
|
source_root File.expand_path('../../templates', __FILE__)
|
10
10
|
desc 'Install the devise security extension'
|
@@ -6,6 +6,7 @@ class Devise::PasswordExpiredControllerTest < ActionController::TestCase
|
|
6
6
|
include Devise::Test::ControllerHelpers
|
7
7
|
|
8
8
|
setup do
|
9
|
+
@controller.class.respond_to :json, :xml
|
9
10
|
@request.env["devise.mapping"] = Devise.mappings[:user]
|
10
11
|
@user = User.create!(
|
11
12
|
username: 'hello',
|
@@ -15,6 +16,8 @@ class Devise::PasswordExpiredControllerTest < ActionController::TestCase
|
|
15
16
|
confirmed_at: 5.months.ago
|
16
17
|
)
|
17
18
|
assert @user.valid?
|
19
|
+
assert @user.need_change_password?
|
20
|
+
|
18
21
|
sign_in(@user)
|
19
22
|
end
|
20
23
|
|
@@ -23,24 +26,116 @@ class Devise::PasswordExpiredControllerTest < ActionController::TestCase
|
|
23
26
|
assert_includes @response.body, 'Renew your password'
|
24
27
|
end
|
25
28
|
|
26
|
-
test '
|
27
|
-
if Rails.
|
28
|
-
put :update,
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
29
|
+
test 'update password with default format' do
|
30
|
+
if Rails.gem_version < Gem::Version.new('5.0')
|
31
|
+
put :update,
|
32
|
+
{
|
33
|
+
user: {
|
34
|
+
current_password: 'Password4',
|
35
|
+
password: 'Password5',
|
36
|
+
password_confirmation: 'Password5'
|
37
|
+
}
|
38
|
+
}
|
35
39
|
else
|
36
|
-
put :update,
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
40
|
+
put :update,
|
41
|
+
params: {
|
42
|
+
user: {
|
43
|
+
current_password: 'Password4',
|
44
|
+
password: 'Password5',
|
45
|
+
password_confirmation: 'Password5'
|
46
|
+
}
|
47
|
+
}
|
43
48
|
end
|
44
49
|
assert_redirected_to root_path
|
50
|
+
assert_equal response.content_type, 'text/html'
|
51
|
+
end
|
52
|
+
|
53
|
+
test 'password confirmation does not match' do
|
54
|
+
if Rails.gem_version < Gem::Version.new('5.0')
|
55
|
+
put :update,
|
56
|
+
{
|
57
|
+
user: {
|
58
|
+
current_password: 'Password4',
|
59
|
+
password: 'Password5',
|
60
|
+
password_confirmation: 'Password6'
|
61
|
+
}
|
62
|
+
}
|
63
|
+
else
|
64
|
+
put :update,
|
65
|
+
params: {
|
66
|
+
user: {
|
67
|
+
current_password: 'Password4',
|
68
|
+
password: 'Password5',
|
69
|
+
password_confirmation: 'Password6'
|
70
|
+
}
|
71
|
+
}
|
72
|
+
end
|
73
|
+
assert_response :success
|
74
|
+
assert_template :show
|
75
|
+
assert_equal response.content_type, 'text/html'
|
76
|
+
end
|
77
|
+
|
78
|
+
test 'update password using JSON format' do
|
79
|
+
if Rails.gem_version < Gem::Version.new('5.0')
|
80
|
+
# The responders gem that is compatible with Rails 4.2
|
81
|
+
# does not return a 204 No Content for common data formats
|
82
|
+
# This is the previously existing behavior so it is allowed
|
83
|
+
put :update,
|
84
|
+
{
|
85
|
+
user: {
|
86
|
+
current_password: 'Password4',
|
87
|
+
password: 'Password5',
|
88
|
+
password_confirmation: 'Password5'
|
89
|
+
}
|
90
|
+
},
|
91
|
+
format: :json
|
92
|
+
assert_redirected_to root_path
|
93
|
+
assert_equal response.content_type, 'text/html'
|
94
|
+
else
|
95
|
+
put :update,
|
96
|
+
format: :json,
|
97
|
+
params: {
|
98
|
+
user: {
|
99
|
+
current_password: 'Password4',
|
100
|
+
password: 'Password5',
|
101
|
+
password_confirmation: 'Password5'
|
102
|
+
}
|
103
|
+
}
|
104
|
+
assert_response 204
|
105
|
+
assert_equal root_url, response.location
|
106
|
+
assert_nil response.content_type, 'No Content-Type header should be set for No Content response'
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
test 'update password using XML format' do
|
111
|
+
if Rails.gem_version < Gem::Version.new('5.0')
|
112
|
+
# The responders gem that is compatible with Rails 4.2
|
113
|
+
# does not return a 204 No Content for common data formats
|
114
|
+
# This is the previously existing behavior so it is allowed
|
115
|
+
put :update,
|
116
|
+
{
|
117
|
+
user: {
|
118
|
+
current_password: 'Password4',
|
119
|
+
password: 'Password5',
|
120
|
+
password_confirmation: 'Password5'
|
121
|
+
},
|
122
|
+
},
|
123
|
+
format: :xml
|
124
|
+
assert_redirected_to root_path
|
125
|
+
assert_equal response.content_type, 'text/html'
|
126
|
+
else
|
127
|
+
put :update,
|
128
|
+
format: :xml,
|
129
|
+
params: {
|
130
|
+
user: {
|
131
|
+
current_password: 'Password4',
|
132
|
+
password: 'Password5',
|
133
|
+
password_confirmation: 'Password5'
|
134
|
+
}
|
135
|
+
}
|
136
|
+
assert_response 204
|
137
|
+
assert_equal root_url, response.location
|
138
|
+
assert_nil response.content_type, 'No Content-Type header should be set for No Content response'
|
139
|
+
end
|
45
140
|
end
|
46
141
|
end
|
data/test/dummy/config/routes.rb
CHANGED
@@ -3,11 +3,11 @@
|
|
3
3
|
RailsApp::Application.routes.draw do
|
4
4
|
devise_for :users
|
5
5
|
|
6
|
-
devise_for :captcha_users, only: [:sessions], controllers: { sessions:
|
7
|
-
devise_for :security_question_users, only: [:sessions, :unlocks], controllers: { unlocks:
|
6
|
+
devise_for :captcha_users, only: [:sessions], controllers: { sessions: 'captcha/sessions' }
|
7
|
+
devise_for :security_question_users, only: [:sessions, :unlocks], controllers: { unlocks: 'security_question/unlocks' }
|
8
8
|
|
9
9
|
resources :foos
|
10
10
|
resource :widgets
|
11
11
|
|
12
|
-
root to: '
|
12
|
+
root to: 'widgets#show'
|
13
13
|
end
|