devise-security 0.14.2 → 0.17.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.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +125 -59
  3. data/app/controllers/devise/paranoid_verification_code_controller.rb +13 -1
  4. data/app/controllers/devise/password_expired_controller.rb +24 -6
  5. data/app/views/devise/paranoid_verification_code/show.html.erb +3 -3
  6. data/app/views/devise/password_expired/show.html.erb +5 -5
  7. data/config/locales/bg.yml +41 -0
  8. data/config/locales/by.yml +49 -0
  9. data/config/locales/cs.yml +41 -0
  10. data/config/locales/de.yml +15 -2
  11. data/config/locales/en.yml +15 -2
  12. data/config/locales/es.yml +10 -9
  13. data/config/locales/fa.yml +41 -0
  14. data/config/locales/fr.yml +1 -0
  15. data/config/locales/hi.yml +42 -0
  16. data/config/locales/it.yml +35 -4
  17. data/config/locales/ja.yml +2 -1
  18. data/config/locales/nl.yml +41 -0
  19. data/config/locales/pt.yml +41 -0
  20. data/config/locales/ru.yml +49 -0
  21. data/config/locales/tr.yml +1 -0
  22. data/config/locales/uk.yml +49 -0
  23. data/config/locales/zh_CN.yml +41 -0
  24. data/config/locales/zh_TW.yml +41 -0
  25. data/lib/devise-security/controllers/helpers.rb +59 -50
  26. data/lib/devise-security/hooks/password_expirable.rb +2 -0
  27. data/lib/devise-security/hooks/session_limitable.rb +21 -11
  28. data/lib/devise-security/models/database_authenticatable_patch.rb +15 -5
  29. data/lib/devise-security/models/password_archivable.rb +2 -2
  30. data/lib/devise-security/models/password_expirable.rb +5 -1
  31. data/lib/devise-security/models/secure_validatable.rb +56 -6
  32. data/lib/devise-security/models/session_limitable.rb +10 -1
  33. data/lib/devise-security/validators/password_complexity_validator.rb +53 -24
  34. data/lib/devise-security/version.rb +1 -1
  35. data/lib/devise-security.rb +13 -5
  36. data/lib/generators/devise_security/install_generator.rb +3 -3
  37. data/lib/generators/templates/{devise-security.rb → devise_security.rb} +6 -1
  38. data/test/controllers/test_paranoid_verification_code_controller.rb +68 -0
  39. data/test/controllers/test_password_expired_controller.rb +121 -19
  40. data/test/controllers/test_security_question_controller.rb +16 -40
  41. data/test/dummy/app/assets/config/manifest.js +3 -0
  42. data/test/dummy/app/controllers/overrides/paranoid_verification_code_controller.rb +7 -0
  43. data/test/dummy/app/controllers/overrides/password_expired_controller.rb +7 -0
  44. data/test/dummy/app/controllers/widgets_controller.rb +3 -0
  45. data/test/dummy/app/models/application_user_record.rb +2 -1
  46. data/test/dummy/app/models/mongoid/confirmable_fields.rb +2 -0
  47. data/test/dummy/app/models/mongoid/database_authenticable_fields.rb +4 -3
  48. data/test/dummy/app/models/mongoid/expirable_fields.rb +2 -0
  49. data/test/dummy/app/models/mongoid/lockable_fields.rb +2 -0
  50. data/test/dummy/app/models/mongoid/mappings.rb +4 -2
  51. data/test/dummy/app/models/mongoid/omniauthable_fields.rb +2 -0
  52. data/test/dummy/app/models/mongoid/paranoid_verification_fields.rb +2 -0
  53. data/test/dummy/app/models/mongoid/password_archivable_fields.rb +2 -0
  54. data/test/dummy/app/models/mongoid/password_expirable_fields.rb +2 -0
  55. data/test/dummy/app/models/mongoid/recoverable_fields.rb +2 -0
  56. data/test/dummy/app/models/mongoid/registerable_fields.rb +4 -2
  57. data/test/dummy/app/models/mongoid/rememberable_fields.rb +2 -0
  58. data/test/dummy/app/models/mongoid/secure_validatable_fields.rb +2 -0
  59. data/test/dummy/app/models/mongoid/security_questionable_fields.rb +2 -0
  60. data/test/dummy/app/models/mongoid/session_limitable_fields.rb +2 -0
  61. data/test/dummy/app/models/mongoid/timeoutable_fields.rb +2 -0
  62. data/test/dummy/app/models/mongoid/trackable_fields.rb +2 -0
  63. data/test/dummy/app/models/mongoid/validatable_fields.rb +2 -0
  64. data/test/dummy/app/models/paranoid_verification_user.rb +26 -0
  65. data/test/dummy/app/models/password_expired_user.rb +26 -0
  66. data/test/dummy/app/models/user.rb +1 -2
  67. data/test/dummy/app/models/widget.rb +1 -3
  68. data/test/dummy/app/mongoid/one_user.rb +5 -5
  69. data/test/dummy/app/mongoid/user_on_engine.rb +2 -2
  70. data/test/dummy/app/mongoid/user_on_main_app.rb +2 -2
  71. data/test/dummy/app/mongoid/user_with_validations.rb +3 -3
  72. data/test/dummy/app/mongoid/user_without_email.rb +3 -3
  73. data/test/dummy/config/application.rb +4 -4
  74. data/test/dummy/config/boot.rb +1 -1
  75. data/test/dummy/config/environment.rb +1 -1
  76. data/test/dummy/config/environments/test.rb +3 -13
  77. data/test/dummy/config/initializers/migration_class.rb +1 -8
  78. data/test/dummy/config/locales/en.yml +10 -0
  79. data/test/dummy/config/mongoid.yml +1 -1
  80. data/test/dummy/config/routes.rb +5 -3
  81. data/test/dummy/db/migrate/20120508165529_create_tables.rb +3 -3
  82. data/test/dummy/lib/shared_expirable_columns.rb +1 -0
  83. data/test/dummy/lib/shared_security_questions_fields.rb +1 -0
  84. data/test/dummy/lib/shared_user.rb +17 -6
  85. data/test/dummy/lib/shared_user_without_email.rb +2 -1
  86. data/test/dummy/lib/shared_user_without_omniauth.rb +12 -3
  87. data/test/dummy/lib/shared_verification_fields.rb +1 -0
  88. data/test/dummy/{app/models/.gitkeep → log/development.log} +0 -0
  89. data/test/dummy/log/test.log +101533 -0
  90. data/test/integration/test_password_expirable_workflow.rb +53 -0
  91. data/test/integration/test_session_limitable_workflow.rb +2 -0
  92. data/test/orm/active_record.rb +7 -4
  93. data/test/orm/mongoid.rb +2 -1
  94. data/test/support/integration_helpers.rb +15 -33
  95. data/test/support/mongoid.yml +1 -1
  96. data/test/test_compatibility.rb +2 -0
  97. data/test/test_complexity_validator.rb +250 -29
  98. data/test/test_database_authenticatable_patch.rb +146 -0
  99. data/test/test_helper.rb +12 -6
  100. data/test/test_install_generator.rb +12 -2
  101. data/test/test_paranoid_verification.rb +0 -1
  102. data/test/test_password_archivable.rb +34 -11
  103. data/test/test_password_expirable.rb +26 -26
  104. data/test/test_secure_validatable.rb +292 -50
  105. data/test/test_secure_validatable_overrides.rb +185 -0
  106. data/test/test_session_limitable.rb +27 -1
  107. data/test/tmp/config/initializers/devise_security.rb +49 -0
  108. data/test/tmp/config/locales/devise.security_extension.by.yml +49 -0
  109. data/test/tmp/config/locales/devise.security_extension.cs.yml +41 -0
  110. data/test/tmp/config/locales/devise.security_extension.de.yml +41 -0
  111. data/test/tmp/config/locales/devise.security_extension.en.yml +42 -0
  112. data/test/tmp/config/locales/devise.security_extension.es.yml +30 -0
  113. data/test/tmp/config/locales/devise.security_extension.fa.yml +41 -0
  114. data/test/tmp/config/locales/devise.security_extension.fr.yml +30 -0
  115. data/test/tmp/config/locales/devise.security_extension.hi.yml +42 -0
  116. data/test/tmp/config/locales/devise.security_extension.it.yml +41 -0
  117. data/test/tmp/config/locales/devise.security_extension.ja.yml +30 -0
  118. data/test/tmp/config/locales/devise.security_extension.nl.yml +41 -0
  119. data/test/tmp/config/locales/devise.security_extension.pt.yml +41 -0
  120. data/test/tmp/config/locales/devise.security_extension.ru.yml +49 -0
  121. data/test/tmp/config/locales/devise.security_extension.tr.yml +18 -0
  122. data/test/tmp/config/locales/devise.security_extension.uk.yml +49 -0
  123. data/test/tmp/config/locales/devise.security_extension.zh_CN.yml +41 -0
  124. data/test/tmp/config/locales/devise.security_extension.zh_TW.yml +41 -0
  125. metadata +168 -132
  126. data/.codeclimate.yml +0 -63
  127. data/.document +0 -5
  128. data/.gitignore +0 -43
  129. data/.mdlrc +0 -1
  130. data/.rubocop.yml +0 -64
  131. data/.ruby-version +0 -1
  132. data/.travis.yml +0 -39
  133. data/Appraisals +0 -35
  134. data/Gemfile +0 -10
  135. data/Rakefile +0 -27
  136. data/devise-security.gemspec +0 -50
  137. data/gemfiles/rails_4.2_stable.gemfile +0 -16
  138. data/gemfiles/rails_5.0_stable.gemfile +0 -15
  139. data/gemfiles/rails_5.1_stable.gemfile +0 -15
  140. data/gemfiles/rails_5.2_stable.gemfile +0 -15
  141. data/gemfiles/rails_6.0_beta.gemfile +0 -15
  142. data/lib/devise-security/orm/active_record.rb +0 -20
  143. data/lib/devise-security/schema.rb +0 -66
  144. data/test/dummy/app/models/secure_user.rb +0 -9
@@ -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,10 +4,17 @@
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])
8
- unique_session_id = Devise.friendly_token
9
- warden.session(options[:scope])['unique_session_id'] = unique_session_id
10
- record.update_unique_session_id!(unique_session_id)
7
+ if record.devise_modules.include?(:session_limitable) &&
8
+ warden.authenticated?(options[:scope]) &&
9
+ !record.skip_session_limitable?
10
+
11
+ if !options[:skip_session_limitable]
12
+ unique_session_id = Devise.friendly_token
13
+ warden.session(options[:scope])['unique_session_id'] = unique_session_id
14
+ record.update_unique_session_id!(unique_session_id)
15
+ else
16
+ warden.session(options[:scope])['devise.skip_session_limitable'] = true
17
+ end
11
18
  end
12
19
  end
13
20
 
@@ -17,15 +24,18 @@ end
17
24
  # page on the next request.
18
25
  Warden::Manager.after_set_user only: :fetch do |record, warden, options|
19
26
  scope = options[:scope]
20
- env = warden.request.env
21
27
 
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: "\
28
+ if record.devise_modules.include?(:session_limitable) &&
29
+ warden.authenticated?(scope) &&
30
+ options[:store] != false
31
+ if record.unique_session_id != warden.session(scope)['unique_session_id'] &&
32
+ !record.skip_session_limitable? &&
33
+ !warden.session(scope)['devise.skip_session_limitable']
34
+ Rails.logger.warn do
35
+ '[devise-security][session_limitable] session id mismatch: '\
26
36
  "expected=#{record.unique_session_id.inspect} "\
27
- "actual=#{warden.session(scope)['unique_session_id'].inspect}"
28
- }
37
+ "actual=#{warden.session(scope)['unique_session_id'].inspect}"
38
+ end
29
39
  warden.raw_session.clear
30
40
  warden.logout(scope)
31
41
  throw :warden, scope: scope, message: :session_limited
@@ -5,18 +5,28 @@ module Devise
5
5
  module DatabaseAuthenticatablePatch
6
6
  def update_with_password(params, *options)
7
7
  current_password = params.delete(:current_password)
8
+ valid_password = valid_password?(current_password)
8
9
 
9
10
  new_password = params[:password]
10
11
  new_password_confirmation = params[:password_confirmation]
11
12
 
12
- result = if valid_password?(current_password) && new_password.present? && new_password_confirmation.present?
13
+ result = if valid_password && new_password.present? && new_password_confirmation.present?
13
14
  update(params, *options)
14
15
  else
15
16
  self.assign_attributes(params, *options)
16
- self.valid?
17
- self.errors.add(:current_password, current_password.blank? ? :blank : :invalid)
18
- self.errors.add(:password, new_password.blank? ? :blank : :invalid)
19
- self.errors.add(:password_confirmation, new_password_confirmation.blank? ? :blank : :invalid)
17
+
18
+ if current_password.blank?
19
+ self.errors.add(:current_password, :blank)
20
+ elsif !valid_password
21
+ self.errors.add(:current_password, :invalid)
22
+ end
23
+
24
+ self.errors.add(:password, :blank) if new_password.blank?
25
+
26
+ if new_password_confirmation.blank?
27
+ self.errors.add(:password_confirmation, :blank)
28
+ end
29
+
20
30
  false
21
31
  end
22
32
 
@@ -41,7 +41,7 @@ module Devise
41
41
  def password_archive_included?
42
42
  return false unless max_old_passwords.positive?
43
43
 
44
- old_passwords_including_cur_change = old_passwords.order(created_at: :desc).limit(max_old_passwords).pluck(:encrypted_password)
44
+ old_passwords_including_cur_change = old_passwords.reorder(created_at: :desc).limit(max_old_passwords).pluck(:encrypted_password)
45
45
  old_passwords_including_cur_change << encrypted_password_was # include most recent change in list, but don't save it yet!
46
46
  old_passwords_including_cur_change.any? do |old_password|
47
47
  # NOTE: we deliberately do not do mass assignment here so that users that
@@ -73,7 +73,7 @@ module Devise
73
73
  return true if old_passwords.where(encrypted_password: encrypted_password_was).exists?
74
74
 
75
75
  old_passwords.create!(encrypted_password: encrypted_password_was) if encrypted_password_was.present?
76
- old_passwords.order(created_at: :desc).offset(max_old_passwords).destroy_all
76
+ old_passwords.reorder(created_at: :desc).offset(max_old_passwords).destroy_all
77
77
  else
78
78
  old_passwords.destroy_all
79
79
  end
@@ -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
@@ -44,17 +44,39 @@ module Devise
44
44
  validates :email, uniqueness: true, allow_blank: true, if: :email_changed? # check uniq for email ever
45
45
  end
46
46
 
47
- validates :password, presence: true, length: password_length, confirmation: true, if: :password_required?
47
+ validates_presence_of :password, if: :password_required?
48
+ validates_confirmation_of :password, if: :password_required?
49
+
50
+ validate if: :password_required? do |record|
51
+ validates_with ActiveModel::Validations::LengthValidator,
52
+ attributes: :password,
53
+ allow_blank: true,
54
+ in: record.password_length
55
+ end
48
56
  end
49
57
 
50
58
  # extra validations
51
- validates :email, email: email_validation if email_validation # see https://github.com/devise-security/devise-security/blob/master/README.md#e-mail-validation
52
- validates :password,
53
- 'devise_security/password_complexity': password_complexity,
54
- if: :password_required?
59
+ # see https://github.com/devise-security/devise-security/blob/master/README.md#e-mail-validation
60
+ validate do |record|
61
+ if email_validation
62
+ validates_with(
63
+ EmailValidator, { attributes: :email }
64
+ )
65
+ end
66
+ end
67
+
68
+ validate if: :password_required? do |record|
69
+ validates_with(
70
+ record.password_complexity_validator.is_a?(Class) ? record.password_complexity_validator : record.password_complexity_validator.classify.constantize,
71
+ { attributes: :password }.merge(record.password_complexity)
72
+ )
73
+ end
55
74
 
56
75
  # don't allow use same password
57
76
  validate :current_equal_password_validation
77
+
78
+ # don't allow email to equal password
79
+ validate :email_not_equal_password_validation
58
80
  end
59
81
  end
60
82
 
@@ -70,11 +92,23 @@ module Devise
70
92
  self.errors.add(:password, :equal_to_current_password) if dummy.valid_password?(password)
71
93
  end
72
94
 
95
+ def email_not_equal_password_validation
96
+ return if allow_passwords_equal_to_email
97
+
98
+ return if password.blank? || email.blank? || (!new_record? && !will_save_change_to_encrypted_password?)
99
+
100
+ return unless Devise.secure_compare(password.downcase.strip, email.downcase.strip)
101
+
102
+ errors.add(:password, :equal_to_email)
103
+ end
104
+
73
105
  protected
74
106
 
75
107
  # Checks whether a password is needed or not. For validations only.
76
108
  # Passwords are always required if it's a new record, or if the password
77
109
  # or confirmation are being set somewhere.
110
+ #
111
+ # @return [Boolean]
78
112
  def password_required?
79
113
  !persisted? || !password.nil? || !password_confirmation.nil?
80
114
  end
@@ -83,8 +117,24 @@ module Devise
83
117
  true
84
118
  end
85
119
 
120
+ delegate(
121
+ :allow_passwords_equal_to_email,
122
+ :email_validation,
123
+ :password_complexity,
124
+ :password_complexity_validator,
125
+ :password_length,
126
+ to: :class
127
+ )
128
+
86
129
  module ClassMethods
87
- Devise::Models.config(self, :password_complexity, :password_length, :email_validation)
130
+ Devise::Models.config(
131
+ self,
132
+ :allow_passwords_equal_to_email,
133
+ :email_validation,
134
+ :password_complexity,
135
+ :password_complexity_validator,
136
+ :password_length
137
+ )
88
138
 
89
139
  private
90
140
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'compatibility'
3
4
  require 'devise-security/hooks/session_limitable'
4
5
 
5
6
  module Devise
@@ -11,6 +12,7 @@ module Devise
11
12
  # someone used his credentials to sign in.
12
13
  module SessionLimitable
13
14
  extend ActiveSupport::Concern
15
+ include Devise::Models::Compatibility
14
16
 
15
17
  # Update the unique_session_id on the model. This will be checked in
16
18
  # the Warden after_set_user hook in {file:devise-security/hooks/session_limitable}
@@ -19,11 +21,18 @@ module Devise
19
21
  # @raise [Devise::Models::Compatibility::NotPersistedError] if record is unsaved
20
22
  def update_unique_session_id!(unique_session_id)
21
23
  raise Devise::Models::Compatibility::NotPersistedError, 'cannot update a new record' unless persisted?
24
+
22
25
  update_attribute_without_validatons_or_callbacks(:unique_session_id, unique_session_id).tap do
23
- 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}" }
24
27
  end
25
28
  end
26
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
27
36
  end
28
37
  end
29
38
  end
@@ -1,33 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Password complexity validator
3
+ # (NIST)[https://pages.nist.gov/800-63-3/sp800-63b.html#appA] does not recommend
4
+ # the use of a password complexity checks because...
5
+ #
6
+ # > Length and complexity requirements beyond those recommended here
7
+ # > significantly increase the difficulty of memorized secrets and increase user
8
+ # > frustration. As a result, users often work around these restrictions in a
9
+ # > way that is counterproductive. Furthermore, other mitigations such as
10
+ # > blacklists, secure hashed storage, and rate limiting are more effective at
11
+ # > preventing modern brute-force attacks. Therefore, no additional complexity
12
+ # > requirements are imposed.
13
+ #
4
14
  # Options:
5
- # - digit: minimum number of digits in the validated string
6
- # - lower: minimum number of lower-case letters in the validated string
7
- # - symbol: minimum number of punctuation characters or symbols in the validated string
8
- # - upper: minimum number of upper-case letters in the validated string
15
+ # - `digit | digits`: minimum number of digits in the validated string. Uses
16
+ # the `digit` localization key.
17
+ # - `lower`: minimum number of lower-case letters in the validated string
18
+ # - `symbol | symbols`: minimum number of punctuation characters or symbols in
19
+ # the validated string. Uses the `symbol` localization key.
20
+ # - `upper`: minimum number of upper-case letters in the validated string
9
21
  class DeviseSecurity::PasswordComplexityValidator < ActiveModel::EachValidator
10
- PATTERNS = {
11
- digit: /\p{Digit}/,
12
- digits: /\p{Digit}/,
13
- lower: /\p{Lower}/,
14
- upper: /\p{Upper}/,
15
- symbol: /\p{Punct}|\p{S}/,
16
- symbols: /\p{Punct}|\p{S}/
17
- }.freeze
22
+ # A Hash of the possible valid patterns that can be checked against. The keys
23
+ # for this Hash are singular symbols corresponding to entries in the
24
+ # localization files. Override or redefine this method if you want to include
25
+ # custom patterns (e.g., `letter: /\p{Alpha}/` for all letters).
26
+ #
27
+ # @return [Hash<Symbol,Regexp>]
28
+ def patterns
29
+ {
30
+ digit: /\p{Digit}/,
31
+ lower: /\p{Lower}/,
32
+ symbol: /\p{Punct}|\p{S}/,
33
+ upper: /\p{Upper}/
34
+ }
35
+ end
18
36
 
19
- def validate_each(record, attribute, value)
20
- active_pattern_keys.each do |key|
21
- minimum = [0, options[key].to_i].max
22
- pattern = Regexp.new PATTERNS[key]
37
+ # Validate the complexity of the password. This validation does not check to
38
+ # ensure the password is not blank. That is the responsibility of other
39
+ # validations. This validator will also ignore any patterns that are not
40
+ # explicitly configured to be used or whose minimum limits are less than 1.
41
+ #
42
+ # @param record [ActiveModel::Model]
43
+ # @param attribute [Symbol]
44
+ # @param password [String]
45
+ def validate_each(record, attribute, password)
46
+ return if password.blank?
23
47
 
24
- unless (value || '').scan(pattern).size >= minimum
25
- record.errors.add attribute, :"password_complexity.#{key}", count: minimum
26
- end
27
- end
28
- end
48
+ options.sort.each do |pattern_name, minimum|
49
+ normalized_option = pattern_name.to_s.singularize.to_sym
29
50
 
30
- def active_pattern_keys
31
- options.keys & PATTERNS.keys
51
+ next unless patterns.key?(normalized_option)
52
+ next unless minimum.positive?
53
+ next if password.scan(patterns[normalized_option]).size >= minimum
54
+
55
+ record.errors.add(
56
+ attribute,
57
+ :"password_complexity.#{normalized_option}",
58
+ count: minimum
59
+ )
60
+ end
32
61
  end
33
62
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeviseSecurity
4
- VERSION = '0.14.2'
4
+ VERSION = '0.17.0'
5
5
  end
@@ -9,15 +9,20 @@ require 'devise'
9
9
 
10
10
  module Devise
11
11
  # Number of seconds that passwords are valid (e.g 3.months)
12
- # Disable pasword expiration with +false+
12
+ # Disable password expiration with +false+
13
13
  # Expire only on demand with +true+
14
14
  mattr_accessor :expire_password_after
15
15
  @@expire_password_after = 3.months
16
16
 
17
- # Validate password for strongness
17
+ # Validate password complexity
18
18
  mattr_accessor :password_complexity
19
19
  @@password_complexity = { digit: 1, lower: 1, symbol: 1, upper: 1 }
20
20
 
21
+ # Define the class used to validate password complexity. Set to a Class or a
22
+ # string which will be used to determine which class to use.
23
+ mattr_accessor :password_complexity_validator
24
+ @@password_complexity_validator = 'devise_security/password_complexity_validator'
25
+
21
26
  # Number of old passwords in archive
22
27
  mattr_accessor :password_archiving_count
23
28
  @@password_archiving_count = 5
@@ -79,11 +84,14 @@ module Devise
79
84
  # paranoid_verification will regenerate verifacation code after faild attempt
80
85
  mattr_accessor :paranoid_code_regenerate_after_attempt
81
86
  @@paranoid_code_regenerate_after_attempt = 10
87
+
88
+ # Whether to allow passwords that are equal (case insensitive) to the email
89
+ mattr_accessor :allow_passwords_equal_to_email
90
+ @@allow_passwords_equal_to_email = false
82
91
  end
83
92
 
84
- # an security extension for devise
93
+ # a security extension for devise
85
94
  module DeviseSecurity
86
- autoload :Schema, 'devise-security/schema'
87
95
  autoload :Patches, 'devise-security/patches'
88
96
 
89
97
  module Controllers
@@ -104,6 +112,6 @@ Devise.add_module :paranoid_verification, controller: :paranoid_verification_cod
104
112
  # requires
105
113
  require 'devise-security/routes'
106
114
  require 'devise-security/rails'
107
- require "devise-security/orm/#{DEVISE_ORM}"
115
+ require "devise-security/orm/#{DEVISE_ORM}" if DEVISE_ORM == :mongoid
108
116
  require 'devise-security/models/database_authenticatable_patch'
109
117
  require 'devise-security/models/paranoid_verification'
@@ -4,14 +4,14 @@ module DeviseSecurity
4
4
  module Generators
5
5
  # Generator for Rails to create or append to a Devise initializer.
6
6
  class InstallGenerator < Rails::Generators::Base
7
- LOCALES = %w[en es de fr it ja tr].freeze
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'
11
11
 
12
12
  def copy_initializer
13
- template('devise-security.rb',
14
- 'config/initializers/devise-security.rb',
13
+ template('devise_security.rb',
14
+ 'config/initializers/devise_security.rb',
15
15
  )
16
16
  end
17
17
 
@@ -7,7 +7,9 @@ Devise.setup do |config|
7
7
  # Should the password expire (e.g 3.months)
8
8
  # config.expire_password_after = false
9
9
 
10
- # Need 1 char of A-Z, a-z and 0-9
10
+ # Need 1 char each of: A-Z, a-z, 0-9, and a punctuation mark or symbol
11
+ # You may use "digits" in place of "digit" and "symbols" in place of
12
+ # "symbol" based on your preference
11
13
  # config.password_complexity = { digit: 1, lower: 1, symbol: 1, upper: 1 }
12
14
 
13
15
  # How many passwords to keep in archive
@@ -41,4 +43,7 @@ Devise.setup do |config|
41
43
 
42
44
  # Time period for account expiry from last_activity_at
43
45
  # config.expire_after = 90.days
46
+
47
+ # Allow password to equal the email
48
+ # config.allow_passwords_equal_to_email = false
44
49
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class Devise::ParanoidVerificationCodeControllerTest < ActionController::TestCase
6
+ include Devise::Test::ControllerHelpers
7
+
8
+ setup do
9
+ @request.env['devise.mapping'] = Devise.mappings[:user]
10
+
11
+ @user = User.create!(
12
+ username: 'hello',
13
+ email: 'hello@path.travel',
14
+ password: 'Password4',
15
+ confirmed_at: 5.months.ago,
16
+ )
17
+
18
+ sign_in(@user)
19
+ end
20
+
21
+ test 'redirects to root on show if user not logged in' do
22
+ sign_out(@user)
23
+ get :show
24
+ assert_redirected_to :root
25
+ end
26
+
27
+ test "redirects to root on show if user doesn't need paranoid verification" do
28
+ get :show
29
+ assert_redirected_to :root
30
+ end
31
+
32
+ test 'renders show on show if user needs paranoid verification' do
33
+ @user.update(paranoid_verification_code: 'cookies')
34
+ get :show
35
+ assert_template :show
36
+ end
37
+
38
+ test "redirects to root on update" do
39
+ patch :update, params: { user: { paranoid_verification_code: 'cookies' } }
40
+ assert_redirected_to :root
41
+ assert_equal 'Verification code accepted', flash[:notice]
42
+ end
43
+ end
44
+
45
+ class ParanoidVerificationCodeCustomRedirectTest < ActionController::TestCase
46
+ include Devise::Test::ControllerHelpers
47
+ tests Overrides::ParanoidVerificationCodeController
48
+
49
+ setup do
50
+ @request.env['devise.mapping'] = Devise.mappings[:paranoid_verification_user]
51
+
52
+ @user = ParanoidVerificationUser.create!(
53
+ username: 'hello',
54
+ email: 'hello@path.travel',
55
+ password: 'Password4',
56
+ confirmed_at: 5.months.ago,
57
+ )
58
+
59
+ sign_in(@user)
60
+ end
61
+
62
+ test 'redirects to custom redirect route on update' do
63
+ patch :update, params: { paranoid_verification_user: { paranoid_verification_code: 'cookies' } }
64
+
65
+ assert_redirected_to '/cats'
66
+ assert_equal 'Verification code accepted', flash[:notice]
67
+ end
68
+ end