lesli_shield 1.1.6 → 1.1.7

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +3 -0
  3. data/app/assets/stylesheets/lesli_shield/application.css +140 -1
  4. data/app/assets/stylesheets/lesli_shield/confirmations.css +917 -18
  5. data/app/assets/stylesheets/lesli_shield/confirmations.tailwind.css +918 -0
  6. data/app/assets/stylesheets/lesli_shield/passwords.css +872 -18
  7. data/app/assets/stylesheets/lesli_shield/passwords.tailwind.css +873 -0
  8. data/app/assets/stylesheets/lesli_shield/registrations.css +904 -832
  9. data/app/assets/stylesheets/lesli_shield/registrations.tailwind.css +908 -0
  10. data/app/assets/stylesheets/lesli_shield/sessions.css +984 -32
  11. data/app/assets/stylesheets/lesli_shield/sessions.tailwind.css +983 -1
  12. data/app/controllers/lesli_shield/profile/passwords_controller.rb +119 -0
  13. data/app/controllers/lesli_shield/profiles_controller.rb +63 -0
  14. data/app/controllers/lesli_shield/sessions_controller.rb +1 -2
  15. data/app/controllers/users/confirmations_controller.rb +162 -49
  16. data/app/controllers/users/passwords_controller.rb +211 -28
  17. data/app/controllers/users/registrations_controller.rb +108 -83
  18. data/app/controllers/users/sessions_controller.rb +122 -84
  19. data/app/interfaces/lesli_shield/authorization_interface.rb +2 -2
  20. data/app/mailers/lesli_shield/devise_mailer.rb +73 -17
  21. data/app/models/concerns/lesli_shield/user_authentication.rb +54 -0
  22. data/app/models/lesli_shield/account.rb +4 -1
  23. data/app/models/lesli_shield/invite.rb +1 -1
  24. data/app/models/lesli_shield/user/role.rb +1 -1
  25. data/app/models/lesli_shield/user.rb +5 -2
  26. data/app/services/lesli_shield/profile/password_service.rb +47 -0
  27. data/app/services/lesli_shield/profile_service.rb +24 -0
  28. data/app/services/lesli_shield/user_registration_service.rb +41 -74
  29. data/app/services/lesli_shield/user_session_service.rb +1 -1
  30. data/app/services/lesli_shield/user_validator_service.rb +30 -32
  31. data/app/views/devise/confirmations/new.html.erb +58 -12
  32. data/app/views/devise/confirmations/show.html.erb +27 -60
  33. data/app/views/devise/passwords/edit.html.erb +59 -25
  34. data/app/views/devise/passwords/new.html.erb +61 -26
  35. data/app/views/devise/registrations/new.html.erb +80 -53
  36. data/app/views/devise/sessions/new.html.erb +53 -36
  37. data/app/views/devise/shared/_application-devise-simple.erb +18 -19
  38. data/app/views/devise/shared/_demo.html.erb +8 -6
  39. data/app/views/devise/shared/_error_messages.html.erb +45 -10
  40. data/app/views/devise/shared/_links.html.erb +6 -13
  41. data/app/views/lesli_shield/invites/_form.html.erb +1 -1
  42. data/app/views/lesli_shield/invites/index.html.erb +3 -2
  43. data/app/views/lesli_shield/profiles/_password-form.html.erb +68 -0
  44. data/app/views/lesli_shield/profiles/show.html.erb +29 -0
  45. data/app/views/lesli_shield/users/_information-form.html.erb +9 -1
  46. data/config/initializers/devise.rb +52 -328
  47. data/config/locales/translations.en.yml +65 -2
  48. data/config/locales/translations.es.yml +65 -2
  49. data/config/routes.rb +4 -0
  50. data/lib/lesli_shield/engine.rb +38 -4
  51. data/lib/lesli_shield/router.rb +52 -18
  52. data/lib/lesli_shield/version.rb +2 -2
  53. data/readme.md +4 -2
  54. metadata +30 -5
  55. data/app/assets/config/lesli_shield_manifest.js +0 -48
  56. data/app/assets/stylesheets/lesli_shield/application.tailwind.css +0 -2
@@ -0,0 +1,119 @@
1
+ module LesliShield
2
+ module Profile
3
+ class PasswordsController < ApplicationController
4
+ PASSWORD_CHANGE_RATE_LIMIT_WINDOW = 15.minutes
5
+
6
+ skip_before_action :authorize_request
7
+
8
+ rate_limit(
9
+ to: 10,
10
+ within: PASSWORD_CHANGE_RATE_LIMIT_WINDOW,
11
+ by: -> { current_user&.id || request.remote_ip },
12
+ with: :respond_to_password_change_rate_limit,
13
+ only: :update,
14
+ name: "profile-password-update"
15
+ )
16
+
17
+ def update
18
+ @active_profile_tab = "security"
19
+ @password = PasswordService
20
+ .new(current_user)
21
+ .update(
22
+ password_params,
23
+ current_session_id: session[:user_session_id]
24
+ )
25
+ @user = @password.result
26
+
27
+ if @password.successful?
28
+ bypass_sign_in(@user)
29
+ record_password_event(
30
+ operation: "password_change_succeeded",
31
+ description: "Password changed successfully"
32
+ )
33
+
34
+ respond_with_lesli(
35
+ turbo: [
36
+ stream_notification_success(success_message),
37
+ stream_redirection(profile_path(tab: "security"))
38
+ ],
39
+ json: { message: success_message },
40
+ redirect: profile_path(tab: "security")
41
+ )
42
+ else
43
+ record_password_event(
44
+ operation: "password_change_failed",
45
+ description: "Password change failed validation"
46
+ )
47
+
48
+ respond_with_lesli(
49
+ turbo: [
50
+ stream_notification_danger(@password.errors_as_sentence),
51
+ turbo_stream.replace(
52
+ "shield-profile-password-form",
53
+ partial: "lesli_shield/profiles/password-form",
54
+ locals: { user: @user }
55
+ )
56
+ ],
57
+ json: { errors: @password.errors },
58
+ html: "lesli_shield/profiles/show",
59
+ status: :unprocessable_content
60
+ )
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def password_params
67
+ params.require(:user).permit(
68
+ :current_password,
69
+ :password,
70
+ :password_confirmation
71
+ )
72
+ end
73
+
74
+ def success_message
75
+ I18n.t("lesli_shield.profiles.password_updated")
76
+ end
77
+
78
+ def record_password_event(operation:, description:)
79
+ log(operation: operation, description: description)
80
+ rescue StandardError => exception
81
+ Rails.error.report(
82
+ exception,
83
+ handled: true,
84
+ context: {
85
+ controller: self.class.name,
86
+ operation: operation
87
+ }
88
+ )
89
+ end
90
+
91
+ def respond_to_password_change_rate_limit
92
+ @active_profile_tab = "security"
93
+ @user = current_user
94
+ message = I18n.t(
95
+ "lesli_shield.profiles.password_too_many_attempts"
96
+ )
97
+
98
+ response.set_header(
99
+ "Retry-After",
100
+ PASSWORD_CHANGE_RATE_LIMIT_WINDOW.to_i.to_s
101
+ )
102
+
103
+ respond_with_lesli(
104
+ turbo: [
105
+ stream_notification_danger(message),
106
+ turbo_stream.replace(
107
+ "shield-profile-password-form",
108
+ partial: "lesli_shield/profiles/password-form",
109
+ locals: { user: @user }
110
+ )
111
+ ],
112
+ json: { error: message },
113
+ html: "lesli_shield/profiles/show",
114
+ status: :too_many_requests
115
+ )
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,63 @@
1
+ module LesliShield
2
+ class ProfilesController < ApplicationController
3
+ PROFILE_FIELDS = %i[
4
+ id
5
+ uid
6
+ alias
7
+ title
8
+ email
9
+ first_name
10
+ last_name
11
+ telephone
12
+ ].freeze
13
+
14
+ skip_before_action :authorize_request
15
+ before_action :set_profile
16
+
17
+ def show
18
+ respond_with_lesli(json: profile_payload)
19
+ end
20
+
21
+ def update
22
+ @profile.update(profile_params)
23
+ @user = @profile.result
24
+
25
+ if @profile.successful?
26
+ respond_with_lesli(
27
+ turbo: stream_notification_success("Profile updated successfully!"),
28
+ json: profile_payload,
29
+ redirect: profile_path
30
+ )
31
+ else
32
+ respond_with_lesli(
33
+ turbo: stream_notification_danger(@profile.errors_as_sentence),
34
+ json: { errors: @profile.errors },
35
+ html: :show,
36
+ status: :unprocessable_content
37
+ )
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def set_profile
44
+ @profile = LesliShield::ProfileService.new(current_user)
45
+ @user = @profile.show
46
+ end
47
+
48
+ def profile_params
49
+ params.require(:user).permit(
50
+ :alias,
51
+ :title,
52
+ :email,
53
+ :first_name,
54
+ :last_name,
55
+ :telephone
56
+ )
57
+ end
58
+
59
+ def profile_payload
60
+ @user.as_json(only: PROFILE_FIELDS)
61
+ end
62
+ end
63
+ end
@@ -6,8 +6,7 @@ module LesliShield
6
6
  def index
7
7
  @sessions = respond_with_pagination(UserSessionService.new(current_user, query).index())
8
8
  respond_with_lesli(
9
- :html => @sessions,
10
- :json => @sessions
9
+ json: @sessions
11
10
  )
12
11
  end
13
12
 
@@ -29,73 +29,186 @@ Building a better future, one line of code at a time.
29
29
  @license GPLv3 http://www.gnu.org/licenses/gpl-3.0.en.html
30
30
 
31
31
  // · ~·~ ~·~ ~·~ ~·~ ~·~ ~·~ ~·~ ~·~ ~·~
32
- // ·
32
+ // ·
33
33
  =end
34
- class Users::ConfirmationsController < Devise::ConfirmationsController
35
34
 
35
+ class Users::ConfirmationsController < Devise::ConfirmationsController
36
+ CONFIRMATION_RATE_LIMIT_WINDOW = 1.hour
37
+
38
+ rate_limit(
39
+ to: 10,
40
+ within: CONFIRMATION_RATE_LIMIT_WINDOW,
41
+ by: -> { request.remote_ip },
42
+ with: :respond_to_confirmation_rate_limit,
43
+ only: :create,
44
+ name: "confirmation-request-ip"
45
+ )
46
+
47
+ rate_limit(
48
+ to: 3,
49
+ within: CONFIRMATION_RATE_LIMIT_WINDOW,
50
+ by: :confirmation_email_rate_limit_key,
51
+ with: :respond_to_confirmation_rate_limit,
52
+ only: :create,
53
+ name: "confirmation-request-email"
54
+ )
55
+
56
+ before_action :protect_confirmation_token, only: :show
57
+
58
+ # Devise validates and consumes the raw token. Account provisioning and the
59
+ # successful audit event share the same transaction so a partial account can
60
+ # never be left behind and a provisioning failure leaves the token reusable.
36
61
  def show
62
+ self.resource = confirm_user_and_provision_account
63
+
64
+ if resource.errors.empty?
65
+ send_welcome_email_safely(resource)
66
+ set_flash_message!(:notice, :confirmed)
67
+ respond_with_navigational(resource) do
68
+ redirect_to after_confirmation_path_for(resource_name, resource)
69
+ end
70
+ else
71
+ respond_with_navigational(
72
+ resource.errors,
73
+ status: :unprocessable_content
74
+ ) do
75
+ render :new, status: :unprocessable_content
76
+ end
77
+ end
78
+ end
79
+
80
+ protected
81
+
82
+ # Keep the user on the resend page so Devise's generic paranoid-mode notice
83
+ # is visible without disclosing whether the email exists or is confirmed.
84
+ def after_resending_confirmation_instructions_path_for(_resource_name)
85
+ new_user_confirmation_path
86
+ end
87
+
88
+ private
89
+
90
+ def confirm_user_and_provision_account
91
+ confirmed_user = nil
37
92
 
38
- # delete all previus messages
39
- flash.clear
93
+ resource_class.transaction do
94
+ confirmed_user = resource_class.confirm_by_token(
95
+ params[:confirmation_token]
96
+ )
40
97
 
41
- # get the confirmation token sent through get params
42
- token = params[:confirmation_token]
98
+ if confirmed_user.errors.empty?
99
+ LesliShield::UserRegistrationService
100
+ .new(confirmed_user)
101
+ .provision_account!
43
102
 
44
- # validate that token were sent
45
- if token.blank?
46
- return flash[:danger] = I18n.t("core.users/confirmations.messages_warning_invalid_token")
103
+ confirmed_user.update!(confirmation_token: nil)
104
+ record_successful_confirmation!(confirmed_user)
105
+ end
47
106
  end
48
107
 
49
- # check if token belongs to a unconfirmed user
50
- user = Lesli::User.find_by(:confirmation_token => token, :confirmed_at => nil)
108
+ confirmed_user
109
+ rescue StandardError => exception
110
+ Rails.error.report(
111
+ exception,
112
+ handled: true,
113
+ context: {
114
+ controller: self.class.name,
115
+ operation: "user_confirmation_succeeded"
116
+ }
117
+ )
118
+
119
+ confirmation_failure_resource(confirmed_user)
120
+ end
51
121
 
52
- # validate that user were found
53
- if user.blank?
54
- return flash[:danger] = I18n.t("core.users/confirmations.messages_warning_invalid_token")
122
+ def confirmation_failure_resource(user)
123
+ failed_resource = if user&.persisted?
124
+ resource_class.find_by(id: user.id)
55
125
  end
56
126
 
57
- # register a log with a validation atempt for the user
58
- log = user.log(engine: LesliShield, source: self.class.name, action: action_name, operation: "user_confirmation", description: "Confirmation process started")
59
-
60
- # create a new instance of the registration service
61
- registration_service = LesliShield::UserRegistrationService.new(user)
127
+ failed_resource ||= resource_class.new
128
+ failed_resource.errors.add(
129
+ :base,
130
+ I18n.t(
131
+ "lesli_shield.devise/confirmations.message_confirmation_failed"
132
+ )
133
+ )
134
+ failed_resource
135
+ end
62
136
 
63
- # confirm the user
64
- registration_service.confirm
137
+ def record_successful_confirmation!(user)
138
+ user.log(
139
+ engine: LesliShield,
140
+ source: self.class.name,
141
+ action: action_name,
142
+ operation: "user_confirmation_succeeded",
143
+ description: "User confirmed successfully"
144
+ )
145
+ end
65
146
 
66
- # send a welcome email to user as is confirmed
67
- LesliShield::DeviseMailer.with(user: resource).welcome.deliver_later
147
+ def send_welcome_email_safely(user)
148
+ LesliShield::DeviseMailer.welcome(user).deliver_later
149
+ rescue StandardError => exception
150
+ Rails.error.report(
151
+ exception,
152
+ handled: true,
153
+ context: {
154
+ controller: self.class.name,
155
+ operation: "welcome_email_delivery"
156
+ }
157
+ )
158
+ end
68
159
 
69
- # let the user knows that the confirmation is done
70
- flash[:success] = I18n.t("core.users/confirmations.messages_success_email_updated")
71
-
72
- # setup the new account
73
- registration_service.create_account if user.account.blank?
160
+ def confirmation_email_rate_limit_key
161
+ normalized_email = confirmation_request_params[:email]
162
+ .to_s
163
+ .strip
164
+ .downcase
165
+ identity = normalized_email.presence || "blank:#{request.remote_ip}"
166
+
167
+ OpenSSL::HMAC.hexdigest(
168
+ "SHA256",
169
+ Rails.application.secret_key_base,
170
+ identity
171
+ )
172
+ end
74
173
 
75
- log.update(description: 'User confirmed successfully') if defined?(LesliAudit)
174
+ def confirmation_request_params
175
+ params
176
+ .fetch(resource_name, ActionController::Parameters.new)
177
+ .permit(:email)
76
178
  end
77
179
 
78
-
79
- # @controller_action_param :email [String] The registered user email
80
- # @return [Json] Json that contains wheter the email confirmation was sent or not.
81
- # If it is not successful, it returs an error message
82
- # @description Resends a email confirmation an already registered user
83
- # @example
84
- # # Executing this controller's action from javascript's frontend
85
- # let email = 'john.doe@email.com';
86
- # let data = {
87
- # user: {
88
- # email: email
89
- # }
90
- # };
91
- # this.http.post('127.0.0.1/conformation', data);
92
- def create
93
- super do |resource|
94
- if successfully_sent?(resource)
95
- return respond_with_successful
96
- else
97
- return respond_with_error(resource.errors.full_messages.to_sentence)
180
+ def respond_to_confirmation_rate_limit
181
+ message = I18n.t(
182
+ "lesli_shield.devise/confirmations.message_too_many_attempts"
183
+ )
184
+
185
+ response.set_header(
186
+ "Retry-After",
187
+ CONFIRMATION_RATE_LIMIT_WINDOW.to_i.to_s
188
+ )
189
+
190
+ respond_to do |format|
191
+ format.html { render_confirmation_rate_limit(message) }
192
+ format.json do
193
+ render json: { error: message }, status: :too_many_requests
98
194
  end
195
+ format.any { head :too_many_requests }
99
196
  end
100
197
  end
198
+
199
+ def render_confirmation_rate_limit(message)
200
+ flash.now[:alert] = message
201
+ self.resource = resource_class.new(
202
+ email: confirmation_request_params[:email]
203
+ )
204
+ render :new, status: :too_many_requests
205
+ end
206
+
207
+ def protect_confirmation_token
208
+ # Prevent the token in the query string from being sent to third-party
209
+ # origins while keeping a valid Origin for the resend form rendered when
210
+ # Devise rejects an invalid or expired token.
211
+ response.set_header("Referrer-Policy", "same-origin")
212
+ response.set_header("Cache-Control", "no-store")
213
+ end
101
214
  end
@@ -34,49 +34,232 @@ Building a better future, one line of code at a time.
34
34
 
35
35
  class Users::PasswordsController < Devise::PasswordsController
36
36
 
37
+ PASSWORD_RESET_RATE_LIMIT_WINDOW = 15.minutes
38
+
39
+ rate_limit(
40
+ to: 20,
41
+ within: PASSWORD_RESET_RATE_LIMIT_WINDOW,
42
+ by: -> { request.remote_ip },
43
+ with: :respond_to_password_reset_rate_limit,
44
+ only: :create,
45
+ name: "password-reset-request-ip"
46
+ )
47
+
48
+ rate_limit(
49
+ to: 5,
50
+ within: PASSWORD_RESET_RATE_LIMIT_WINDOW,
51
+ by: :password_reset_email_rate_limit_key,
52
+ with: :respond_to_password_reset_rate_limit,
53
+ only: :create,
54
+ name: "password-reset-request-email"
55
+ )
56
+
57
+ rate_limit(
58
+ to: 20,
59
+ within: PASSWORD_RESET_RATE_LIMIT_WINDOW,
60
+ by: -> { request.remote_ip },
61
+ with: :respond_to_password_reset_rate_limit,
62
+ only: :update,
63
+ name: "password-reset-update-ip"
64
+ )
65
+
66
+ before_action :protect_password_reset_token, only: %i[edit update]
67
+
37
68
  # Sends an email with a token, so the user can reset their password
38
69
  def create
39
- self.resource = resource_class.send_reset_password_instructions(resource_params)
70
+ super do |user|
71
+ record_activity_safely(
72
+ user,
73
+ operation: "password_reset_requested",
74
+ description: "Password reset requested"
75
+ )
76
+ end
77
+ end
40
78
 
41
- user = self.resource
79
+ def update
80
+ password_reset_succeeded = false
42
81
 
43
- if successfully_sent?(resource)
82
+ super do |user|
83
+ password_reset_succeeded = user.errors.empty?
44
84
 
45
- user.log(
46
- :engine => LesliShield,
47
- :source => self.class.name,
48
- :action => action_name,
49
- :operation => 'password_reset',
50
- :description => 'Reset password instructions sent'
51
- )
85
+ if password_reset_succeeded && user.has_expired_password?
86
+ user.update!(password_expiration_at: nil)
87
+ end
88
+ end
52
89
 
53
- success(I18n.t("core.users/passwords.messages_success"))
54
- redirect_to(new_user_password_path)
90
+ if password_reset_succeeded
91
+ complete_successful_password_reset!(resource)
55
92
  else
56
- #respond_with(resource)
57
- danger("Error sending reset password instructions")
58
- redirect_to(new_user_password_path)
93
+ record_activity_safely(
94
+ resource,
95
+ operation: "password_reset_failed",
96
+ description: "Password reset failed validation"
97
+ )
59
98
  end
60
99
  end
61
100
 
62
- def update
63
- super do |user|
101
+ protected
102
+
103
+ # Preserve the current UX: after requesting a reset, show the same page with
104
+ # Devise's generic paranoid-mode confirmation instead of revealing whether
105
+ # the submitted email belongs to an account.
106
+ def after_sending_reset_password_instructions_path_for(_resource_name)
107
+ new_user_password_path
108
+ end
109
+
110
+ # Devise resets the password even when the account cannot authenticate. Only
111
+ # create a web session when the user also passes LesliShield's account, role,
112
+ # confirmation, and lock checks.
113
+ def sign_in_after_reset_password?
114
+ super && resource.active_for_authentication?
115
+ end
116
+
117
+ private
64
118
 
65
- logs = user.log(engine: LesliShield, source: self.class.name, action: action_name, operation: 'password_update', description:"Password update attempt")
119
+ def complete_successful_password_reset!(user)
120
+ unless user_signed_in?
121
+ record_activity_safely(
122
+ user,
123
+ operation: "password_reset_succeeded",
124
+ description: "Password reset successful"
125
+ )
126
+ return
127
+ end
66
128
 
67
- # check if password update was ok
68
- if user.errors.empty?
129
+ session[:user_session_id] = create_application_session_and_audit!(user)
130
+ record_successful_password_reset_analytics
131
+ rescue StandardError
132
+ rollback_web_session
133
+ raise
134
+ end
69
135
 
70
- # reset password expiration due the user just updated his password
71
- if user.has_expired_password?
72
- user.update(password_expiration_at: nil)
73
- end
136
+ # The password has already been committed by Devise at this point. This
137
+ # transaction makes the application session and its single audit event
138
+ # atomic without wrapping Devise's sign-in or response handling.
139
+ def create_application_session_and_audit!(user)
140
+ resource_class.transaction do
141
+ application_session = LesliShield::UserSessionService
142
+ .new(user)
143
+ .create(
144
+ request.remote_ip,
145
+ detected_user_agent
146
+ )
147
+ .result
74
148
 
75
- logs&.update(description: "Password update successful")
76
- else
77
- danger(user.errors.full_messages.to_sentence)
78
- logs&.update(description: resource.errors.full_messages.to_sentence)
149
+ record_activity(
150
+ user,
151
+ operation: "password_reset_succeeded",
152
+ description: "Password reset successful",
153
+ session_id: application_session.id
154
+ )
155
+
156
+ application_session.id
157
+ end
158
+ end
159
+
160
+ def rollback_web_session
161
+ session.delete(:user_session_id)
162
+ sign_out(resource_name)
163
+ end
164
+
165
+ def record_successful_password_reset_analytics
166
+ log_requests if respond_to?(:log_requests)
167
+ log_devices if respond_to?(:log_devices)
168
+ end
169
+
170
+ def detected_user_agent
171
+ get_user_agent if respond_to?(:get_user_agent)
172
+ end
173
+
174
+ def password_reset_email_rate_limit_key
175
+ normalized_email = password_reset_request_params[:email].to_s.strip.downcase
176
+ identity = normalized_email.presence || "blank:#{request.remote_ip}"
177
+
178
+ OpenSSL::HMAC.hexdigest(
179
+ "SHA256",
180
+ Rails.application.secret_key_base,
181
+ identity
182
+ )
183
+ end
184
+
185
+ def password_reset_request_params
186
+ params.fetch(resource_name, {}).permit(:email)
187
+ end
188
+
189
+ def respond_to_password_reset_rate_limit
190
+ message = I18n.t(
191
+ "lesli_shield.devise/passwords.message_too_many_attempts"
192
+ )
193
+
194
+ response.set_header(
195
+ "Retry-After",
196
+ PASSWORD_RESET_RATE_LIMIT_WINDOW.to_i.to_s
197
+ )
198
+
199
+ respond_to do |format|
200
+ format.html { render_password_reset_rate_limit(message) }
201
+ format.json do
202
+ render json: { error: message }, status: :too_many_requests
79
203
  end
204
+ format.any { head :too_many_requests }
80
205
  end
81
206
  end
207
+
208
+ def render_password_reset_rate_limit(message)
209
+ flash.now[:alert] = message
210
+
211
+ if action_name == "update"
212
+ self.resource = resource_class.new(
213
+ reset_password_token: resource_params[:reset_password_token]
214
+ )
215
+ set_minimum_password_length
216
+ render :edit, status: :too_many_requests
217
+ else
218
+ self.resource = resource_class.new(
219
+ email: password_reset_request_params[:email]
220
+ )
221
+ render :new, status: :too_many_requests
222
+ end
223
+ end
224
+
225
+ def protect_password_reset_token
226
+ # `no-referrer` makes some browsers submit the form with `Origin: null`,
227
+ # which Rails correctly rejects when origin-based CSRF protection is
228
+ # enabled. `same-origin` keeps the reset token away from third-party
229
+ # origins while preserving a verifiable origin for the form submission.
230
+ response.set_header("Referrer-Policy", "same-origin")
231
+ response.set_header("Cache-Control", "no-store")
232
+ end
233
+
234
+ def record_activity_safely(user, operation:, description:)
235
+ record_activity(
236
+ user,
237
+ operation: operation,
238
+ description: description
239
+ )
240
+ rescue StandardError => exception
241
+ Rails.error.report(
242
+ exception,
243
+ handled: true,
244
+ context: {
245
+ controller: self.class.name,
246
+ operation: operation
247
+ }
248
+ )
249
+ end
250
+
251
+ # Invalid or unknown reset tokens can produce a resource without a tenant.
252
+ # Those attempts cannot be written to tenant-scoped Audit tables.
253
+ def record_activity(user, operation:, description:, session_id: nil)
254
+ return unless user&.account
255
+
256
+ user.log(
257
+ engine: LesliShield,
258
+ source: self.class.name,
259
+ action: action_name,
260
+ operation: operation,
261
+ description: description,
262
+ session_id: session_id
263
+ )
264
+ end
82
265
  end