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.
- checksums.yaml +4 -4
- data/Rakefile +3 -0
- data/app/assets/stylesheets/lesli_shield/application.css +140 -1
- data/app/assets/stylesheets/lesli_shield/confirmations.css +917 -18
- data/app/assets/stylesheets/lesli_shield/confirmations.tailwind.css +918 -0
- data/app/assets/stylesheets/lesli_shield/passwords.css +872 -18
- data/app/assets/stylesheets/lesli_shield/passwords.tailwind.css +873 -0
- data/app/assets/stylesheets/lesli_shield/registrations.css +904 -832
- data/app/assets/stylesheets/lesli_shield/registrations.tailwind.css +908 -0
- data/app/assets/stylesheets/lesli_shield/sessions.css +984 -32
- data/app/assets/stylesheets/lesli_shield/sessions.tailwind.css +983 -1
- data/app/controllers/lesli_shield/profile/passwords_controller.rb +119 -0
- data/app/controllers/lesli_shield/profiles_controller.rb +63 -0
- data/app/controllers/lesli_shield/sessions_controller.rb +1 -2
- data/app/controllers/users/confirmations_controller.rb +162 -49
- data/app/controllers/users/passwords_controller.rb +211 -28
- data/app/controllers/users/registrations_controller.rb +108 -83
- data/app/controllers/users/sessions_controller.rb +122 -84
- data/app/interfaces/lesli_shield/authorization_interface.rb +2 -2
- data/app/mailers/lesli_shield/devise_mailer.rb +73 -17
- data/app/models/concerns/lesli_shield/user_authentication.rb +54 -0
- data/app/models/lesli_shield/account.rb +4 -1
- data/app/models/lesli_shield/invite.rb +1 -1
- data/app/models/lesli_shield/user/role.rb +1 -1
- data/app/models/lesli_shield/user.rb +5 -2
- data/app/services/lesli_shield/profile/password_service.rb +47 -0
- data/app/services/lesli_shield/profile_service.rb +24 -0
- data/app/services/lesli_shield/user_registration_service.rb +41 -74
- data/app/services/lesli_shield/user_session_service.rb +1 -1
- data/app/services/lesli_shield/user_validator_service.rb +30 -32
- data/app/views/devise/confirmations/new.html.erb +58 -12
- data/app/views/devise/confirmations/show.html.erb +27 -60
- data/app/views/devise/passwords/edit.html.erb +59 -25
- data/app/views/devise/passwords/new.html.erb +61 -26
- data/app/views/devise/registrations/new.html.erb +80 -53
- data/app/views/devise/sessions/new.html.erb +53 -36
- data/app/views/devise/shared/_application-devise-simple.erb +18 -19
- data/app/views/devise/shared/_demo.html.erb +8 -6
- data/app/views/devise/shared/_error_messages.html.erb +45 -10
- data/app/views/devise/shared/_links.html.erb +6 -13
- data/app/views/lesli_shield/invites/_form.html.erb +1 -1
- data/app/views/lesli_shield/invites/index.html.erb +3 -2
- data/app/views/lesli_shield/profiles/_password-form.html.erb +68 -0
- data/app/views/lesli_shield/profiles/show.html.erb +29 -0
- data/app/views/lesli_shield/users/_information-form.html.erb +9 -1
- data/config/initializers/devise.rb +52 -328
- data/config/locales/translations.en.yml +65 -2
- data/config/locales/translations.es.yml +65 -2
- data/config/routes.rb +4 -0
- data/lib/lesli_shield/engine.rb +38 -4
- data/lib/lesli_shield/router.rb +52 -18
- data/lib/lesli_shield/version.rb +2 -2
- data/readme.md +4 -2
- metadata +30 -5
- data/app/assets/config/lesli_shield_manifest.js +0 -48
- 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
|
|
@@ -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
|
-
|
|
39
|
-
|
|
93
|
+
resource_class.transaction do
|
|
94
|
+
confirmed_user = resource_class.confirm_by_token(
|
|
95
|
+
params[:confirmation_token]
|
|
96
|
+
)
|
|
40
97
|
|
|
41
|
-
|
|
42
|
-
|
|
98
|
+
if confirmed_user.errors.empty?
|
|
99
|
+
LesliShield::UserRegistrationService
|
|
100
|
+
.new(confirmed_user)
|
|
101
|
+
.provision_account!
|
|
43
102
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
103
|
+
confirmed_user.update!(confirmation_token: nil)
|
|
104
|
+
record_successful_confirmation!(confirmed_user)
|
|
105
|
+
end
|
|
47
106
|
end
|
|
48
107
|
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
if user
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
LesliShield::DeviseMailer.
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
174
|
+
def confirmation_request_params
|
|
175
|
+
params
|
|
176
|
+
.fetch(resource_name, ActionController::Parameters.new)
|
|
177
|
+
.permit(:email)
|
|
76
178
|
end
|
|
77
179
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
+
def update
|
|
80
|
+
password_reset_succeeded = false
|
|
42
81
|
|
|
43
|
-
|
|
82
|
+
super do |user|
|
|
83
|
+
password_reset_succeeded = user.errors.empty?
|
|
44
84
|
|
|
45
|
-
user.
|
|
46
|
-
:
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
54
|
-
|
|
90
|
+
if password_reset_succeeded
|
|
91
|
+
complete_successful_password_reset!(resource)
|
|
55
92
|
else
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|