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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +114 -58
  3. data/app/controllers/devise/password_expired_controller.rb +10 -1
  4. data/app/views/devise/paranoid_verification_code/show.html.erb +3 -3
  5. data/app/views/devise/password_expired/show.html.erb +5 -5
  6. data/config/locales/by.yml +48 -0
  7. data/config/locales/cs.yml +40 -0
  8. data/config/locales/de.yml +12 -2
  9. data/config/locales/en.yml +12 -1
  10. data/config/locales/es.yml +9 -9
  11. data/config/locales/fa.yml +40 -0
  12. data/config/locales/hi.yml +41 -0
  13. data/config/locales/it.yml +34 -4
  14. data/config/locales/ja.yml +1 -1
  15. data/config/locales/nl.yml +40 -0
  16. data/config/locales/pt.yml +40 -0
  17. data/config/locales/ru.yml +48 -0
  18. data/config/locales/uk.yml +48 -0
  19. data/config/locales/zh_CN.yml +40 -0
  20. data/config/locales/zh_TW.yml +40 -0
  21. data/lib/devise-security/controllers/helpers.rb +59 -50
  22. data/lib/devise-security/hooks/password_expirable.rb +2 -0
  23. data/lib/devise-security/hooks/session_limitable.rb +13 -7
  24. data/lib/devise-security/models/password_expirable.rb +5 -1
  25. data/lib/devise-security/models/session_limitable.rb +8 -1
  26. data/lib/devise-security/validators/password_complexity_validator.rb +4 -2
  27. data/lib/devise-security/version.rb +1 -1
  28. data/lib/generators/devise_security/install_generator.rb +2 -2
  29. data/test/controllers/test_password_expired_controller.rb +111 -16
  30. data/test/dummy/app/assets/config/manifest.js +3 -0
  31. data/test/dummy/config/routes.rb +3 -3
  32. data/test/dummy/log/test.log +1799 -0
  33. data/test/integration/test_password_expirable_workflow.rb +57 -0
  34. data/test/orm/active_record.rb +4 -1
  35. data/test/support/integration_helpers.rb +1 -1
  36. data/test/test_complexity_validator.rb +12 -0
  37. data/test/test_helper.rb +10 -3
  38. data/test/test_install_generator.rb +10 -0
  39. data/test/test_session_limitable.rb +17 -0
  40. data/test/tmp/config/initializers/devise-security.rb +44 -0
  41. data/test/tmp/config/locales/devise.security_extension.de.yml +38 -0
  42. data/test/tmp/config/locales/devise.security_extension.en.yml +40 -0
  43. data/test/tmp/config/locales/devise.security_extension.es.yml +29 -0
  44. data/test/tmp/config/locales/devise.security_extension.fa.yml +40 -0
  45. data/test/tmp/config/locales/devise.security_extension.fr.yml +29 -0
  46. data/test/tmp/config/locales/devise.security_extension.it.yml +40 -0
  47. data/test/tmp/config/locales/devise.security_extension.ja.yml +29 -0
  48. data/test/tmp/config/locales/devise.security_extension.nl.yml +40 -0
  49. data/test/tmp/config/locales/devise.security_extension.pt.yml +40 -0
  50. data/test/tmp/config/locales/devise.security_extension.ru.yml +48 -0
  51. data/test/tmp/config/locales/devise.security_extension.tr.yml +17 -0
  52. data/test/tmp/config/locales/devise.security_extension.uk.yml +48 -0
  53. data/test/tmp/config/locales/devise.security_extension.zh_CN.yml +40 -0
  54. metadata +152 -118
  55. data/.codeclimate.yml +0 -63
  56. data/.document +0 -5
  57. data/.gitignore +0 -43
  58. data/.mdlrc +0 -1
  59. data/.rubocop.yml +0 -64
  60. data/.ruby-version +0 -1
  61. data/.travis.yml +0 -39
  62. data/Appraisals +0 -35
  63. data/Gemfile +0 -10
  64. data/Rakefile +0 -27
  65. data/devise-security.gemspec +0 -50
  66. data/gemfiles/rails_4.2_stable.gemfile +0 -16
  67. data/gemfiles/rails_5.0_stable.gemfile +0 -15
  68. data/gemfiles/rails_5.1_stable.gemfile +0 -15
  69. data/gemfiles/rails_5.2_stable.gemfile +0 -15
  70. data/gemfiles/rails_6.0_beta.gemfile +0 -15
  71. 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
- private
44
-
45
- # lookup if an password change needed
46
- def handle_password_change
47
- return if warden.nil?
48
-
49
- if !devise_controller? && !ignore_password_expire? && !request.format.nil? && request.format.html?
50
- Devise.mappings.keys.flatten.any? do |scope|
51
- if signed_in?(scope) && warden.session(scope)['password_expired']
52
- # re-check to avoid infinite loop if date changed after login attempt
53
- if send(:"current_#{scope}").try(:need_change_password?)
54
- store_location_for(scope, request.original_fullpath) if request.get?
55
- redirect_for_password_change scope
56
- return
57
- else
58
- warden.session(scope)[:password_expired] = false
59
- end
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
- # lookup if extra (paranoid) code verification is needed
66
- def handle_paranoid_verification
67
- return if warden.nil?
74
+ # lookup if extra (paranoid) code verification is needed
75
+ def handle_paranoid_verification
76
+ return if warden.nil?
68
77
 
69
- if !devise_controller? && !request.format.nil? && request.format.html?
70
- Devise.mappings.keys.flatten.any? do |scope|
71
- if signed_in?(scope) && warden.session(scope)['paranoid_verify']
72
- store_location_for(scope, request.original_fullpath) if request.get?
73
- redirect_for_paranoid_verification scope
74
- return
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
- # redirect for password update with alert message
81
- def redirect_for_password_change(scope)
82
- redirect_to change_password_required_path_for(scope), alert: I18n.t('change_required', {scope: 'devise.password_expired'})
83
- end
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
- def redirect_for_paranoid_verification(scope)
86
- redirect_to paranoid_verification_code_path_for(scope), alert: I18n.t('code_required', {scope: 'devise.paranoid_verify'})
87
- end
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
- # path for change password
90
- def change_password_required_path_for(resource_or_scope = nil)
91
- scope = Devise::Mapping.find_scope!(resource_or_scope)
92
- change_path = "#{scope}_password_expired_path"
93
- send(change_path)
94
- end
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
- def paranoid_verification_code_path_for(resource_or_scope = nil)
97
- scope = Devise::Mapping.find_scope!(resource_or_scope)
98
- change_path = "#{scope}_paranoid_verification_code_path"
99
- send(change_path)
100
- end
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
- protected
111
+ protected
103
112
 
104
- # allow to overwrite for some special handlings
105
- def ignore_password_expire?
106
- false
107
- end
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.respond_to?(:update_unique_session_id!) && warden.authenticated?(options[:scope])
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.respond_to?(:unique_session_id) && warden.authenticated?(scope) && options[:store] != false
23
- if record.unique_session_id != warden.session(scope)['unique_session_id'] && !env['devise.skip_session_limitable']
24
- Rails.logger.warn {
25
- "[devise-security][session_limitable] session id mismatch: "\
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
- return unless (new_record? || encrypted_password_changed?) && !password_changed_at_changed?
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeviseSecurity
4
- VERSION = '0.14.3'
4
+ VERSION = '0.15.0'
5
5
  end
@@ -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 de fr it ja tr].freeze
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 'should update password' do
27
- if Rails.version < "5"
28
- put :update, {
29
- user: {
30
- current_password: 'Password4',
31
- password: 'Password5',
32
- password_confirmation: 'Password5'
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, params: {
37
- user: {
38
- current_password: 'Password4',
39
- password: 'Password5',
40
- password_confirmation: 'Password5'
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
@@ -0,0 +1,3 @@
1
+ // = link_tree ../images
2
+ // = link_directory ../javascripts .js
3
+ // = link_directory ../stylesheets .css
@@ -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: "captcha/sessions" }
7
- devise_for :security_question_users, only: [:sessions, :unlocks], controllers: { unlocks: "security_question/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: 'foos#index'
12
+ root to: 'widgets#show'
13
13
  end