lesli_shield 1.1.5 → 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 (59) 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 -770
  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/lesli_shield/users_controller.rb +3 -7
  16. data/app/controllers/users/confirmations_controller.rb +162 -49
  17. data/app/controllers/users/passwords_controller.rb +211 -28
  18. data/app/controllers/users/registrations_controller.rb +108 -83
  19. data/app/controllers/users/sessions_controller.rb +122 -84
  20. data/app/interfaces/lesli_shield/authentication_interface.rb +3 -3
  21. data/app/interfaces/lesli_shield/authorization_interface.rb +2 -2
  22. data/app/mailers/lesli_shield/devise_mailer.rb +73 -17
  23. data/app/models/concerns/lesli_shield/user_authentication.rb +54 -0
  24. data/app/models/lesli_shield/account.rb +4 -1
  25. data/app/models/lesli_shield/invite.rb +1 -1
  26. data/app/models/lesli_shield/user/role.rb +1 -1
  27. data/app/models/lesli_shield/user.rb +5 -2
  28. data/app/services/lesli_shield/profile/password_service.rb +47 -0
  29. data/app/services/lesli_shield/profile_service.rb +24 -0
  30. data/app/services/lesli_shield/user_registration_service.rb +41 -74
  31. data/app/services/lesli_shield/user_session_service.rb +1 -1
  32. data/app/services/lesli_shield/user_validator_service.rb +35 -39
  33. data/app/views/devise/confirmations/new.html.erb +58 -12
  34. data/app/views/devise/confirmations/show.html.erb +27 -60
  35. data/app/views/devise/passwords/edit.html.erb +59 -25
  36. data/app/views/devise/passwords/new.html.erb +61 -26
  37. data/app/views/devise/registrations/new.html.erb +80 -53
  38. data/app/views/devise/sessions/new.html.erb +53 -36
  39. data/app/views/devise/shared/_application-devise-simple.erb +18 -19
  40. data/app/views/devise/shared/_demo.html.erb +8 -6
  41. data/app/views/devise/shared/_error_messages.html.erb +45 -10
  42. data/app/views/devise/shared/_links.html.erb +6 -13
  43. data/app/views/lesli_shield/invites/_form.html.erb +1 -1
  44. data/app/views/lesli_shield/invites/index.html.erb +3 -2
  45. data/app/views/lesli_shield/profiles/_password-form.html.erb +68 -0
  46. data/app/views/lesli_shield/profiles/show.html.erb +29 -0
  47. data/app/views/lesli_shield/users/_information-card.html.erb +35 -45
  48. data/app/views/lesli_shield/users/_information-form.html.erb +9 -1
  49. data/config/initializers/devise.rb +52 -328
  50. data/config/locales/translations.en.yml +65 -2
  51. data/config/locales/translations.es.yml +65 -2
  52. data/config/routes.rb +4 -0
  53. data/lib/lesli_shield/engine.rb +44 -4
  54. data/lib/lesli_shield/router.rb +52 -18
  55. data/lib/lesli_shield/version.rb +2 -2
  56. data/readme.md +6 -4
  57. metadata +30 -5
  58. data/app/assets/config/lesli_shield_manifest.js +0 -48
  59. data/app/assets/stylesheets/lesli_shield/application.tailwind.css +0 -0
@@ -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
@@ -4,7 +4,7 @@
4
4
 
5
5
  Lesli
6
6
 
7
- Copyright (c) 2025, Lesli Technologies, S. A.
7
+ Copyright (c) 2026, Lesli Technologies, S. A.
8
8
 
9
9
  This program is free software: you can redistribute it and/or modify
10
10
  it under the terms of the GNU General Public License as published by
@@ -33,98 +33,123 @@ Building a better future, one line of code at a time.
33
33
  =end
34
34
 
35
35
  class Users::RegistrationsController < Devise::RegistrationsController
36
- before_action :configure_sign_up_params, only: [:create]
37
- # before_action :configure_account_update_params, only: [:update]
36
+ REGISTRATION_RATE_LIMIT_WINDOW = 1.hour
37
+
38
+ before_action :ensure_registration_allowed!, only: %i[new create]
39
+
40
+ rate_limit(
41
+ to: 10,
42
+ within: REGISTRATION_RATE_LIMIT_WINDOW,
43
+ by: -> { request.remote_ip },
44
+ with: :respond_to_registration_rate_limit,
45
+ only: :create,
46
+ name: "registration-ip"
47
+ )
48
+
49
+ rate_limit(
50
+ to: 3,
51
+ within: REGISTRATION_RATE_LIMIT_WINDOW,
52
+ by: :registration_email_rate_limit_key,
53
+ with: :respond_to_registration_rate_limit,
54
+ only: :create,
55
+ name: "registration-email"
56
+ )
57
+
58
+ # Devise owns user construction, validation, persistence, confirmation
59
+ # delivery, password cleanup, and the active/inactive response flow.
60
+ def create
61
+ super do |user|
62
+ record_user_creation_safely(user)
63
+ end
64
+ end
38
65
 
39
- # GET /resource/sign_up
40
- # def new
41
- # super
42
- # end
66
+ protected
43
67
 
44
- def create
45
- begin
68
+ # Standalone registrations remain inactive until confirmation creates the
69
+ # account and role. Preserve the existing UX by returning to the form with
70
+ # Devise's success notice instructing the user to check their email.
71
+ def after_inactive_sign_up_path_for(_user)
72
+ new_user_registration_path
73
+ end
46
74
 
47
- # Check if instance allow multi-account
48
- if !Lesli.config.security.dig(:allow_registration)
49
- raise(I18n.t("core.users/registrations.messages_error_registration_not_allowed"))
50
- end
75
+ private
51
76
 
52
- # build new user
53
- user = build_resource(sign_up_params)
77
+ def ensure_registration_allowed!
78
+ return if Lesli.config.security.dig(:allow_registration)
54
79
 
55
- # persist new user
56
- if user.save
57
- user.log(engine: LesliShield, source: self.class.name, action: action_name, operation: 'user_creation', description: 'User creation successfully')
58
- success("Account created, check your email")
59
- else
60
- raise(user.errors.full_messages.to_sentence)
61
- end
62
- rescue => exception
63
- #Honeybadger.notify(exception)
64
- danger(exception.message)
65
- end
66
- redirect_to(new_user_registration_path)
80
+ redirect_to(
81
+ new_user_session_path,
82
+ alert: I18n.t(
83
+ "lesli_shield.devise/registrations.message_registration_not_allowed"
84
+ ),
85
+ status: request.post? ? :see_other : :found
86
+ )
67
87
  end
68
88
 
69
- # GET /resource/edit
70
- # def edit
71
- # super
72
- # end
73
-
74
- # PUT /resource
75
- # def update
76
- # super
77
- # end
78
-
79
- # DELETE /resource
80
- # def destroy
81
- # super
82
- # end
83
-
84
- # GET /resource/cancel
85
- # Forces the session data which is usually expired after sign
86
- # in to be expired now. This is useful if the user wants to
87
- # cancel oauth signing in/up in the middle of the process,
88
- # removing all OAuth session data.
89
- # def cancel
90
- # super
91
- # end
89
+ def registration_email_rate_limit_key
90
+ normalized_email = registration_params[:email].to_s.strip.downcase
91
+ identity = normalized_email.presence || "blank:#{request.remote_ip}"
92
92
 
93
- protected
93
+ OpenSSL::HMAC.hexdigest(
94
+ "SHA256",
95
+ Rails.application.secret_key_base,
96
+ identity
97
+ )
98
+ end
99
+
100
+ def registration_params
101
+ params
102
+ .fetch(resource_name, ActionController::Parameters.new)
103
+ .permit(:email)
104
+ end
105
+
106
+ def respond_to_registration_rate_limit
107
+ message = I18n.t(
108
+ "lesli_shield.devise/registrations.message_too_many_attempts"
109
+ )
110
+
111
+ response.set_header(
112
+ "Retry-After",
113
+ REGISTRATION_RATE_LIMIT_WINDOW.to_i.to_s
114
+ )
115
+
116
+ respond_to do |format|
117
+ format.html { render_registration_rate_limit(message) }
118
+ format.json do
119
+ render json: { error: message }, status: :too_many_requests
120
+ end
121
+ format.any { head :too_many_requests }
122
+ end
123
+ end
94
124
 
95
- # If you have extra params to permit, append them to the sanitizer.
96
- # def configure_account_update_params
97
- # devise_parameter_sanitizer.permit(:account_update, keys: [:attribute])
98
- # end
99
-
100
- # If you have extra params to permit, append them to the sanitizer.
101
- def configure_sign_up_params
102
- devise_parameter_sanitizer.permit(:sign_up, keys: [
103
- :email,
104
- :password,
105
- :password_confirmation
106
- ])
125
+ def render_registration_rate_limit(message)
126
+ flash.now[:alert] = message
127
+ self.resource = resource_class.new(email: registration_params[:email])
128
+ set_minimum_password_length
129
+ render :new, status: :too_many_requests
107
130
  end
108
131
 
109
- # The path used after sign up.
110
- # def after_sign_up_path_for(resource)
111
- # super(resource)
112
- # end
113
-
114
- # The path used after sign up for inactive accounts.
115
- # def after_inactive_sign_up_path_for(resource)
116
- # super(resource)
117
- # end
118
-
119
- # def sign_up_params
120
- # params.permit(:sign_up, keys: [
121
- # :email,
122
- # :password,
123
- # :password_confirmation
124
- # ])
125
- # end
126
-
127
- def sign_up_params
128
- params.fetch(:user, {}).permit(:email, :password, :password_confirmation)
132
+ # A standalone signup has no tenant yet, so its durable audit event belongs
133
+ # to the later confirmation transaction. Keep support for pre-associated
134
+ # resources without allowing an optional audit failure to undo registration.
135
+ def record_user_creation_safely(user)
136
+ return unless user.persisted? && user.account.present?
137
+
138
+ user.log(
139
+ engine: LesliShield,
140
+ source: self.class.name,
141
+ action: action_name,
142
+ operation: "user_creation_succeeded",
143
+ description: "User created successfully"
144
+ )
145
+ rescue StandardError => exception
146
+ Rails.error.report(
147
+ exception,
148
+ handled: true,
149
+ context: {
150
+ controller: self.class.name,
151
+ operation: "user_creation_succeeded"
152
+ }
153
+ )
129
154
  end
130
155
  end