devise-security 0.12.0 → 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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +3 -1
  3. data/README.md +199 -65
  4. data/app/controllers/devise/paranoid_verification_code_controller.rb +28 -12
  5. data/app/controllers/devise/password_expired_controller.rb +34 -10
  6. data/app/views/devise/paranoid_verification_code/show.html.erb +4 -4
  7. data/app/views/devise/password_expired/show.html.erb +6 -6
  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 +33 -7
  12. data/config/locales/en.yml +26 -1
  13. data/config/locales/es.yml +31 -6
  14. data/config/locales/fa.yml +42 -0
  15. data/config/locales/fr.yml +42 -0
  16. data/config/locales/hi.yml +43 -0
  17. data/config/locales/it.yml +36 -4
  18. data/config/locales/ja.yml +42 -0
  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 +42 -0
  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 +74 -51
  27. data/lib/devise-security/hooks/expirable.rb +6 -4
  28. data/lib/devise-security/hooks/paranoid_verification.rb +3 -3
  29. data/lib/devise-security/hooks/password_expirable.rb +5 -3
  30. data/lib/devise-security/hooks/session_limitable.rb +31 -14
  31. data/lib/devise-security/models/active_record/old_password.rb +5 -0
  32. data/lib/devise-security/models/compatibility/active_record_patch.rb +41 -0
  33. data/lib/devise-security/models/compatibility/mongoid_patch.rb +32 -0
  34. data/lib/devise-security/models/compatibility.rb +8 -15
  35. data/lib/devise-security/models/database_authenticatable_patch.rb +20 -10
  36. data/lib/devise-security/models/expirable.rb +14 -7
  37. data/lib/devise-security/models/mongoid/old_password.rb +21 -0
  38. data/lib/devise-security/models/paranoid_verification.rb +4 -2
  39. data/lib/devise-security/models/password_archivable.rb +19 -8
  40. data/lib/devise-security/models/password_expirable.rb +103 -48
  41. data/lib/devise-security/models/secure_validatable.rb +69 -12
  42. data/lib/devise-security/models/security_questionable.rb +2 -0
  43. data/lib/devise-security/models/session_limitable.rb +19 -2
  44. data/lib/devise-security/orm/mongoid.rb +7 -0
  45. data/lib/devise-security/patches/controller_captcha.rb +2 -0
  46. data/lib/devise-security/patches/controller_security_question.rb +3 -1
  47. data/lib/devise-security/patches.rb +16 -8
  48. data/lib/devise-security/rails.rb +2 -0
  49. data/lib/devise-security/routes.rb +4 -3
  50. data/lib/devise-security/validators/password_complexity_validator.rb +62 -0
  51. data/lib/devise-security/version.rb +3 -1
  52. data/lib/devise-security.rb +23 -11
  53. data/lib/generators/devise_security/install_generator.rb +6 -6
  54. data/lib/generators/templates/devise_security.rb +52 -0
  55. data/test/{test_captcha_controller.rb → controllers/test_captcha_controller.rb} +2 -0
  56. data/test/controllers/test_paranoid_verification_code_controller.rb +133 -0
  57. data/test/controllers/test_password_expired_controller.rb +164 -0
  58. data/test/controllers/test_security_question_controller.rb +66 -0
  59. data/test/dummy/Rakefile +3 -1
  60. data/test/dummy/app/assets/config/manifest.js +3 -0
  61. data/test/dummy/app/controllers/application_controller.rb +2 -0
  62. data/test/dummy/app/controllers/captcha/sessions_controller.rb +2 -0
  63. data/test/dummy/app/controllers/overrides/paranoid_verification_code_controller.rb +7 -0
  64. data/test/dummy/app/controllers/overrides/password_expired_controller.rb +17 -0
  65. data/test/dummy/app/controllers/security_question/unlocks_controller.rb +2 -0
  66. data/test/dummy/app/controllers/widgets_controller.rb +9 -0
  67. data/test/dummy/app/models/application_record.rb +10 -2
  68. data/test/dummy/app/models/application_user_record.rb +12 -0
  69. data/test/dummy/app/models/captcha_user.rb +7 -2
  70. data/test/dummy/app/models/mongoid/confirmable_fields.rb +15 -0
  71. data/test/dummy/app/models/mongoid/database_authenticable_fields.rb +18 -0
  72. data/test/dummy/app/models/mongoid/expirable_fields.rb +13 -0
  73. data/test/dummy/app/models/mongoid/lockable_fields.rb +15 -0
  74. data/test/dummy/app/models/mongoid/mappings.rb +15 -0
  75. data/test/dummy/app/models/mongoid/omniauthable_fields.rb +13 -0
  76. data/test/dummy/app/models/mongoid/paranoid_verification_fields.rb +12 -0
  77. data/test/dummy/app/models/mongoid/password_archivable_fields.rb +11 -0
  78. data/test/dummy/app/models/mongoid/password_expirable_fields.rb +12 -0
  79. data/test/dummy/app/models/mongoid/recoverable_fields.rb +13 -0
  80. data/test/dummy/app/models/mongoid/registerable_fields.rb +21 -0
  81. data/test/dummy/app/models/mongoid/rememberable_fields.rb +12 -0
  82. data/test/dummy/app/models/mongoid/secure_validatable_fields.rb +13 -0
  83. data/test/dummy/app/models/mongoid/security_questionable_fields.rb +15 -0
  84. data/test/dummy/app/models/mongoid/session_limitable_fields.rb +12 -0
  85. data/test/dummy/app/models/mongoid/timeoutable_fields.rb +11 -0
  86. data/test/dummy/app/models/mongoid/trackable_fields.rb +16 -0
  87. data/test/dummy/app/models/mongoid/validatable_fields.rb +9 -0
  88. data/test/dummy/app/models/paranoid_verification_user.rb +26 -0
  89. data/test/dummy/app/models/password_expired_user.rb +26 -0
  90. data/test/dummy/app/models/security_question_user.rb +9 -4
  91. data/test/dummy/app/models/user.rb +16 -1
  92. data/test/dummy/app/models/widget.rb +4 -0
  93. data/test/dummy/app/mongoid/admin.rb +31 -0
  94. data/test/dummy/app/mongoid/one_user.rb +58 -0
  95. data/test/dummy/app/mongoid/shim.rb +25 -0
  96. data/test/dummy/app/mongoid/user_on_engine.rb +41 -0
  97. data/test/dummy/app/mongoid/user_on_main_app.rb +41 -0
  98. data/test/dummy/app/mongoid/user_with_validations.rb +37 -0
  99. data/test/dummy/app/mongoid/user_without_email.rb +38 -0
  100. data/test/dummy/config/application.rb +13 -11
  101. data/test/dummy/config/boot.rb +3 -1
  102. data/test/dummy/config/environment.rb +3 -1
  103. data/test/dummy/config/environments/test.rb +6 -13
  104. data/test/dummy/config/initializers/devise.rb +6 -3
  105. data/test/dummy/config/initializers/migration_class.rb +3 -6
  106. data/test/dummy/config/locales/en.yml +10 -0
  107. data/test/dummy/config/mongoid.yml +6 -0
  108. data/test/dummy/config/routes.rb +8 -3
  109. data/test/dummy/config.ru +3 -1
  110. data/test/dummy/db/migrate/20120508165529_create_tables.rb +17 -6
  111. data/test/dummy/db/migrate/20150402165590_add_verification_columns.rb +2 -0
  112. data/test/dummy/db/migrate/20150407162345_add_verification_attempt_column.rb +2 -0
  113. data/test/dummy/db/migrate/20160320162345_add_security_questions_fields.rb +2 -0
  114. data/test/dummy/db/migrate/20180318103603_add_expireable_columns.rb +2 -0
  115. data/test/dummy/db/migrate/20180318105329_add_confirmable_columns.rb +2 -0
  116. data/test/dummy/db/migrate/20180318105732_add_rememberable_columns.rb +2 -0
  117. data/test/dummy/db/migrate/20180318111336_add_recoverable_columns.rb +2 -0
  118. data/test/dummy/db/migrate/20180319114023_add_widget.rb +2 -0
  119. data/test/dummy/lib/shared_expirable_columns.rb +15 -0
  120. data/test/dummy/lib/shared_security_questions_fields.rb +17 -0
  121. data/test/dummy/lib/shared_user.rb +43 -0
  122. data/test/dummy/lib/shared_user_with_password_verification.rb +13 -0
  123. data/test/dummy/lib/shared_user_without_omniauth.rb +24 -0
  124. data/test/dummy/lib/shared_verification_fields.rb +16 -0
  125. data/test/dummy/log/test.log +45240 -0
  126. data/test/i18n_test.rb +22 -0
  127. data/test/integration/test_paranoid_verification_code_workflow.rb +53 -0
  128. data/test/integration/test_password_expirable_workflow.rb +53 -0
  129. data/test/integration/test_session_limitable_workflow.rb +69 -0
  130. data/test/orm/active_record.rb +15 -0
  131. data/test/orm/mongoid.rb +13 -0
  132. data/test/support/integration_helpers.rb +35 -0
  133. data/test/support/mongoid.yml +6 -0
  134. data/test/test_compatibility.rb +15 -0
  135. data/test/test_complexity_validator.rb +282 -0
  136. data/test/test_database_authenticatable_patch.rb +146 -0
  137. data/test/test_helper.rb +41 -9
  138. data/test/test_install_generator.rb +20 -3
  139. data/test/test_paranoid_verification.rb +10 -9
  140. data/test/test_password_archivable.rb +37 -13
  141. data/test/test_password_expirable.rb +72 -9
  142. data/test/test_secure_validatable.rb +289 -55
  143. data/test/test_secure_validatable_overrides.rb +185 -0
  144. data/test/test_session_limitable.rb +57 -0
  145. data/test/tmp/config/initializers/devise_security.rb +52 -0
  146. data/test/tmp/config/locales/devise.security_extension.by.yml +50 -0
  147. data/test/tmp/config/locales/devise.security_extension.cs.yml +46 -0
  148. data/test/tmp/config/locales/devise.security_extension.de.yml +42 -0
  149. data/test/tmp/config/locales/devise.security_extension.en.yml +42 -0
  150. data/test/tmp/config/locales/devise.security_extension.es.yml +42 -0
  151. data/test/tmp/config/locales/devise.security_extension.fa.yml +42 -0
  152. data/test/tmp/config/locales/devise.security_extension.fr.yml +42 -0
  153. data/test/tmp/config/locales/devise.security_extension.hi.yml +43 -0
  154. data/test/tmp/config/locales/devise.security_extension.it.yml +42 -0
  155. data/test/tmp/config/locales/devise.security_extension.ja.yml +42 -0
  156. data/test/tmp/config/locales/devise.security_extension.nl.yml +42 -0
  157. data/test/tmp/config/locales/devise.security_extension.pt.yml +42 -0
  158. data/test/tmp/config/locales/devise.security_extension.ru.yml +50 -0
  159. data/test/tmp/config/locales/devise.security_extension.tr.yml +42 -0
  160. data/test/tmp/config/locales/devise.security_extension.uk.yml +50 -0
  161. data/test/tmp/config/locales/devise.security_extension.zh_CN.yml +42 -0
  162. data/test/tmp/config/locales/devise.security_extension.zh_TW.yml +42 -0
  163. metadata +290 -124
  164. data/.circleci/config.yml +0 -41
  165. data/.document +0 -5
  166. data/.gitignore +0 -40
  167. data/.rubocop.yml +0 -63
  168. data/.ruby-version +0 -1
  169. data/.travis.yml +0 -25
  170. data/Appraisals +0 -19
  171. data/Gemfile +0 -3
  172. data/Rakefile +0 -28
  173. data/devise-security.gemspec +0 -44
  174. data/gemfiles/rails_4.1_stable.gemfile +0 -8
  175. data/gemfiles/rails_4.2_stable.gemfile +0 -8
  176. data/gemfiles/rails_5.0_stable.gemfile +0 -8
  177. data/gemfiles/rails_5.1_stable.gemfile +0 -8
  178. data/gemfiles/rails_5.2_rc1.gemfile +0 -8
  179. data/lib/devise-security/models/old_password.rb +0 -4
  180. data/lib/devise-security/orm/active_record.rb +0 -18
  181. data/lib/devise-security/patches/confirmations_controller_captcha.rb +0 -21
  182. data/lib/devise-security/patches/confirmations_controller_security_question.rb +0 -24
  183. data/lib/devise-security/patches/passwords_controller_captcha.rb +0 -20
  184. data/lib/devise-security/patches/passwords_controller_security_question.rb +0 -23
  185. data/lib/devise-security/patches/registrations_controller_captcha.rb +0 -33
  186. data/lib/devise-security/patches/sessions_controller_captcha.rb +0 -24
  187. data/lib/devise-security/patches/unlocks_controller_captcha.rb +0 -20
  188. data/lib/devise-security/patches/unlocks_controller_security_question.rb +0 -23
  189. data/lib/devise-security/schema.rb +0 -64
  190. data/lib/generators/templates/devise-security.rb +0 -38
  191. data/test/dummy/app/controllers/foos_controller.rb +0 -0
  192. data/test/dummy/app/models/.gitkeep +0 -0
  193. data/test/dummy/app/models/secure_user.rb +0 -3
  194. data/test/test_password_expired_controller.rb +0 -44
  195. data/test/test_security_question_controller.rb +0 -84
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'devise-security/hooks/paranoid_verification'
2
4
 
3
5
  module Devise
@@ -18,7 +20,7 @@ module Devise
18
20
  elsif code == paranoid_verification_code
19
21
  attempt = 0
20
22
  update_without_password paranoid_verification_code: nil,
21
- paranoid_verified_at: Time.now,
23
+ paranoid_verified_at: Time.zone.now,
22
24
  paranoid_verification_attempt: attempt
23
25
  else
24
26
  update_without_password paranoid_verification_attempt: attempt
@@ -30,7 +32,7 @@ module Devise
30
32
  end
31
33
 
32
34
  def generate_paranoid_code
33
- update_without_password paranoid_verification_code: Devise.verification_code_generator.call(),
35
+ update_without_password paranoid_verification_code: Devise.verification_code_generator.call,
34
36
  paranoid_verification_attempt: 0
35
37
  end
36
38
  end
@@ -1,4 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'compatibility'
4
+ require_relative "#{DEVISE_ORM}/old_password"
2
5
 
3
6
  module Devise
4
7
  module Models
@@ -9,7 +12,7 @@ module Devise
9
12
  include Devise::Models::DatabaseAuthenticatable
10
13
 
11
14
  included do
12
- has_many :old_passwords, as: :password_archivable, dependent: :destroy
15
+ has_many :old_passwords, class_name: 'OldPassword', as: :password_archivable, dependent: :destroy
13
16
  before_update :archive_password, if: :will_save_change_to_encrypted_password?
14
17
  validate :validate_password_archive, if: :password_present?
15
18
  end
@@ -32,15 +35,19 @@ module Devise
32
35
  end
33
36
  end
34
37
 
35
- # validate is the password used in the past
38
+ # validate if the password was used in the past
36
39
  # @return [true] if current password was used previously
37
40
  # @return [false] if disabled or not previously used
38
41
  def password_archive_included?
39
- return false unless max_old_passwords > 0
40
- old_passwords_including_cur_change = old_passwords.order(:id).reverse_order.limit(max_old_passwords).pluck(:encrypted_password)
42
+ return false unless max_old_passwords.positive?
43
+
44
+ old_passwords_including_cur_change = old_passwords.reorder(created_at: :desc).limit(max_old_passwords).pluck(:encrypted_password)
41
45
  old_passwords_including_cur_change << encrypted_password_was # include most recent change in list, but don't save it yet!
42
46
  old_passwords_including_cur_change.any? do |old_password|
43
- self.class.new(encrypted_password: old_password).valid_password?(password)
47
+ # NOTE: we deliberately do not do mass assignment here so that users that
48
+ # rely on `protected_attributes_continued` gem can still use this extension.
49
+ # See issue #68
50
+ self.class.new.tap { |object| object.encrypted_password = old_password }.valid_password?(password)
44
51
  end
45
52
  end
46
53
 
@@ -58,11 +65,15 @@ module Devise
58
65
 
59
66
  private
60
67
 
61
- # archive the last password before save and delete all to old passwords from archive
68
+ # Archive the last password before save and delete all to old passwords from archive
69
+ # @note we check to see if an old password has already been archived because
70
+ # mongoid will keep re-triggering this callback when we add an old password
62
71
  def archive_password
63
- if max_old_passwords > 0
72
+ if max_old_passwords.positive?
73
+ return true if old_passwords.where(encrypted_password: encrypted_password_was).exists?
74
+
64
75
  old_passwords.create!(encrypted_password: encrypted_password_was) if encrypted_password_was.present?
65
- old_passwords.order(:id).reverse_order.offset(max_old_passwords).destroy_all
76
+ old_passwords.reorder(created_at: :desc).offset(max_old_passwords).destroy_all
66
77
  else
67
78
  old_passwords.destroy_all
68
79
  end
@@ -1,67 +1,122 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'devise-security/hooks/password_expirable'
2
4
 
3
- module Devise
4
- module Models
5
+ module Devise::Models
6
+ # PasswordExpirable makes passwords expire after a configurable amount of
7
+ # time, or on demand.
8
+ #
9
+ # == Configuration
10
+ # Set +expire_password_after+ to the number of seconds a password is valid for
11
+ # (example: +3.months+). Setting it to +true+ will allow passwords to be expired
12
+ # on-demand only, and +false+ disables this feature.
13
+ #
14
+ # == Expire On-Demand
15
+ # This is useful to force users to change passwords for complex business reasons.
16
+ # Call +need_change_password+ to indicate a record needs a new password.
17
+ module PasswordExpirable
18
+ extend ActiveSupport::Concern
5
19
 
6
- # PasswordExpirable takes care of change password after
7
- module PasswordExpirable
8
- extend ActiveSupport::Concern
20
+ included do
21
+ scope :with_password_change_requested, -> { where(password_changed_at: nil) }
22
+ scope :without_password_change_requested, -> { where.not(password_changed_at: nil) }
23
+ scope :with_expired_password, -> { where('password_changed_at is NULL OR password_changed_at < ?', expire_password_after.seconds.ago) }
24
+ scope :without_expired_password, -> { without_password_change_requested.where('password_changed_at >= ?', expire_password_after.seconds.ago) }
25
+ before_save :update_password_changed
26
+ end
9
27
 
10
- included do
11
- before_save :update_password_changed
12
- end
28
+ # Is a password change required?
29
+ # @return [Boolean]
30
+ # @return [true] if +password_changed_at+ has not been set or if it is old
31
+ # enough based on +expire_password_after+ configuration.
32
+ def need_change_password?
33
+ password_change_requested? || password_too_old?
34
+ end
13
35
 
14
- # is an password change required?
15
- def need_change_password?
16
- if expired_password_after_numeric?
17
- self.password_changed_at.nil? || self.password_changed_at < self.expire_password_after.seconds.ago
18
- else
19
- false
20
- end
21
- end
36
+ # Clear the +password_changed_at+ field so that the user will be required to
37
+ # update their password.
38
+ # @note Saves the record (without validations)
39
+ # @return [Boolean]
40
+ def need_change_password!
41
+ return unless password_expiration_enabled?
22
42
 
23
- # set a fake datetime so a password change is needed and save the record
24
- def need_change_password!
25
- if expired_password_after_numeric?
26
- need_change_password
27
- self.save(validate: false)
28
- end
29
- end
43
+ need_change_password
44
+ save(validate: false)
45
+ end
46
+ alias expire_password! need_change_password!
47
+ alias request_password_change! need_change_password!
30
48
 
31
- # set a fake datetime so a password change is needed
32
- def need_change_password
33
- if expired_password_after_numeric?
34
- self.password_changed_at = self.expire_password_after.seconds.ago
35
- end
49
+ # Clear the +password_changed_at+ field so that the user will be required to
50
+ # update their password.
51
+ # @note Does not save the record
52
+ # @return [void]
53
+ def need_change_password
54
+ return unless password_expiration_enabled?
36
55
 
37
- # is date not set it will set default to need set new password next login
38
- need_change_password if self.password_changed_at.nil?
56
+ self.password_changed_at = nil
57
+ end
58
+ alias expire_password need_change_password
59
+ alias request_password_change need_change_password
39
60
 
40
- self.password_changed_at
41
- end
61
+ # @return [Integer] number of seconds passwords are valid for
62
+ # @return [true] passwords are expired 'on demand' only.
63
+ # @return [false] passwords never expire (this feature is disabled)
64
+ def expire_password_after
65
+ self.class.expire_password_after
66
+ end
42
67
 
43
- def expire_password_after
44
- self.class.expire_password_after
45
- end
68
+ # When +password_changed_at+ is set to +NULL+ in the database
69
+ # the user is required to change their password. This only happens
70
+ # on demand or when the column is first added to the table.
71
+ # @return [Boolean]
72
+ def password_change_requested?
73
+ return false unless password_expiration_enabled?
74
+ return false if new_record?
46
75
 
47
- private
76
+ password_changed_at.nil?
77
+ end
78
+
79
+ # Is this password older than the configured expiration timeout?
80
+ # @return [Boolean]
81
+ def password_too_old?
82
+ return false if new_record?
83
+ return false unless password_expiration_enabled?
84
+ return false if expire_password_on_demand?
48
85
 
49
- # is password changed then update password_cahanged_at
50
- def update_password_changed
51
- self.password_changed_at = Time.now if (self.new_record? || self.encrypted_password_changed?) && !self.password_changed_at_changed?
52
- end
86
+ password_changed_at < expire_password_after.seconds.ago
87
+ end
88
+ alias password_expired? password_too_old?
53
89
 
54
- def expired_password_after_numeric?
55
- return @_numeric if defined?(@_numeric)
56
- @_numeric ||= self.expire_password_after.is_a?(1.class) ||
57
- self.expire_password_after.is_a?(Float)
58
- end
90
+ private
59
91
 
60
- module ClassMethods
61
- ::Devise::Models.config(self, :expire_password_after)
92
+ # Update +password_changed_at+ for new records and changed passwords.
93
+ # @note called as a +before_save+ hook
94
+ def update_password_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?
62
99
  end
100
+
101
+ self.password_changed_at = Time.zone.now
63
102
  end
64
103
 
65
- end
104
+ # Enabled if configuration +expire_password_after+ is set to an {Integer},
105
+ # {Float}, or {true}
106
+ def password_expiration_enabled?
107
+ expire_password_after.is_a?(1.class) ||
108
+ expire_password_after.is_a?(Float) ||
109
+ expire_password_on_demand?
110
+ end
111
+
112
+ # When +expire_password_after+ is set to +true+ then only expire passwords
113
+ # on demand.
114
+ def expire_password_on_demand?
115
+ expire_password_after.present? && expire_password_after == true
116
+ end
66
117
 
118
+ module ClassMethods
119
+ ::Devise::Models.config(self, :expire_password_after)
120
+ end
121
+ end
67
122
  end
@@ -1,4 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'compatibility'
4
+ require_relative '../validators/password_complexity_validator'
2
5
 
3
6
  module Devise
4
7
  module Models
@@ -23,7 +26,7 @@ module Devise
23
26
  already_validated_email = false
24
27
 
25
28
  # validate login in a strict way if not yet validated
26
- unless has_uniqueness_validation_of_login?
29
+ unless uniqueness_validation_of_login?
27
30
  validation_condition = "#{login_attribute}_changed?".to_sym
28
31
 
29
32
  validates login_attribute, uniqueness: {
@@ -41,15 +44,39 @@ module Devise
41
44
  validates :email, uniqueness: true, allow_blank: true, if: :email_changed? # check uniq for email ever
42
45
  end
43
46
 
44
- 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
45
56
  end
46
57
 
47
58
  # extra validations
48
- validates :email, email: email_validation if email_validation # use rails_email_validator or similar
49
- validates :password, format: { with: password_regex, message: :password_format }, 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
50
74
 
51
75
  # don't allow use same password
52
76
  validate :current_equal_password_validation
77
+
78
+ # don't allow email to equal password
79
+ validate :email_not_equal_password_validation
53
80
  end
54
81
  end
55
82
 
@@ -59,10 +86,21 @@ module Devise
59
86
 
60
87
  def current_equal_password_validation
61
88
  return if new_record? || !will_save_change_to_encrypted_password? || password.blank?
89
+
62
90
  dummy = self.class.new(encrypted_password: encrypted_password_was).tap do |user|
63
91
  user.password_salt = password_salt_was if respond_to?(:password_salt)
64
92
  end
65
- 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)
66
104
  end
67
105
 
68
106
  protected
@@ -70,6 +108,8 @@ module Devise
70
108
  # Checks whether a password is needed or not. For validations only.
71
109
  # Passwords are always required if it's a new record, or if the password
72
110
  # or confirmation are being set somewhere.
111
+ #
112
+ # @return [Boolean]
73
113
  def password_required?
74
114
  !persisted? || !password.nil? || !password_confirmation.nil?
75
115
  end
@@ -78,14 +118,31 @@ module Devise
78
118
  true
79
119
  end
80
120
 
81
- module ClassMethods
82
- Devise::Models.config(self, :password_regex, :password_length, :email_validation)
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
+ )
83
129
 
84
- private
85
- def has_uniqueness_validation_of_login?
130
+ module ClassMethods
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
+ )
139
+
140
+ private
141
+
142
+ def uniqueness_validation_of_login?
86
143
  validators.any? do |validator|
87
- validator.kind_of?(ActiveRecord::Validations::UniquenessValidator) &&
88
- validator.attributes.include?(login_attribute)
144
+ validator_orm_klass = DEVISE_ORM == :active_record ? ActiveRecord::Validations::UniquenessValidator : ::Mongoid::Validatable::UniquenessValidator
145
+ validator.is_a?(validator_orm_klass) && validator.attributes.include?(login_attribute)
89
146
  end
90
147
  end
91
148
 
@@ -94,7 +151,7 @@ module Devise
94
151
  end
95
152
 
96
153
  def devise_validation_enabled?
97
- self.ancestors.map(&:to_s).include? 'Devise::Models::Validatable'
154
+ ancestors.map(&:to_s).include? 'Devise::Models::Validatable'
98
155
  end
99
156
  end
100
157
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Devise
2
4
  module Models
3
5
  # SecurityQuestionable is an accessible add-on for visually handicapped people,
@@ -1,3 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'compatibility'
1
4
  require 'devise-security/hooks/session_limitable'
2
5
 
3
6
  module Devise
@@ -9,13 +12,27 @@ module Devise
9
12
  # someone used his credentials to sign in.
10
13
  module SessionLimitable
11
14
  extend ActiveSupport::Concern
15
+ include Devise::Models::Compatibility
12
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
13
22
  def update_unique_session_id!(unique_session_id)
14
- self.unique_session_id = unique_session_id
23
+ raise Devise::Models::Compatibility::NotPersistedError, 'cannot update a new record' unless persisted?
15
24
 
16
- 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
17
28
  end
18
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
19
36
  end
20
37
  end
21
38
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveSupport.on_load(:mongoid) do
4
+ require 'orm_adapter/adapters/mongoid'
5
+
6
+ Mongoid::Document::ClassMethods.include(Devise::Models)
7
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DeviseSecurity::Patches
2
4
  module ControllerCaptcha
3
5
  extend ActiveSupport::Concern
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DeviseSecurity::Patches
2
4
  module ControllerSecurityQuestion
3
5
  extend ActiveSupport::Concern
@@ -7,6 +9,7 @@ module DeviseSecurity::Patches
7
9
  end
8
10
 
9
11
  private
12
+
10
13
  def check_security_question
11
14
  # only find via email, not login
12
15
  resource = resource_class.find_or_initialize_with_error_by(:email, params[resource_name][:email], :not_found)
@@ -17,4 +20,3 @@ module DeviseSecurity::Patches
17
20
  end
18
21
  end
19
22
  end
20
-
@@ -1,21 +1,29 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DeviseSecurity
2
4
  module Patches
3
5
  autoload :ControllerCaptcha, 'devise-security/patches/controller_captcha'
4
6
  autoload :ControllerSecurityQuestion, 'devise-security/patches/controller_security_question'
5
7
 
6
8
  class << self
9
+ # rubocop:disable Metrics/AbcSize
10
+ # rubocop:disable Metrics/CyclomaticComplexity
11
+ # rubocop:disable Metrics/PerceivedComplexity
7
12
  def apply
8
- Devise::PasswordsController.send(:include, Patches::ControllerCaptcha) if Devise.captcha_for_recover || Devise.security_question_for_recover
9
- Devise::UnlocksController.send(:include, Patches::ControllerCaptcha) if Devise.captcha_for_unlock || Devise.security_question_for_unlock
10
- 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
11
16
 
12
- Devise::PasswordsController.send(:include, Patches::ControllerSecurityQuestion) if Devise.security_question_for_recover
13
- Devise::UnlocksController.send(:include, Patches::ControllerSecurityQuestion) if Devise.security_question_for_unlock
14
- 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
15
20
 
16
- Devise::RegistrationsController.send(:include, Patches::ControllerCaptcha) if Devise.captcha_for_sign_up
17
- 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
18
23
  end
24
+ # rubocop:enable Metrics/AbcSize
25
+ # rubocop:enable Metrics/CyclomaticComplexity
26
+ # rubocop:enable Metrics/PerceivedComplexity
19
27
  end
20
28
  end
21
29
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DeviseSecurity
2
4
  class Engine < ::Rails::Engine
3
5
  ActiveSupport.on_load(:action_controller) do
@@ -1,16 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActionDispatch::Routing
2
4
  class Mapper
3
-
4
5
  protected
5
6
 
6
7
  # route for handle expired passwords
7
8
  def devise_password_expired(mapping, controllers)
8
- 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]
9
10
  end
10
11
 
11
12
  # route for handle paranoid verification
12
13
  def devise_verification_code(mapping, controllers)
13
- 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]
14
15
  end
15
16
  end
16
17
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
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
+ #
14
+ # Options:
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
21
+ class DeviseSecurity::PasswordComplexityValidator < ActiveModel::EachValidator
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
36
+
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?
47
+
48
+ options.sort.each do |pattern_name, minimum|
49
+ normalized_option = pattern_name.to_s.singularize.to_sym
50
+
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
61
+ end
62
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DeviseSecurity
2
- VERSION = '0.12.0'.freeze
4
+ VERSION = '0.18.0'
3
5
  end