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