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.
- 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 -770
- 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/lesli_shield/users_controller.rb +3 -7
- 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/authentication_interface.rb +3 -3
- 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 +35 -39
- 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-card.html.erb +35 -45
- 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 +44 -4
- data/lib/lesli_shield/router.rb +52 -18
- data/lib/lesli_shield/version.rb +2 -2
- data/readme.md +6 -4
- metadata +30 -5
- data/app/assets/config/lesli_shield_manifest.js +0 -48
- data/app/assets/stylesheets/lesli_shield/application.tailwind.css +0 -0
|
@@ -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
|
|
@@ -41,13 +41,9 @@ module LesliShield
|
|
|
41
41
|
|
|
42
42
|
# GET /users/1
|
|
43
43
|
def show
|
|
44
|
-
@user_roles = @user.
|
|
45
|
-
@activities = @user.
|
|
46
|
-
|
|
47
|
-
.select(:id, :operation, :description, Date2.new.db_column('created_at', as:'date'))
|
|
48
|
-
.limit(7)
|
|
49
|
-
.as_json
|
|
50
|
-
@sessions = @user.result.sessions
|
|
44
|
+
@user_roles = @user.roles
|
|
45
|
+
@activities = @user.logs
|
|
46
|
+
@sessions = @user.sessions
|
|
51
47
|
@user = @user.show
|
|
52
48
|
end
|
|
53
49
|
|
|
@@ -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
|