devise-security 0.14.1 → 0.18.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 (170) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +3 -1
  3. data/README.md +136 -61
  4. data/app/controllers/devise/paranoid_verification_code_controller.rb +26 -12
  5. data/app/controllers/devise/password_expired_controller.rb +32 -10
  6. data/app/views/devise/paranoid_verification_code/show.html.erb +3 -3
  7. data/app/views/devise/password_expired/show.html.erb +5 -5
  8. data/config/locales/bg.yml +42 -0
  9. data/config/locales/by.yml +50 -0
  10. data/config/locales/cs.yml +46 -0
  11. data/config/locales/de.yml +16 -2
  12. data/config/locales/en.yml +15 -2
  13. data/config/locales/es.yml +22 -9
  14. data/config/locales/fa.yml +42 -0
  15. data/config/locales/fr.yml +15 -2
  16. data/config/locales/hi.yml +43 -0
  17. data/config/locales/it.yml +36 -4
  18. data/config/locales/ja.yml +14 -1
  19. data/config/locales/nl.yml +42 -0
  20. data/config/locales/pt.yml +42 -0
  21. data/config/locales/ru.yml +50 -0
  22. data/config/locales/tr.yml +26 -1
  23. data/config/locales/uk.yml +50 -0
  24. data/config/locales/zh_CN.yml +42 -0
  25. data/config/locales/zh_TW.yml +42 -0
  26. data/lib/devise-security/controllers/helpers.rb +72 -51
  27. data/lib/devise-security/hooks/expirable.rb +3 -3
  28. data/lib/devise-security/hooks/paranoid_verification.rb +1 -3
  29. data/lib/devise-security/hooks/password_expirable.rb +3 -3
  30. data/lib/devise-security/hooks/session_limitable.rb +29 -14
  31. data/lib/devise-security/models/compatibility/{active_record.rb → active_record_patch.rb} +14 -2
  32. data/lib/devise-security/models/compatibility/{mongoid.rb → mongoid_patch.rb} +12 -1
  33. data/lib/devise-security/models/compatibility.rb +2 -2
  34. data/lib/devise-security/models/database_authenticatable_patch.rb +18 -10
  35. data/lib/devise-security/models/expirable.rb +6 -5
  36. data/lib/devise-security/models/paranoid_verification.rb +2 -2
  37. data/lib/devise-security/models/password_archivable.rb +3 -3
  38. data/lib/devise-security/models/password_expirable.rb +5 -1
  39. data/lib/devise-security/models/secure_validatable.rb +62 -11
  40. data/lib/devise-security/models/session_limitable.rb +17 -2
  41. data/lib/devise-security/orm/mongoid.rb +1 -1
  42. data/lib/devise-security/patches.rb +14 -8
  43. data/lib/devise-security/routes.rb +2 -3
  44. data/lib/devise-security/validators/password_complexity_validator.rb +53 -24
  45. data/lib/devise-security/version.rb +1 -1
  46. data/lib/devise-security.rb +15 -6
  47. data/lib/generators/devise_security/install_generator.rb +4 -6
  48. data/lib/generators/templates/{devise-security.rb → devise_security.rb} +9 -1
  49. data/test/controllers/test_paranoid_verification_code_controller.rb +133 -0
  50. data/test/controllers/test_password_expired_controller.rb +164 -0
  51. data/test/{test_security_question_controller.rb → controllers/test_security_question_controller.rb} +19 -37
  52. data/test/dummy/app/assets/config/manifest.js +3 -0
  53. data/test/dummy/app/controllers/overrides/paranoid_verification_code_controller.rb +7 -0
  54. data/test/dummy/app/controllers/overrides/password_expired_controller.rb +17 -0
  55. data/test/dummy/app/controllers/widgets_controller.rb +9 -0
  56. data/test/dummy/app/models/application_user_record.rb +2 -1
  57. data/test/dummy/app/models/mongoid/confirmable_fields.rb +2 -0
  58. data/test/dummy/app/models/mongoid/database_authenticable_fields.rb +4 -3
  59. data/test/dummy/app/models/mongoid/expirable_fields.rb +2 -0
  60. data/test/dummy/app/models/mongoid/lockable_fields.rb +2 -0
  61. data/test/dummy/app/models/mongoid/mappings.rb +4 -2
  62. data/test/dummy/app/models/mongoid/omniauthable_fields.rb +2 -0
  63. data/test/dummy/app/models/mongoid/paranoid_verification_fields.rb +2 -0
  64. data/test/dummy/app/models/mongoid/password_archivable_fields.rb +2 -0
  65. data/test/dummy/app/models/mongoid/password_expirable_fields.rb +2 -0
  66. data/test/dummy/app/models/mongoid/recoverable_fields.rb +2 -0
  67. data/test/dummy/app/models/mongoid/registerable_fields.rb +4 -2
  68. data/test/dummy/app/models/mongoid/rememberable_fields.rb +2 -0
  69. data/test/dummy/app/models/mongoid/secure_validatable_fields.rb +2 -0
  70. data/test/dummy/app/models/mongoid/security_questionable_fields.rb +2 -0
  71. data/test/dummy/app/models/mongoid/session_limitable_fields.rb +2 -0
  72. data/test/dummy/app/models/mongoid/timeoutable_fields.rb +2 -0
  73. data/test/dummy/app/models/mongoid/trackable_fields.rb +2 -0
  74. data/test/dummy/app/models/mongoid/validatable_fields.rb +2 -0
  75. data/test/dummy/app/models/paranoid_verification_user.rb +26 -0
  76. data/test/dummy/app/models/password_expired_user.rb +26 -0
  77. data/test/dummy/app/models/user.rb +10 -2
  78. data/test/dummy/app/models/widget.rb +1 -3
  79. data/test/dummy/app/mongoid/one_user.rb +5 -5
  80. data/test/dummy/app/mongoid/user_on_engine.rb +2 -2
  81. data/test/dummy/app/mongoid/user_on_main_app.rb +2 -2
  82. data/test/dummy/app/mongoid/user_with_validations.rb +3 -3
  83. data/test/dummy/app/mongoid/user_without_email.rb +7 -4
  84. data/test/dummy/config/application.rb +3 -7
  85. data/test/dummy/config/boot.rb +1 -1
  86. data/test/dummy/config/environment.rb +1 -1
  87. data/test/dummy/config/environments/test.rb +4 -13
  88. data/test/dummy/config/initializers/devise.rb +1 -5
  89. data/test/dummy/config/initializers/migration_class.rb +1 -8
  90. data/test/dummy/config/locales/en.yml +10 -0
  91. data/test/dummy/config/mongoid.yml +1 -1
  92. data/test/dummy/config/routes.rb +6 -3
  93. data/test/dummy/config.ru +1 -1
  94. data/test/dummy/db/migrate/20120508165529_create_tables.rb +15 -6
  95. data/test/dummy/lib/shared_expirable_columns.rb +1 -0
  96. data/test/dummy/lib/shared_security_questions_fields.rb +1 -0
  97. data/test/dummy/lib/shared_user.rb +17 -6
  98. data/test/dummy/lib/shared_user_without_omniauth.rb +12 -3
  99. data/test/dummy/lib/shared_verification_fields.rb +1 -0
  100. data/test/dummy/log/test.log +45240 -0
  101. data/test/i18n_test.rb +22 -0
  102. data/test/integration/test_paranoid_verification_code_workflow.rb +53 -0
  103. data/test/integration/test_password_expirable_workflow.rb +53 -0
  104. data/test/integration/test_session_limitable_workflow.rb +69 -0
  105. data/test/orm/active_record.rb +7 -4
  106. data/test/orm/mongoid.rb +2 -1
  107. data/test/support/integration_helpers.rb +35 -0
  108. data/test/support/mongoid.yml +1 -1
  109. data/test/test_compatibility.rb +15 -0
  110. data/test/test_complexity_validator.rb +251 -29
  111. data/test/test_database_authenticatable_patch.rb +146 -0
  112. data/test/test_helper.rb +23 -8
  113. data/test/test_install_generator.rb +12 -2
  114. data/test/test_paranoid_verification.rb +8 -9
  115. data/test/test_password_archivable.rb +34 -11
  116. data/test/test_password_expirable.rb +27 -27
  117. data/test/test_secure_validatable.rb +284 -50
  118. data/test/test_secure_validatable_overrides.rb +185 -0
  119. data/test/test_session_limitable.rb +57 -0
  120. data/test/tmp/config/initializers/devise_security.rb +52 -0
  121. data/test/tmp/config/locales/devise.security_extension.by.yml +50 -0
  122. data/test/tmp/config/locales/devise.security_extension.cs.yml +46 -0
  123. data/test/tmp/config/locales/devise.security_extension.de.yml +42 -0
  124. data/test/tmp/config/locales/devise.security_extension.en.yml +42 -0
  125. data/test/tmp/config/locales/devise.security_extension.es.yml +42 -0
  126. data/test/tmp/config/locales/devise.security_extension.fa.yml +42 -0
  127. data/test/tmp/config/locales/devise.security_extension.fr.yml +42 -0
  128. data/test/tmp/config/locales/devise.security_extension.hi.yml +43 -0
  129. data/test/tmp/config/locales/devise.security_extension.it.yml +42 -0
  130. data/test/tmp/config/locales/devise.security_extension.ja.yml +42 -0
  131. data/test/tmp/config/locales/devise.security_extension.nl.yml +42 -0
  132. data/test/tmp/config/locales/devise.security_extension.pt.yml +42 -0
  133. data/test/tmp/config/locales/devise.security_extension.ru.yml +50 -0
  134. data/test/tmp/config/locales/devise.security_extension.tr.yml +42 -0
  135. data/test/tmp/config/locales/devise.security_extension.uk.yml +50 -0
  136. data/test/tmp/config/locales/devise.security_extension.zh_CN.yml +42 -0
  137. data/test/tmp/config/locales/devise.security_extension.zh_TW.yml +42 -0
  138. metadata +202 -138
  139. data/.codeclimate.yml +0 -63
  140. data/.document +0 -5
  141. data/.gitignore +0 -43
  142. data/.mdlrc +0 -1
  143. data/.rubocop.yml +0 -64
  144. data/.ruby-version +0 -1
  145. data/.travis.yml +0 -39
  146. data/Appraisals +0 -35
  147. data/Gemfile +0 -10
  148. data/Rakefile +0 -27
  149. data/devise-security.gemspec +0 -50
  150. data/gemfiles/rails_4.2_stable.gemfile +0 -16
  151. data/gemfiles/rails_5.0_stable.gemfile +0 -15
  152. data/gemfiles/rails_5.1_stable.gemfile +0 -15
  153. data/gemfiles/rails_5.2_stable.gemfile +0 -15
  154. data/gemfiles/rails_6.0_beta.gemfile +0 -15
  155. data/lib/devise-security/orm/active_record.rb +0 -20
  156. data/lib/devise-security/patches/confirmations_controller_captcha.rb +0 -23
  157. data/lib/devise-security/patches/confirmations_controller_security_question.rb +0 -26
  158. data/lib/devise-security/patches/passwords_controller_captcha.rb +0 -22
  159. data/lib/devise-security/patches/passwords_controller_security_question.rb +0 -25
  160. data/lib/devise-security/patches/registrations_controller_captcha.rb +0 -35
  161. data/lib/devise-security/patches/sessions_controller_captcha.rb +0 -26
  162. data/lib/devise-security/patches/unlocks_controller_captcha.rb +0 -22
  163. data/lib/devise-security/patches/unlocks_controller_security_question.rb +0 -25
  164. data/lib/devise-security/schema.rb +0 -66
  165. data/test/dummy/app/controllers/foos_controller.rb +0 -0
  166. data/test/dummy/app/models/.gitkeep +0 -0
  167. data/test/dummy/app/models/secure_user.rb +0 -9
  168. data/test/dummy/lib/shared_user_without_email.rb +0 -28
  169. data/test/test_password_expired_controller.rb +0 -46
  170. /data/test/{test_captcha_controller.rb → controllers/test_captcha_controller.rb} +0 -0
@@ -26,7 +26,7 @@ module Devise
26
26
  already_validated_email = false
27
27
 
28
28
  # validate login in a strict way if not yet validated
29
- unless has_uniqueness_validation_of_login?
29
+ unless uniqueness_validation_of_login?
30
30
  validation_condition = "#{login_attribute}_changed?".to_sym
31
31
 
32
32
  validates login_attribute, uniqueness: {
@@ -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
 
@@ -64,10 +86,21 @@ module Devise
64
86
 
65
87
  def current_equal_password_validation
66
88
  return if new_record? || !will_save_change_to_encrypted_password? || password.blank?
89
+
67
90
  dummy = self.class.new(encrypted_password: encrypted_password_was).tap do |user|
68
91
  user.password_salt = password_salt_was if respond_to?(:password_salt)
69
92
  end
70
- self.errors.add(:password, :equal_to_current_password) if dummy.valid_password?(password)
93
+ errors.add(:password, :equal_to_current_password) if dummy.valid_password?(password)
94
+ end
95
+
96
+ def email_not_equal_password_validation
97
+ return if allow_passwords_equal_to_email
98
+
99
+ return if password.blank? || email.blank? || (!new_record? && !will_save_change_to_encrypted_password?)
100
+
101
+ return unless Devise.secure_compare(password.downcase.strip, email.downcase.strip)
102
+
103
+ errors.add(:password, :equal_to_email)
71
104
  end
72
105
 
73
106
  protected
@@ -75,6 +108,8 @@ module Devise
75
108
  # Checks whether a password is needed or not. For validations only.
76
109
  # Passwords are always required if it's a new record, or if the password
77
110
  # or confirmation are being set somewhere.
111
+ #
112
+ # @return [Boolean]
78
113
  def password_required?
79
114
  !persisted? || !password.nil? || !password_confirmation.nil?
80
115
  end
@@ -83,15 +118,31 @@ module Devise
83
118
  true
84
119
  end
85
120
 
121
+ delegate(
122
+ :allow_passwords_equal_to_email,
123
+ :email_validation,
124
+ :password_complexity,
125
+ :password_complexity_validator,
126
+ :password_length,
127
+ to: :class
128
+ )
129
+
86
130
  module ClassMethods
87
- Devise::Models.config(self, :password_complexity, :password_length, :email_validation)
131
+ Devise::Models.config(
132
+ self,
133
+ :allow_passwords_equal_to_email,
134
+ :email_validation,
135
+ :password_complexity,
136
+ :password_complexity_validator,
137
+ :password_length
138
+ )
88
139
 
89
140
  private
90
141
 
91
- def has_uniqueness_validation_of_login?
142
+ def uniqueness_validation_of_login?
92
143
  validators.any? do |validator|
93
144
  validator_orm_klass = DEVISE_ORM == :active_record ? ActiveRecord::Validations::UniquenessValidator : ::Mongoid::Validatable::UniquenessValidator
94
- validator.kind_of?(validator_orm_klass) && validator.attributes.include?(login_attribute)
145
+ validator.is_a?(validator_orm_klass) && validator.attributes.include?(login_attribute)
95
146
  end
96
147
  end
97
148
 
@@ -100,7 +151,7 @@ module Devise
100
151
  end
101
152
 
102
153
  def devise_validation_enabled?
103
- self.ancestors.map(&:to_s).include? 'Devise::Models::Validatable'
154
+ ancestors.map(&:to_s).include? 'Devise::Models::Validatable'
104
155
  end
105
156
  end
106
157
  end
@@ -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,13 +12,27 @@ 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
 
17
+ # Update the unique_session_id on the model. This will be checked in
18
+ # the Warden after_set_user hook in {file:devise-security/hooks/session_limitable}
19
+ # @param unique_session_id [String]
20
+ # @return [void]
21
+ # @raise [Devise::Models::Compatibility::NotPersistedError] if record is unsaved
15
22
  def update_unique_session_id!(unique_session_id)
16
- self.unique_session_id = unique_session_id
23
+ raise Devise::Models::Compatibility::NotPersistedError, 'cannot update a new record' unless persisted?
17
24
 
18
- save(validate: false)
25
+ update_attribute_without_validatons_or_callbacks(:unique_session_id, unique_session_id).tap do
26
+ Rails.logger.debug { "[devise-security][session_limitable] unique_session_id=#{unique_session_id}" }
27
+ end
19
28
  end
20
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
21
36
  end
22
37
  end
23
38
  end
@@ -3,5 +3,5 @@
3
3
  ActiveSupport.on_load(:mongoid) do
4
4
  require 'orm_adapter/adapters/mongoid'
5
5
 
6
- Mongoid::Document::ClassMethods.send :include, Devise::Models
6
+ Mongoid::Document::ClassMethods.include(Devise::Models)
7
7
  end
@@ -6,18 +6,24 @@ module DeviseSecurity
6
6
  autoload :ControllerSecurityQuestion, 'devise-security/patches/controller_security_question'
7
7
 
8
8
  class << self
9
+ # rubocop:disable Metrics/AbcSize
10
+ # rubocop:disable Metrics/CyclomaticComplexity
11
+ # rubocop:disable Metrics/PerceivedComplexity
9
12
  def apply
10
- Devise::PasswordsController.send(:include, Patches::ControllerCaptcha) if Devise.captcha_for_recover || Devise.security_question_for_recover
11
- Devise::UnlocksController.send(:include, Patches::ControllerCaptcha) if Devise.captcha_for_unlock || Devise.security_question_for_unlock
12
- Devise::ConfirmationsController.send(:include, Patches::ControllerCaptcha) if Devise.captcha_for_confirmation
13
+ Devise::PasswordsController.include(Patches::ControllerCaptcha) if Devise.captcha_for_recover || Devise.security_question_for_recover
14
+ Devise::UnlocksController.include(Patches::ControllerCaptcha) if Devise.captcha_for_unlock || Devise.security_question_for_unlock
15
+ Devise::ConfirmationsController.include(Patches::ControllerCaptcha) if Devise.captcha_for_confirmation
13
16
 
14
- Devise::PasswordsController.send(:include, Patches::ControllerSecurityQuestion) if Devise.security_question_for_recover
15
- Devise::UnlocksController.send(:include, Patches::ControllerSecurityQuestion) if Devise.security_question_for_unlock
16
- Devise::ConfirmationsController.send(:include, Patches::ControllerSecurityQuestion) if Devise.security_question_for_confirmation
17
+ Devise::PasswordsController.include(Patches::ControllerSecurityQuestion) if Devise.security_question_for_recover
18
+ Devise::UnlocksController.include(Patches::ControllerSecurityQuestion) if Devise.security_question_for_unlock
19
+ Devise::ConfirmationsController.include(Patches::ControllerSecurityQuestion) if Devise.security_question_for_confirmation
17
20
 
18
- Devise::RegistrationsController.send(:include, Patches::ControllerCaptcha) if Devise.captcha_for_sign_up
19
- Devise::SessionsController.send(:include, Patches::ControllerCaptcha) if Devise.captcha_for_sign_in
21
+ Devise::RegistrationsController.include(Patches::ControllerCaptcha) if Devise.captcha_for_sign_up
22
+ Devise::SessionsController.include(Patches::ControllerCaptcha) if Devise.captcha_for_sign_in
20
23
  end
24
+ # rubocop:enable Metrics/AbcSize
25
+ # rubocop:enable Metrics/CyclomaticComplexity
26
+ # rubocop:enable Metrics/PerceivedComplexity
21
27
  end
22
28
  end
23
29
  end
@@ -2,17 +2,16 @@
2
2
 
3
3
  module ActionDispatch::Routing
4
4
  class Mapper
5
-
6
5
  protected
7
6
 
8
7
  # route for handle expired passwords
9
8
  def devise_password_expired(mapping, controllers)
10
- resource :password_expired, only: [:show, :update], path: mapping.path_names[:password_expired], controller: controllers[:password_expired]
9
+ resource :password_expired, only: %i[show update], path: mapping.path_names[:password_expired], controller: controllers[:password_expired]
11
10
  end
12
11
 
13
12
  # route for handle paranoid verification
14
13
  def devise_verification_code(mapping, controllers)
15
- resource :paranoid_verification_code, only: [:show, :update], path: mapping.path_names[:verification_code], controller: controllers[:paranoid_verification_code]
14
+ resource :paranoid_verification_code, only: %i[show update], path: mapping.path_names[:verification_code], controller: controllers[:paranoid_verification_code]
16
15
  end
17
16
  end
18
17
  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.1'
4
+ VERSION = '0.18.0'
5
5
  end
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  DEVISE_ORM = ENV.fetch('DEVISE_ORM', 'active_record').to_sym unless defined?(DEVISE_ORM)
3
4
 
4
- require DEVISE_ORM.to_s if DEVISE_ORM.in? [:active_record, :mongoid]
5
+ require DEVISE_ORM.to_s if DEVISE_ORM.in? %i[active_record mongoid]
5
6
  require 'active_support/core_ext/integer'
6
7
  require 'active_support/ordered_hash'
7
8
  require 'active_support/concern'
@@ -9,15 +10,20 @@ require 'devise'
9
10
 
10
11
  module Devise
11
12
  # Number of seconds that passwords are valid (e.g 3.months)
12
- # Disable pasword expiration with +false+
13
+ # Disable password expiration with +false+
13
14
  # Expire only on demand with +true+
14
15
  mattr_accessor :expire_password_after
15
16
  @@expire_password_after = 3.months
16
17
 
17
- # Validate password for strongness
18
+ # Validate password complexity
18
19
  mattr_accessor :password_complexity
19
20
  @@password_complexity = { digit: 1, lower: 1, symbol: 1, upper: 1 }
20
21
 
22
+ # Define the class used to validate password complexity. Set to a Class or a
23
+ # string which will be used to determine which class to use.
24
+ mattr_accessor :password_complexity_validator
25
+ @@password_complexity_validator = 'devise_security/password_complexity_validator'
26
+
21
27
  # Number of old passwords in archive
22
28
  mattr_accessor :password_archiving_count
23
29
  @@password_archiving_count = 5
@@ -79,11 +85,14 @@ module Devise
79
85
  # paranoid_verification will regenerate verifacation code after faild attempt
80
86
  mattr_accessor :paranoid_code_regenerate_after_attempt
81
87
  @@paranoid_code_regenerate_after_attempt = 10
88
+
89
+ # Whether to allow passwords that are equal (case insensitive) to the email
90
+ mattr_accessor :allow_passwords_equal_to_email
91
+ @@allow_passwords_equal_to_email = false
82
92
  end
83
93
 
84
- # an security extension for devise
94
+ # a security extension for devise
85
95
  module DeviseSecurity
86
- autoload :Schema, 'devise-security/schema'
87
96
  autoload :Patches, 'devise-security/patches'
88
97
 
89
98
  module Controllers
@@ -104,6 +113,6 @@ Devise.add_module :paranoid_verification, controller: :paranoid_verification_cod
104
113
  # requires
105
114
  require 'devise-security/routes'
106
115
  require 'devise-security/rails'
107
- require "devise-security/orm/#{DEVISE_ORM}"
116
+ require "devise-security/orm/#{DEVISE_ORM}" if DEVISE_ORM == :mongoid
108
117
  require 'devise-security/models/database_authenticatable_patch'
109
118
  require 'devise-security/models/paranoid_verification'
@@ -4,22 +4,20 @@ 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
- source_root File.expand_path('../../templates', __FILE__)
9
+ source_root File.expand_path('../templates', __dir__)
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',
15
- )
13
+ template('devise_security.rb', 'config/initializers/devise_security.rb')
16
14
  end
17
15
 
18
16
  def copy_locales
19
17
  LOCALES.each do |locale|
20
18
  copy_file(
21
19
  "../../../config/locales/#{locale}.yml",
22
- "config/locales/devise.security_extension.#{locale}.yml",
20
+ "config/locales/devise.security_extension.#{locale}.yml"
23
21
  )
24
22
  end
25
23
  end
@@ -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,10 @@ 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
49
+
50
+ # paranoid_verification will regenerate verification code after failed attempt
51
+ # config.paranoid_code_regenerate_after_attempt = 10
44
52
  end
@@ -0,0 +1,133 @@
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
+ @controller.class.respond_to :json, :xml
10
+ @request.env['devise.mapping'] = Devise.mappings[:user]
11
+ @user = User.create!(
12
+ username: 'hello',
13
+ email: 'hello@path.travel',
14
+ password: 'Password4',
15
+ confirmed_at: 5.months.ago,
16
+ paranoid_verification_code: 'cookies'
17
+ )
18
+ assert @user.valid?
19
+ assert @user.need_paranoid_verification?
20
+
21
+ sign_in(@user)
22
+ end
23
+
24
+ test 'redirects to root on show if user not logged in' do
25
+ sign_out(@user)
26
+ get :show
27
+ assert_redirected_to :root
28
+ end
29
+
30
+ test "redirects to root on show if user doesn't need paranoid verification" do
31
+ @user.update(paranoid_verification_code: nil)
32
+ get :show
33
+ assert_redirected_to :root
34
+ end
35
+
36
+ test 'renders show on show if user needs paranoid verification' do
37
+ @user.update(paranoid_verification_code: 'cookies')
38
+ get :show
39
+ assert_template :show
40
+ end
41
+
42
+ test 'redirects on update if user not logged in' do
43
+ sign_out(@user)
44
+ patch :update
45
+ assert_redirected_to :root
46
+ end
47
+
48
+ test 'redirects on update if user does not need paranoid verification' do
49
+ @user.update(paranoid_verification_code: nil)
50
+ patch :update
51
+ assert_redirected_to :root
52
+ end
53
+
54
+ test 'update paranoid_verification_code with default format' do
55
+ patch(
56
+ :update,
57
+ params: {
58
+ user: {
59
+ paranoid_verification_code: 'cookies'
60
+ }
61
+ }
62
+ )
63
+ assert_redirected_to root_path
64
+ assert_equal 'Verification code accepted', flash[:notice]
65
+ assert_equal('text/html', response.media_type)
66
+ end
67
+
68
+ test 'update paranoid_verification_code using JSON format' do
69
+ patch(
70
+ :update,
71
+ format: :json,
72
+ params: {
73
+ user: {
74
+ paranoid_verification_code: 'cookies'
75
+ }
76
+ }
77
+ )
78
+
79
+ assert_response 204
80
+ assert_equal root_url, response.location
81
+ assert_nil response.media_type, 'No Content-Type header should be set for No Content response'
82
+ end
83
+
84
+ test 'update paranoid_verification_code using XML format' do
85
+ patch(
86
+ :update,
87
+ format: :xml,
88
+ params: {
89
+ user: {
90
+ paranoid_verification_code: 'cookies'
91
+ }
92
+ }
93
+ )
94
+ assert_response 204
95
+ assert_equal root_url, response.location
96
+ assert_nil response.media_type, 'No Content-Type header should be set for No Content response'
97
+ end
98
+ end
99
+
100
+ class ParanoidVerificationCodeCustomRedirectTest < ActionController::TestCase
101
+ include Devise::Test::ControllerHelpers
102
+ tests Overrides::ParanoidVerificationCodeController
103
+
104
+ setup do
105
+ @controller.class.respond_to :json, :xml
106
+ @request.env['devise.mapping'] = Devise.mappings[:paranoid_verification_user]
107
+ @user = ParanoidVerificationUser.create!(
108
+ username: 'hello',
109
+ email: 'hello@path.travel',
110
+ password: 'Password4',
111
+ confirmed_at: 5.months.ago,
112
+ paranoid_verification_code: 'cookies'
113
+ )
114
+ assert @user.valid?
115
+ assert @user.need_paranoid_verification?
116
+
117
+ sign_in(@user)
118
+ end
119
+
120
+ test 'redirects to custom redirect route on update' do
121
+ patch(
122
+ :update,
123
+ params: {
124
+ paranoid_verification_user: {
125
+ paranoid_verification_code: 'cookies'
126
+ }
127
+ }
128
+ )
129
+
130
+ assert_redirected_to '/cats'
131
+ assert_equal 'Verification code accepted', flash[:notice]
132
+ end
133
+ end