devise-security 0.12.0 → 0.18.0

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