devise-security 0.12.0 → 0.16.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 (183) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -63
  3. data/app/controllers/devise/paranoid_verification_code_controller.rb +2 -0
  4. data/app/controllers/devise/password_expired_controller.rb +13 -6
  5. data/app/views/devise/paranoid_verification_code/show.html.erb +4 -4
  6. data/app/views/devise/password_expired/show.html.erb +6 -6
  7. data/config/locales/by.yml +49 -0
  8. data/config/locales/cs.yml +41 -0
  9. data/config/locales/de.yml +30 -7
  10. data/config/locales/en.yml +25 -1
  11. data/config/locales/es.yml +19 -6
  12. data/config/locales/fa.yml +41 -0
  13. data/config/locales/fr.yml +30 -0
  14. data/config/locales/hi.yml +42 -0
  15. data/config/locales/it.yml +35 -4
  16. data/config/locales/ja.yml +30 -0
  17. data/config/locales/nl.yml +41 -0
  18. data/config/locales/pt.yml +41 -0
  19. data/config/locales/ru.yml +49 -0
  20. data/config/locales/tr.yml +18 -0
  21. data/config/locales/uk.yml +49 -0
  22. data/config/locales/zh_CN.yml +41 -0
  23. data/config/locales/zh_TW.yml +41 -0
  24. data/lib/devise-security/controllers/helpers.rb +61 -50
  25. data/lib/devise-security/hooks/expirable.rb +3 -1
  26. data/lib/devise-security/hooks/paranoid_verification.rb +2 -0
  27. data/lib/devise-security/hooks/password_expirable.rb +4 -0
  28. data/lib/devise-security/hooks/session_limitable.rb +31 -14
  29. data/lib/devise-security/models/active_record/old_password.rb +5 -0
  30. data/lib/devise-security/models/compatibility/active_record_patch.rb +40 -0
  31. data/lib/devise-security/models/compatibility/mongoid_patch.rb +31 -0
  32. data/lib/devise-security/models/compatibility.rb +8 -15
  33. data/lib/devise-security/models/database_authenticatable_patch.rb +3 -1
  34. data/lib/devise-security/models/expirable.rb +8 -2
  35. data/lib/devise-security/models/mongoid/old_password.rb +21 -0
  36. data/lib/devise-security/models/paranoid_verification.rb +2 -0
  37. data/lib/devise-security/models/password_archivable.rb +18 -7
  38. data/lib/devise-security/models/password_expirable.rb +103 -48
  39. data/lib/devise-security/models/secure_validatable.rb +26 -6
  40. data/lib/devise-security/models/security_questionable.rb +2 -0
  41. data/lib/devise-security/models/session_limitable.rb +19 -2
  42. data/lib/devise-security/orm/mongoid.rb +7 -0
  43. data/lib/devise-security/patches/confirmations_controller_captcha.rb +2 -0
  44. data/lib/devise-security/patches/confirmations_controller_security_question.rb +2 -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/passwords_controller_captcha.rb +2 -0
  48. data/lib/devise-security/patches/passwords_controller_security_question.rb +2 -0
  49. data/lib/devise-security/patches/registrations_controller_captcha.rb +2 -0
  50. data/lib/devise-security/patches/sessions_controller_captcha.rb +2 -0
  51. data/lib/devise-security/patches/unlocks_controller_captcha.rb +2 -0
  52. data/lib/devise-security/patches/unlocks_controller_security_question.rb +2 -0
  53. data/lib/devise-security/patches.rb +2 -0
  54. data/lib/devise-security/rails.rb +2 -0
  55. data/lib/devise-security/routes.rb +2 -0
  56. data/lib/devise-security/validators/password_complexity_validator.rb +35 -0
  57. data/lib/devise-security/version.rb +3 -1
  58. data/lib/devise-security.rb +16 -10
  59. data/lib/generators/devise_security/install_generator.rb +5 -3
  60. data/lib/generators/templates/devise_security.rb +47 -0
  61. data/test/{test_captcha_controller.rb → controllers/test_captcha_controller.rb} +2 -0
  62. data/test/controllers/test_password_expired_controller.rb +110 -0
  63. data/test/controllers/test_security_question_controller.rb +60 -0
  64. data/test/dummy/Rakefile +3 -1
  65. data/test/dummy/app/assets/config/manifest.js +3 -0
  66. data/test/dummy/app/controllers/application_controller.rb +2 -0
  67. data/test/dummy/app/controllers/captcha/sessions_controller.rb +2 -0
  68. data/test/dummy/app/controllers/security_question/unlocks_controller.rb +2 -0
  69. data/test/dummy/app/controllers/widgets_controller.rb +6 -0
  70. data/test/dummy/app/models/application_record.rb +10 -2
  71. data/test/dummy/app/models/application_user_record.rb +11 -0
  72. data/test/dummy/app/models/captcha_user.rb +7 -2
  73. data/test/dummy/app/models/mongoid/confirmable_fields.rb +13 -0
  74. data/test/dummy/app/models/mongoid/database_authenticable_fields.rb +17 -0
  75. data/test/dummy/app/models/mongoid/expirable_fields.rb +11 -0
  76. data/test/dummy/app/models/mongoid/lockable_fields.rb +13 -0
  77. data/test/dummy/app/models/mongoid/mappings.rb +13 -0
  78. data/test/dummy/app/models/mongoid/omniauthable_fields.rb +11 -0
  79. data/test/dummy/app/models/mongoid/paranoid_verification_fields.rb +10 -0
  80. data/test/dummy/app/models/mongoid/password_archivable_fields.rb +9 -0
  81. data/test/dummy/app/models/mongoid/password_expirable_fields.rb +10 -0
  82. data/test/dummy/app/models/mongoid/recoverable_fields.rb +11 -0
  83. data/test/dummy/app/models/mongoid/registerable_fields.rb +19 -0
  84. data/test/dummy/app/models/mongoid/rememberable_fields.rb +10 -0
  85. data/test/dummy/app/models/mongoid/secure_validatable_fields.rb +11 -0
  86. data/test/dummy/app/models/mongoid/security_questionable_fields.rb +13 -0
  87. data/test/dummy/app/models/mongoid/session_limitable_fields.rb +10 -0
  88. data/test/dummy/app/models/mongoid/timeoutable_fields.rb +9 -0
  89. data/test/dummy/app/models/mongoid/trackable_fields.rb +14 -0
  90. data/test/dummy/app/models/mongoid/validatable_fields.rb +7 -0
  91. data/test/dummy/app/models/secure_user.rb +7 -1
  92. data/test/dummy/app/models/security_question_user.rb +9 -4
  93. data/test/dummy/app/models/user.rb +15 -0
  94. data/test/dummy/app/models/widget.rb +6 -0
  95. data/test/dummy/app/mongoid/admin.rb +31 -0
  96. data/test/dummy/app/mongoid/one_user.rb +58 -0
  97. data/test/dummy/app/mongoid/shim.rb +25 -0
  98. data/test/dummy/app/mongoid/user_on_engine.rb +41 -0
  99. data/test/dummy/app/mongoid/user_on_main_app.rb +41 -0
  100. data/test/dummy/app/mongoid/user_with_validations.rb +37 -0
  101. data/test/dummy/app/mongoid/user_without_email.rb +35 -0
  102. data/test/dummy/config/application.rb +13 -7
  103. data/test/dummy/config/boot.rb +2 -0
  104. data/test/dummy/config/environment.rb +2 -0
  105. data/test/dummy/config/environments/test.rb +5 -13
  106. data/test/dummy/config/initializers/devise.rb +10 -3
  107. data/test/dummy/config/initializers/migration_class.rb +3 -6
  108. data/test/dummy/config/mongoid.yml +6 -0
  109. data/test/dummy/config/routes.rb +6 -3
  110. data/test/dummy/config.ru +3 -1
  111. data/test/dummy/db/migrate/20120508165529_create_tables.rb +13 -2
  112. data/test/dummy/db/migrate/20150402165590_add_verification_columns.rb +2 -0
  113. data/test/dummy/db/migrate/20150407162345_add_verification_attempt_column.rb +2 -0
  114. data/test/dummy/db/migrate/20160320162345_add_security_questions_fields.rb +2 -0
  115. data/test/dummy/db/migrate/20180318103603_add_expireable_columns.rb +2 -0
  116. data/test/dummy/db/migrate/20180318105329_add_confirmable_columns.rb +2 -0
  117. data/test/dummy/db/migrate/20180318105732_add_rememberable_columns.rb +2 -0
  118. data/test/dummy/db/migrate/20180318111336_add_recoverable_columns.rb +2 -0
  119. data/test/dummy/db/migrate/20180319114023_add_widget.rb +2 -0
  120. data/test/dummy/lib/shared_expirable_columns.rb +14 -0
  121. data/test/dummy/lib/shared_security_questions_fields.rb +16 -0
  122. data/test/dummy/lib/shared_user.rb +32 -0
  123. data/test/dummy/lib/shared_user_with_password_verification.rb +13 -0
  124. data/test/dummy/lib/shared_user_without_email.rb +28 -0
  125. data/test/dummy/lib/shared_user_without_omniauth.rb +15 -0
  126. data/test/dummy/lib/shared_verification_fields.rb +15 -0
  127. data/test/dummy/log/development.log +883 -0
  128. data/test/dummy/log/test.log +21689 -0
  129. data/test/integration/test_password_expirable_workflow.rb +53 -0
  130. data/test/integration/test_session_limitable_workflow.rb +67 -0
  131. data/test/orm/active_record.rb +15 -0
  132. data/test/orm/mongoid.rb +13 -0
  133. data/test/support/integration_helpers.rb +29 -0
  134. data/test/support/mongoid.yml +6 -0
  135. data/test/test_compatibility.rb +13 -0
  136. data/test/test_complexity_validator.rb +72 -0
  137. data/test/test_helper.rb +42 -9
  138. data/test/test_install_generator.rb +19 -2
  139. data/test/test_paranoid_verification.rb +2 -0
  140. data/test/test_password_archivable.rb +8 -7
  141. data/test/test_password_expirable.rb +70 -7
  142. data/test/test_secure_validatable.rb +97 -21
  143. data/test/test_session_limitable.rb +57 -0
  144. data/{lib/generators/templates → test/tmp/config/initializers}/devise-security.rb +12 -3
  145. data/test/tmp/config/locales/devise.security_extension.by.yml +49 -0
  146. data/test/tmp/config/locales/devise.security_extension.cs.yml +41 -0
  147. data/test/tmp/config/locales/devise.security_extension.de.yml +39 -0
  148. data/test/tmp/config/locales/devise.security_extension.en.yml +41 -0
  149. data/test/tmp/config/locales/devise.security_extension.es.yml +30 -0
  150. data/test/tmp/config/locales/devise.security_extension.fa.yml +41 -0
  151. data/test/tmp/config/locales/devise.security_extension.fr.yml +30 -0
  152. data/test/tmp/config/locales/devise.security_extension.hi.yml +42 -0
  153. data/test/tmp/config/locales/devise.security_extension.it.yml +41 -0
  154. data/test/tmp/config/locales/devise.security_extension.ja.yml +30 -0
  155. data/test/tmp/config/locales/devise.security_extension.nl.yml +41 -0
  156. data/test/tmp/config/locales/devise.security_extension.pt.yml +41 -0
  157. data/test/tmp/config/locales/devise.security_extension.ru.yml +49 -0
  158. data/test/tmp/config/locales/devise.security_extension.tr.yml +18 -0
  159. data/test/tmp/config/locales/devise.security_extension.uk.yml +49 -0
  160. data/test/tmp/config/locales/devise.security_extension.zh_CN.yml +41 -0
  161. data/test/tmp/config/locales/devise.security_extension.zh_TW.yml +41 -0
  162. metadata +235 -110
  163. data/.circleci/config.yml +0 -41
  164. data/.document +0 -5
  165. data/.gitignore +0 -40
  166. data/.rubocop.yml +0 -63
  167. data/.ruby-version +0 -1
  168. data/.travis.yml +0 -25
  169. data/Appraisals +0 -19
  170. data/Gemfile +0 -3
  171. data/Rakefile +0 -28
  172. data/devise-security.gemspec +0 -44
  173. data/gemfiles/rails_4.1_stable.gemfile +0 -8
  174. data/gemfiles/rails_4.2_stable.gemfile +0 -8
  175. data/gemfiles/rails_5.0_stable.gemfile +0 -8
  176. data/gemfiles/rails_5.1_stable.gemfile +0 -8
  177. data/gemfiles/rails_5.2_rc1.gemfile +0 -8
  178. data/lib/devise-security/models/old_password.rb +0 -4
  179. data/lib/devise-security/orm/active_record.rb +0 -18
  180. data/lib/devise-security/schema.rb +0 -64
  181. data/test/dummy/app/models/.gitkeep +0 -0
  182. data/test/test_password_expired_controller.rb +0 -44
  183. data/test/test_security_question_controller.rb +0 -84
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DeviseSecurity
2
4
  module Controllers
3
5
  module Helpers
@@ -38,71 +40,80 @@ module DeviseSecurity
38
40
 
39
41
  # controller instance methods
40
42
 
41
- private
42
-
43
- # lookup if an password change needed
44
- def handle_password_change
45
- return if warden.nil?
46
-
47
- if !devise_controller? && !ignore_password_expire? && !request.format.nil? && request.format.html?
48
- Devise.mappings.keys.flatten.any? do |scope|
49
- if signed_in?(scope) && warden.session(scope)['password_expired']
50
- # re-check to avoid infinite loop if date changed after login attempt
51
- if send(:"current_#{scope}").try(:need_change_password?)
52
- store_location_for(scope, request.original_fullpath) if request.get?
53
- redirect_for_password_change scope
54
- return
55
- else
56
- warden.session(scope)[:password_expired] = false
57
- end
43
+ private
44
+
45
+ # Called as a `before_action` on all actions on any controller that uses
46
+ # this helper. If the user's session is marked as having an expired
47
+ # password we double check in case it has been changed by another process,
48
+ # then redirect to the password change url.
49
+ #
50
+ # @note `Warden::Manager.after_authentication` is run AFTER this method
51
+ #
52
+ # @note Once the warden session has `'password_expired'` set to `false`,
53
+ # it will **never** be checked again until the user re-logs in.
54
+ def handle_password_change
55
+ return if warden.nil?
56
+
57
+ if !devise_controller? &&
58
+ !ignore_password_expire? &&
59
+ !request.format.nil? &&
60
+ request.format.html?
61
+ Devise.mappings.keys.flatten.any? do |scope|
62
+ if signed_in?(scope) && warden.session(scope)['password_expired'] == true
63
+ if send(:"current_#{scope}").try(:need_change_password?)
64
+ store_location_for(scope, request.original_fullpath) if request.get?
65
+ redirect_for_password_change(scope)
66
+ else
67
+ warden.session(scope)['password_expired'] = false
58
68
  end
59
69
  end
60
70
  end
61
71
  end
72
+ end
62
73
 
63
- # lookup if extra (paranoid) code verification is needed
64
- def handle_paranoid_verification
65
- return if warden.nil?
74
+ # lookup if extra (paranoid) code verification is needed
75
+ def handle_paranoid_verification
76
+ return if warden.nil?
66
77
 
67
- if !devise_controller? && !request.format.nil? && request.format.html?
68
- Devise.mappings.keys.flatten.any? do |scope|
69
- if signed_in?(scope) && warden.session(scope)['paranoid_verify']
70
- store_location_for(scope, request.original_fullpath) if request.get?
71
- redirect_for_paranoid_verification scope
72
- return
73
- end
78
+ if !devise_controller? && !request.format.nil? && request.format.html?
79
+ Devise.mappings.keys.flatten.any? do |scope|
80
+ if signed_in?(scope) && warden.session(scope)['paranoid_verify']
81
+ store_location_for(scope, request.original_fullpath) if request.get?
82
+ redirect_for_paranoid_verification scope
83
+ return
74
84
  end
75
85
  end
76
86
  end
87
+ end
77
88
 
78
- # redirect for password update with alert message
79
- def redirect_for_password_change(scope)
80
- redirect_to change_password_required_path_for(scope), alert: I18n.t('change_required', {scope: 'devise.password_expired'})
81
- end
89
+ # redirect for password update with alert message
90
+ def redirect_for_password_change(scope)
91
+ redirect_to change_password_required_path_for(scope), alert: I18n.t('change_required', scope: 'devise.password_expired')
92
+ end
82
93
 
83
- def redirect_for_paranoid_verification(scope)
84
- redirect_to paranoid_verification_code_path_for(scope), alert: I18n.t('code_required', {scope: 'devise.paranoid_verify'})
85
- end
94
+ def redirect_for_paranoid_verification(scope)
95
+ redirect_to paranoid_verification_code_path_for(scope), alert: I18n.t('code_required', scope: 'devise.paranoid_verify')
96
+ end
86
97
 
87
- # path for change password
88
- def change_password_required_path_for(resource_or_scope = nil)
89
- scope = Devise::Mapping.find_scope!(resource_or_scope)
90
- change_path = "#{scope}_password_expired_path"
91
- send(change_path)
92
- end
98
+ # path for change password
99
+ def change_password_required_path_for(resource_or_scope = nil)
100
+ scope = Devise::Mapping.find_scope!(resource_or_scope)
101
+ change_path = "#{scope}_password_expired_path"
102
+ send(change_path)
103
+ end
93
104
 
94
- def paranoid_verification_code_path_for(resource_or_scope = nil)
95
- scope = Devise::Mapping.find_scope!(resource_or_scope)
96
- change_path = "#{scope}_paranoid_verification_code_path"
97
- send(change_path)
98
- end
105
+ def paranoid_verification_code_path_for(resource_or_scope = nil)
106
+ scope = Devise::Mapping.find_scope!(resource_or_scope)
107
+ change_path = "#{scope}_paranoid_verification_code_path"
108
+ send(change_path)
109
+ end
99
110
 
100
- protected
111
+ protected
101
112
 
102
- # allow to overwrite for some special handlings
103
- def ignore_password_expire?
104
- false
105
- end
113
+ # allow to overwrite for some special handlings
114
+ def ignore_password_expire?
115
+ false
116
+ end
106
117
  end
107
118
  end
108
119
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # Updates the last_activity_at fields from the record. Only when the user is active
2
4
  # for authentication and authenticated.
3
5
  # An expiry of the account is only checked on sign in OR on manually setting the
@@ -7,4 +9,4 @@ Warden::Manager.after_set_user do |record, warden, options|
7
9
  warden.authenticated?(options[:scope]) && record.respond_to?(:update_last_activity!)
8
10
  record.update_last_activity!
9
11
  end
10
- end
12
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  Warden::Manager.after_set_user do |record, warden, options|
2
4
  if record.respond_to?(:need_paranoid_verification?)
3
5
  warden.session(options[:scope])['paranoid_verify'] = record.need_paranoid_verification?
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @note This happens after
4
+ # {DeviseSecurity::Controller::Helpers#handle_password_change}
1
5
  Warden::Manager.after_authentication do |record, warden, options|
2
6
  if record.respond_to?(:need_change_password?)
3
7
  warden.session(options[:scope])['password_expired'] = record.need_change_password?
@@ -1,24 +1,41 @@
1
- # After each sign in, update unique_session_id.
2
- # This is only triggered when the user is explicitly set (with set_user)
3
- # and on authentication. Retrieving the user from session (:fetch) does
4
- # not trigger it.
1
+ # frozen_string_literal: true
2
+
3
+ # After each sign in, update unique_session_id. This is only triggered when the
4
+ # user is explicitly set (with set_user) and on authentication. Retrieving the
5
+ # user from session (:fetch) does not trigger it.
5
6
  Warden::Manager.after_set_user except: :fetch do |record, warden, options|
6
- if record.respond_to?(:update_unique_session_id!) && warden.authenticated?(options[:scope])
7
- unique_session_id = Devise.friendly_token
8
- warden.session(options[:scope])['unique_session_id'] = unique_session_id
9
- record.update_unique_session_id!(unique_session_id)
7
+ if record.devise_modules.include?(:session_limitable) &&
8
+ warden.authenticated?(options[:scope]) &&
9
+ !record.skip_session_limitable?
10
+
11
+ if !options[:skip_session_limitable]
12
+ unique_session_id = Devise.friendly_token
13
+ warden.session(options[:scope])['unique_session_id'] = unique_session_id
14
+ record.update_unique_session_id!(unique_session_id)
15
+ else
16
+ warden.session(options[:scope])['devise.skip_session_limitable'] = true
17
+ end
10
18
  end
11
19
  end
12
20
 
13
- # Each time a record is fetched from session we check if a new session from another
14
- # browser was opened for the record or not, based on a unique session identifier.
15
- # If so, the old account is logged out and redirected to the sign in page on the next request.
21
+ # Each time a record is fetched from session we check if a new session from
22
+ # another browser was opened for the record or not, based on a unique session
23
+ # identifier. If so, the old account is logged out and redirected to the sign in
24
+ # page on the next request.
16
25
  Warden::Manager.after_set_user only: :fetch do |record, warden, options|
17
26
  scope = options[:scope]
18
- env = warden.request.env
19
27
 
20
- if record.respond_to?(:unique_session_id) && warden.authenticated?(scope) && options[:store] != false
21
- if record.unique_session_id != warden.session(scope)['unique_session_id'] && !env['devise.skip_session_limitable']
28
+ if record.devise_modules.include?(:session_limitable) &&
29
+ warden.authenticated?(scope) &&
30
+ options[:store] != false
31
+ if record.unique_session_id != warden.session(scope)['unique_session_id'] &&
32
+ !record.skip_session_limitable? &&
33
+ !warden.session(scope)['devise.skip_session_limitable']
34
+ Rails.logger.warn do
35
+ '[devise-security][session_limitable] session id mismatch: '\
36
+ "expected=#{record.unique_session_id.inspect} "\
37
+ "actual=#{warden.session(scope)['unique_session_id'].inspect}"
38
+ end
22
39
  warden.raw_session.clear
23
40
  warden.logout(scope)
24
41
  throw :warden, scope: scope, message: :session_limited
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class OldPassword < ApplicationRecord
4
+ belongs_to :password_archivable, polymorphic: true
5
+ end
@@ -0,0 +1,40 @@
1
+ module Devise
2
+ module Models
3
+ module Compatibility
4
+
5
+ class NotPersistedError < ActiveRecord::ActiveRecordError; end
6
+
7
+ module ActiveRecordPatch
8
+ extend ActiveSupport::Concern
9
+ unless Devise.activerecord51?
10
+ # When the record was saved, was the +encrypted_password+ changed?
11
+ # @return [Boolean]
12
+ def saved_change_to_encrypted_password?
13
+ encrypted_password_changed?
14
+ end
15
+
16
+ # The encrypted password that existed before the record was saved
17
+ # @return [String]
18
+ # @return [nil] if an +encrypted_password+ had not been set
19
+ def encrypted_password_before_last_save
20
+ previous_changes['encrypted_password'].try(:first)
21
+ end
22
+
23
+ # When the record is saved, will the +encrypted_password+ be changed?
24
+ # @return [Boolean]
25
+ def will_save_change_to_encrypted_password?
26
+ changed_attributes['encrypted_password'].present?
27
+ end
28
+ end
29
+
30
+ # Updates the record with the value and does not trigger validations or callbacks
31
+ # @param name [Symbol] attribute to update
32
+ # @param value [String] value to set
33
+ def update_attribute_without_validatons_or_callbacks(name, value)
34
+ update_column(name, value)
35
+ end
36
+
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,31 @@
1
+ module Devise
2
+ module Models
3
+ module Compatibility
4
+
5
+ class NotPersistedError < Mongoid::Errors::MongoidError; end
6
+
7
+ module MongoidPatch
8
+ extend ActiveSupport::Concern
9
+
10
+ # Will saving this record change the +email+ attribute?
11
+ # @return [Boolean]
12
+ def will_save_change_to_email?
13
+ changed.include? 'email'
14
+ end
15
+
16
+ # Will saving this record change the +encrypted_password+ attribute?
17
+ # @return [Boolean]
18
+ def will_save_change_to_encrypted_password?
19
+ changed.include? 'encrypted_password'
20
+ end
21
+
22
+ # Updates the document with the value and does not trigger validations or callbacks
23
+ # @param name [Symbol] attribute to update
24
+ # @param value [String] value to set
25
+ def update_attribute_without_validatons_or_callbacks(name, value)
26
+ set(Hash[*[name, value]])
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,22 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "compatibility/#{DEVISE_ORM}_patch"
4
+
1
5
  module Devise
2
6
  module Models
7
+ # These compatibility modules define methods used by devise-security
8
+ # that may need to be defined or re-defined for compatibility between ORMs
9
+ # and/or older versions of ORMs.
3
10
  module Compatibility
4
11
  extend ActiveSupport::Concern
5
-
6
- # for backwards compatibility with Rails < 5.1.x
7
- unless Devise.activerecord51?
8
- def saved_change_to_encrypted_password?
9
- encrypted_password_changed?
10
- end
11
-
12
- def encrypted_password_before_last_save
13
- previous_changes['encrypted_password'].try(:first)
14
- end
15
-
16
- def will_save_change_to_encrypted_password?
17
- changed_attributes['encrypted_password'].present?
18
- end
19
- end
12
+ include "Devise::Models::Compatibility::#{DEVISE_ORM.to_s.classify}Patch".constantize
20
13
  end
21
14
  end
22
15
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Devise
2
4
  module Models
3
5
  module DatabaseAuthenticatablePatch
@@ -8,7 +10,7 @@ module Devise
8
10
  new_password_confirmation = params[:password_confirmation]
9
11
 
10
12
  result = if valid_password?(current_password) && new_password.present? && new_password_confirmation.present?
11
- update_attributes(params, *options)
13
+ update(params, *options)
12
14
  else
13
15
  self.assign_attributes(params, *options)
14
16
  self.valid?
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'devise-security/hooks/expirable'
2
4
 
3
5
  module Devise
@@ -20,7 +22,11 @@ module Devise
20
22
 
21
23
  # Updates +last_activity_at+, called from a Warden::Manager.after_set_user hook.
22
24
  def update_last_activity!
23
- self.update_column(:last_activity_at, Time.now.utc)
25
+ if respond_to?(:update_column)
26
+ self.update_column(:last_activity_at, Time.now.utc)
27
+ elsif defined? Mongoid
28
+ self.update_attribute(:last_activity_at, Time.now.utc)
29
+ end
24
30
  end
25
31
 
26
32
  # Tells if the account has expired
@@ -101,7 +107,7 @@ module Devise
101
107
  # @example Overwritten version to blank out the object.
102
108
  # def self.delete_all_expired_for(time = 90.days)
103
109
  # expired_for(time).each do |u|
104
- # u.update_attributes first_name: nil, last_name: nil
110
+ # u.update first_name: nil, last_name: nil
105
111
  # end
106
112
  # end
107
113
  def delete_all_expired_for(time)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class OldPassword
4
+ include Mongoid::Document
5
+
6
+ ## Database authenticatable
7
+ field :encrypted_password, type: String
8
+ validates_presence_of :encrypted_password
9
+ field :password_salt, type: String
10
+
11
+ field :password_archivable_type, type: String
12
+ validates_presence_of :password_archivable_type
13
+
14
+ field :password_archivable_id, type: String
15
+ validates_presence_of :password_archivable_id
16
+ index({ password_archivable_type: 1, password_archivable_id: 1 }, name: 'index_password_archivable')
17
+
18
+ include Mongoid::Timestamps
19
+
20
+ belongs_to :password_archivable, polymorphic: true
21
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'devise-security/hooks/paranoid_verification'
2
4
 
3
5
  module Devise
@@ -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
@@ -36,11 +39,15 @@ module Devise
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.order(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.order(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