lato 3.24.0 → 3.26.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.
- checksums.yaml +4 -4
- data/README.md +49 -5
- data/app/assets/javascripts/lato/controllers/lato_account_security_controller.js +51 -0
- data/app/assets/javascripts/lato/controllers/lato_account_webauthn_controller.js +8 -5
- data/app/assets/javascripts/lato/controllers/lato_action_controller.js +26 -15
- data/app/assets/javascripts/lato/controllers/lato_confirm_controller.js +25 -15
- data/app/assets/javascripts/lato/controllers/lato_copyclip_controller.js +28 -10
- data/app/assets/javascripts/lato/controllers/lato_form_controller.js +1 -0
- data/app/assets/javascripts/lato/controllers/lato_index_controller.js +4 -1
- data/app/controllers/concerns/lato/componentable.rb +20 -8
- data/app/controllers/concerns/lato/sessionable.rb +58 -16
- data/app/controllers/lato/account_controller.rb +98 -24
- data/app/controllers/lato/application_controller.rb +25 -1
- data/app/controllers/lato/authentication_controller.rb +74 -39
- data/app/helpers/lato/components_helper.rb +42 -16
- data/app/jobs/lato/active_storage_cleaner_job.rb +8 -7
- data/app/models/lato/invitation.rb +1 -1
- data/app/models/lato/operation.rb +6 -2
- data/app/models/lato/session.rb +73 -19
- data/app/models/lato/session_record.rb +5 -0
- data/app/models/lato/user.rb +66 -26
- data/app/views/lato/account/_alert-accepted-privacy-policy-version.html.erb +1 -1
- data/app/views/lato/account/_alert-accepted-terms-and-conditions-version.html.erb +1 -1
- data/app/views/lato/account/_form-authenticator.html.erb +19 -33
- data/app/views/lato/account/_form-destroy.html.erb +1 -0
- data/app/views/lato/account/_form-password.html.erb +4 -2
- data/app/views/lato/account/_form-user.html.erb +1 -0
- data/app/views/lato/account/_form-web3.html.erb +5 -3
- data/app/views/lato/account/_form-webauthn.html.erb +1 -0
- data/app/views/lato/account/_security-confirmation.html.erb +12 -0
- data/app/views/lato/authentication/_fields-registration.html.erb +8 -7
- data/app/views/lato/authentication/_form-authenticator.html.erb +2 -2
- data/app/views/lato/authentication/_form-signin.html.erb +2 -2
- data/app/views/lato/authentication/_form-verify-email.html.erb +3 -2
- data/app/views/lato/authentication/_form-web3-signin.html.erb +4 -3
- data/app/views/lato/components/_index.html.erb +25 -20
- data/app/views/lato/components/_navbar_nav_item.html.erb +1 -1
- data/app/views/lato/components/_operation.html.erb +4 -1
- data/app/views/lato/components/_page_head.html.erb +1 -1
- data/app/views/lato/components/_sidebar_nav_item.html.erb +2 -1
- data/app/views/lato/mailer/invitation/invite_mail.html.erb +1 -1
- data/app/views/lato/mailer/user/email_verification_mail.html.erb +1 -1
- data/app/views/lato/mailer/user/password_update_mail.html.erb +1 -1
- data/app/views/lato/mailer/user/signin_success_mail.html.erb +1 -1
- data/app/views/layouts/lato/_action.html.erb +2 -2
- data/app/views/layouts/lato/_aside-opener.html.erb +2 -2
- data/app/views/layouts/lato/_confirm.html.erb +6 -8
- data/app/views/layouts/lato/_feedbacks.html.erb +9 -8
- data/app/views/layouts/lato/_mailer-head_content.html.erb +1 -1
- data/app/views/layouts/lato/_network.html.erb +3 -3
- data/app/views/layouts/lato/application.html.erb +3 -2
- data/config/locales/en.yml +32 -0
- data/config/locales/fr.yml +29 -0
- data/config/locales/it.yml +29 -0
- data/config/locales/ro.yml +29 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20261024000000_create_lato_session_records.rb +16 -0
- data/db/migrate/20261110000000_add_second_factor_counters_to_lato_users.rb +6 -0
- data/lib/lato/config.rb +4 -0
- data/lib/lato/engine.rb +9 -0
- data/lib/lato/version.rb +1 -1
- metadata +34 -17
|
@@ -3,8 +3,20 @@ module Lato
|
|
|
3
3
|
before_action :authenticate_session
|
|
4
4
|
before_action { active_navbar(:account) }
|
|
5
5
|
|
|
6
|
+
before_action :discard_expired_security_enrollment
|
|
7
|
+
before_action :limit_requests, only: %i[update_user_action update_password_action update_authenticator_action update_webauthn_action update_web3_action destroy_action security_challenge]
|
|
8
|
+
before_action :reauthenticate_sensitive_action, only: %i[update_user_action update_password_action update_authenticator_action update_webauthn_action update_web3_action destroy_action]
|
|
9
|
+
|
|
6
10
|
def index; end
|
|
7
11
|
|
|
12
|
+
def security_challenge
|
|
13
|
+
return head :not_found unless @session.user.webauthn_enabled?
|
|
14
|
+
|
|
15
|
+
options = @session.user.webauthn_authentication_options
|
|
16
|
+
@session.set_security_challenge(options.challenge)
|
|
17
|
+
render json: options.as_json
|
|
18
|
+
end
|
|
19
|
+
|
|
8
20
|
def update_user_action
|
|
9
21
|
respond_to do |format|
|
|
10
22
|
if @session.user.update(params.require(:user).permit(:first_name, :last_name, :email))
|
|
@@ -20,25 +32,32 @@ module Lato
|
|
|
20
32
|
def update_authenticator_action
|
|
21
33
|
return respond_to_with_not_found unless Lato.config.authenticator_connection
|
|
22
34
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
user = @session.user
|
|
36
|
+
if user.authenticator_enabled?
|
|
37
|
+
success = user.remove_authenticator_secret
|
|
38
|
+
elsif params.dig(:user, :authenticator_command) == "confirm"
|
|
39
|
+
pending = session.delete(:pending_authenticator_secret)
|
|
40
|
+
used_at = pending.present? && ROTP::TOTP.new(pending).verify(params.dig(:user, :authenticator_code).to_s, drift_behind: 15)
|
|
41
|
+
# Store the enrollment code timestamp so the same code can not be replayed for the first login.
|
|
42
|
+
success = used_at ? user.update(authenticator_secret: pending, authenticator_last_used_at: Time.at(used_at)) : false
|
|
43
|
+
user.errors.add(:base, :authenticator_code_invalid) unless success
|
|
44
|
+
@session.clear_security_continuation
|
|
33
45
|
else
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
session[:pending_authenticator_secret] = ROTP::Base32.random
|
|
47
|
+
grant_security_continuation
|
|
48
|
+
return respond_to do |format|
|
|
49
|
+
format.html { redirect_to lato.account_path }
|
|
50
|
+
format.json { render json: { secret: session[:pending_authenticator_secret] } }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
respond_to do |format|
|
|
55
|
+
if success
|
|
56
|
+
format.html { redirect_to lato.account_path }
|
|
57
|
+
format.json { render json: user }
|
|
58
|
+
else
|
|
59
|
+
format.html { render :index, status: :unprocessable_entity }
|
|
60
|
+
format.json { render json: user.errors, status: :unprocessable_entity }
|
|
42
61
|
end
|
|
43
62
|
end
|
|
44
63
|
end
|
|
@@ -76,6 +95,7 @@ module Lato
|
|
|
76
95
|
format.json { render json: @session.user.errors, status: :unprocessable_entity }
|
|
77
96
|
end
|
|
78
97
|
else
|
|
98
|
+
grant_security_continuation
|
|
79
99
|
options = @session.user.webauthn_registration_options
|
|
80
100
|
session[:webauthn_registration_challenge] = Base64.strict_encode64(options.challenge)
|
|
81
101
|
session[:webauthn_registration_options] = options.as_json
|
|
@@ -107,19 +127,18 @@ module Lato
|
|
|
107
127
|
end
|
|
108
128
|
elsif session[:web3_nonce]
|
|
109
129
|
respond_to do |format|
|
|
110
|
-
if @session.user.add_web3_connection(params.require(:user).permit(:web3_address, :web3_signed_nonce).merge(web3_nonce:
|
|
111
|
-
session[:web3_nonce] = nil
|
|
130
|
+
if @session.user.add_web3_connection(params.require(:user).permit(:web3_address, :web3_signed_nonce).merge(web3_nonce: web3_message_consume))
|
|
112
131
|
format.html { redirect_to lato.account_path }
|
|
113
132
|
format.json { render json: @session.user }
|
|
114
133
|
else
|
|
115
|
-
session[:web3_nonce] = nil
|
|
116
134
|
format.html { render :index, status: :unprocessable_entity }
|
|
117
135
|
format.json { render json: @session.user.errors, status: :unprocessable_entity }
|
|
118
136
|
end
|
|
119
137
|
end
|
|
120
138
|
else
|
|
121
139
|
respond_to do |format|
|
|
122
|
-
|
|
140
|
+
grant_security_continuation
|
|
141
|
+
if web3_message_generate('connect your wallet')
|
|
123
142
|
format.html { redirect_to lato.account_path }
|
|
124
143
|
format.json { render json: @session.user }
|
|
125
144
|
else
|
|
@@ -145,8 +164,9 @@ module Lato
|
|
|
145
164
|
def update_password_action
|
|
146
165
|
respond_to do |format|
|
|
147
166
|
if @session.user.update(params.require(:user).permit(:password, :password_confirmation))
|
|
148
|
-
|
|
149
|
-
format.
|
|
167
|
+
session_destroy
|
|
168
|
+
format.html { redirect_to lato.authentication_signin_path, notice: I18n.t('lato.account_controller.update_password_action_notice') }
|
|
169
|
+
format.json { render json: {} }
|
|
150
170
|
else
|
|
151
171
|
format.html { render :index, status: :unprocessable_entity }
|
|
152
172
|
format.json { render json: @session.user.errors, status: :unprocessable_entity }
|
|
@@ -194,7 +214,61 @@ module Lato
|
|
|
194
214
|
|
|
195
215
|
private
|
|
196
216
|
|
|
217
|
+
def discard_expired_security_enrollment
|
|
218
|
+
action = @session.security_continuation_action
|
|
219
|
+
session.delete(:pending_authenticator_secret) unless action == "update_authenticator_action"
|
|
220
|
+
unless action == "update_webauthn_action"
|
|
221
|
+
session.delete(:webauthn_registration_challenge)
|
|
222
|
+
session.delete(:webauthn_registration_options)
|
|
223
|
+
end
|
|
224
|
+
return if action == "update_web3_action"
|
|
225
|
+
|
|
226
|
+
session.delete(:web3_nonce)
|
|
227
|
+
session.delete(:web3_nonce_expires_at)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def reauthenticate_sensitive_action
|
|
231
|
+
if action_name == "update_user_action"
|
|
232
|
+
email = params.dig(:user, :email)
|
|
233
|
+
return if email.nil? || email.to_s.downcase.strip == @session.user.email
|
|
234
|
+
end
|
|
235
|
+
return if action_name == "update_webauthn_action" && params.dig(:user, :webauthn_command) == "cancel"
|
|
236
|
+
|
|
237
|
+
continuation = case action_name
|
|
238
|
+
when "update_authenticator_action"
|
|
239
|
+
params.dig(:user, :authenticator_command) == "confirm"
|
|
240
|
+
when "update_webauthn_action"
|
|
241
|
+
params.dig(:user, :webauthn_command) == "complete"
|
|
242
|
+
when "update_web3_action"
|
|
243
|
+
session[:web3_nonce].present?
|
|
244
|
+
end
|
|
245
|
+
return if continuation && @session.consume_security_continuation(action_name)
|
|
246
|
+
|
|
247
|
+
user = @session.user
|
|
248
|
+
password_valid = user.authenticate(params.dig(:user, :current_password).to_s)
|
|
249
|
+
factor_valid = if user.authenticator_enabled?
|
|
250
|
+
user.authenticator(authenticator_code: params.dig(:user, :current_authenticator_code).to_s)
|
|
251
|
+
elsif user.webauthn_enabled?
|
|
252
|
+
challenge = @session.take_security_challenge
|
|
253
|
+
user.webauthn_authentication({ webauthn_credential: params.dig(:user, :current_webauthn_credential) }, challenge)
|
|
254
|
+
else
|
|
255
|
+
true
|
|
256
|
+
end
|
|
257
|
+
return if password_valid && factor_valid
|
|
258
|
+
|
|
259
|
+
user.errors.add(:base, I18n.t('lato.security_confirmation_required'))
|
|
260
|
+
respond_to do |format|
|
|
261
|
+
format.html { render :index, status: :unprocessable_entity }
|
|
262
|
+
format.json { render json: user.errors, status: :unprocessable_entity }
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def grant_security_continuation
|
|
267
|
+
@session.grant_security_continuation(action_name)
|
|
268
|
+
end
|
|
269
|
+
|
|
197
270
|
def reset_webauthn_registration_state
|
|
271
|
+
@session.clear_security_continuation
|
|
198
272
|
session[:webauthn_registration_challenge] = nil
|
|
199
273
|
session[:webauthn_registration_options] = nil
|
|
200
274
|
end
|
|
@@ -46,11 +46,35 @@ module Lato
|
|
|
46
46
|
|
|
47
47
|
def respond_to_redirect_same_page(notice = nil)
|
|
48
48
|
respond_to do |format|
|
|
49
|
-
|
|
49
|
+
# Never follow a Referer pointing to another host (open redirect).
|
|
50
|
+
format.html { redirect_back_or_to lato.root_path, allow_other_host: false, notice: notice }
|
|
50
51
|
format.json { render json: {} }
|
|
51
52
|
end
|
|
52
53
|
end
|
|
53
54
|
|
|
55
|
+
# Web3 challenge: the signed text binds the signature to this host, a purpose and a short lifetime,
|
|
56
|
+
# so a signature collected by another site can not be replayed here.
|
|
57
|
+
WEB3_MESSAGE_LIFETIME = 5.minutes
|
|
58
|
+
|
|
59
|
+
def web3_message_generate(purpose)
|
|
60
|
+
session[:web3_nonce] = [
|
|
61
|
+
"#{request.host} wants you to #{purpose} with your Ethereum account.",
|
|
62
|
+
"",
|
|
63
|
+
"URI: #{request.base_url}",
|
|
64
|
+
"Nonce: #{SecureRandom.hex(16)}",
|
|
65
|
+
"Issued At: #{Time.current.utc.iso8601}"
|
|
66
|
+
].join("\n")
|
|
67
|
+
session[:web3_nonce_expires_at] = WEB3_MESSAGE_LIFETIME.from_now.to_i
|
|
68
|
+
session[:web3_nonce]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Single use: the message is removed from the session whatever the outcome.
|
|
72
|
+
def web3_message_consume
|
|
73
|
+
message = session.delete(:web3_nonce)
|
|
74
|
+
expires_at = session.delete(:web3_nonce_expires_at).to_i
|
|
75
|
+
message if message.present? && Time.current.to_i <= expires_at
|
|
76
|
+
end
|
|
77
|
+
|
|
54
78
|
def respond_to_with_offline
|
|
55
79
|
hide_sidebar
|
|
56
80
|
respond_to do |format|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
module Lato
|
|
2
2
|
class AuthenticationController < ApplicationController
|
|
3
|
-
before_action :limit_requests, only: %i[signin_action signup_action accept_invitation_action recover_password_action update_password_action verify_email_action authenticator_action webauthn_action]
|
|
4
|
-
before_action :not_authenticate_session, only: %i[signin signin_action signup signup_action accept_invitation accept_invitation_action]
|
|
3
|
+
before_action :limit_requests, only: %i[signin_action signup_action accept_invitation_action recover_password_action update_password_action verify_email_action authenticator_action webauthn_action web3_signin_action]
|
|
4
|
+
before_action :not_authenticate_session, only: %i[signin signin_action web3_signin web3_signin_action signup signup_action accept_invitation accept_invitation_action]
|
|
5
5
|
before_action :authenticate_session, only: %i[signout signout_action]
|
|
6
6
|
|
|
7
7
|
before_action :find_user, only: %i[verify_email verify_email_action]
|
|
8
8
|
before_action :find_recover_password_user, only: %i[update_password update_password_action]
|
|
9
9
|
before_action :find_invitation, only: %i[accept_invitation accept_invitation_action]
|
|
10
|
-
before_action :find_authentication_user, only: %i[authentication_method authentication_method_action webauthn webauthn_action]
|
|
10
|
+
before_action :find_authentication_user, only: %i[authentication_method authentication_method_action authenticator authenticator_action webauthn webauthn_action]
|
|
11
11
|
|
|
12
12
|
before_action :lock_signup_if_disabled, only: %i[signup signup_action]
|
|
13
13
|
before_action :lock_recover_password_if_disabled, only: %i[recover_password recover_password_action update_password update_password_action]
|
|
@@ -26,12 +26,15 @@ module Lato
|
|
|
26
26
|
|
|
27
27
|
def signin_action
|
|
28
28
|
@user = Lato::User.new
|
|
29
|
+
signin_params = params.require(:user).permit(:email, :password)
|
|
30
|
+
return if account_limit_reached?(signin_params[:email])
|
|
29
31
|
|
|
30
32
|
respond_to do |format|
|
|
31
|
-
if @user.signin(
|
|
33
|
+
if @user.signin(signin_params.merge(
|
|
32
34
|
ip_address: request.remote_ip,
|
|
33
35
|
user_agent: request.user_agent
|
|
34
36
|
))
|
|
37
|
+
account_limit_reset(signin_params[:email])
|
|
35
38
|
redirect_path = determine_authentication_redirect(@user)
|
|
36
39
|
if redirect_path
|
|
37
40
|
# Second factor still pending: expose only where to continue, not user data.
|
|
@@ -43,6 +46,7 @@ module Lato
|
|
|
43
46
|
format.json { render json: @user }
|
|
44
47
|
end
|
|
45
48
|
else
|
|
49
|
+
account_limit_track_failure(signin_params[:email])
|
|
46
50
|
format.html { render :signin, status: :unprocessable_entity }
|
|
47
51
|
format.json { render json: @user.errors, status: :unprocessable_entity }
|
|
48
52
|
end
|
|
@@ -51,7 +55,7 @@ module Lato
|
|
|
51
55
|
|
|
52
56
|
def web3_signin
|
|
53
57
|
@user = Lato::User.new
|
|
54
|
-
|
|
58
|
+
web3_message_generate('sign in')
|
|
55
59
|
end
|
|
56
60
|
|
|
57
61
|
def web3_signin_action
|
|
@@ -61,20 +65,18 @@ module Lato
|
|
|
61
65
|
if @user.web3_signin(params.require(:user).permit(:web3_address, :web3_signed_nonce).merge(
|
|
62
66
|
ip_address: request.remote_ip,
|
|
63
67
|
user_agent: request.user_agent,
|
|
64
|
-
web3_nonce:
|
|
68
|
+
web3_nonce: web3_message_consume
|
|
65
69
|
))
|
|
66
|
-
session[:web3_nonce] = nil
|
|
67
70
|
redirect_path = determine_authentication_redirect(@user)
|
|
68
71
|
if redirect_path
|
|
69
72
|
format.html { redirect_to redirect_path }
|
|
70
|
-
format.json { render json:
|
|
73
|
+
format.json { render json: { redirect: redirect_path } }
|
|
71
74
|
else
|
|
72
75
|
session_create(@user.id)
|
|
73
76
|
format.html { redirect_to lato.root_path }
|
|
74
77
|
format.json { render json: @user }
|
|
75
78
|
end
|
|
76
79
|
else
|
|
77
|
-
session[:web3_nonce] = nil
|
|
78
80
|
format.html { render :web3_signin, status: :unprocessable_entity }
|
|
79
81
|
format.json { render json: @user.errors, status: :unprocessable_entity }
|
|
80
82
|
end
|
|
@@ -232,22 +234,19 @@ module Lato
|
|
|
232
234
|
# Authenticator
|
|
233
235
|
##
|
|
234
236
|
|
|
235
|
-
def authenticator
|
|
236
|
-
@user = Lato::User.find_by_id(session[:authentication_user_id])
|
|
237
|
-
return respond_to_with_not_found unless @user
|
|
238
|
-
end
|
|
237
|
+
def authenticator; end
|
|
239
238
|
|
|
240
239
|
def authenticator_action
|
|
241
|
-
@user = Lato::User.find_by_id(session[:authentication_user_id])
|
|
242
|
-
|
|
243
240
|
respond_to do |format|
|
|
244
241
|
if @user.authenticator(params.require(:user).permit(:authenticator_code))
|
|
242
|
+
account_limit_reset(authentication_limit_identifier)
|
|
245
243
|
clear_authentication_session
|
|
246
244
|
session_create(@user.id)
|
|
247
245
|
|
|
248
246
|
format.html { redirect_to lato.root_path }
|
|
249
247
|
format.json { render json: @user }
|
|
250
248
|
else
|
|
249
|
+
track_authentication_failure
|
|
251
250
|
format.html { render :authenticator, status: :unprocessable_entity }
|
|
252
251
|
format.json { render json: @user.errors, status: :unprocessable_entity }
|
|
253
252
|
end
|
|
@@ -265,12 +264,14 @@ module Lato
|
|
|
265
264
|
def webauthn_action
|
|
266
265
|
respond_to do |format|
|
|
267
266
|
if @user.webauthn_authentication(params.require(:user).permit(:webauthn_credential), session[:webauthn_challenge])
|
|
267
|
+
account_limit_reset(authentication_limit_identifier)
|
|
268
268
|
clear_authentication_session
|
|
269
269
|
session_create(@user.id)
|
|
270
270
|
|
|
271
271
|
format.html { redirect_to lato.root_path }
|
|
272
272
|
format.json { render json: @user }
|
|
273
273
|
else
|
|
274
|
+
track_authentication_failure
|
|
274
275
|
@options = @user.webauthn_authentication_options
|
|
275
276
|
session[:webauthn_challenge] = @options.challenge
|
|
276
277
|
format.html { render :webauthn, status: :unprocessable_entity }
|
|
@@ -305,9 +306,30 @@ module Lato
|
|
|
305
306
|
respond_to_with_not_found unless @invitation
|
|
306
307
|
end
|
|
307
308
|
|
|
309
|
+
# Pending second factor state lives in the session cookie: it expires and allows a few attempts only.
|
|
310
|
+
AUTHENTICATION_PENDING_LIFETIME = 10.minutes
|
|
311
|
+
AUTHENTICATION_PENDING_MAX_ATTEMPTS = 5
|
|
312
|
+
|
|
308
313
|
def find_authentication_user
|
|
309
|
-
|
|
310
|
-
|
|
314
|
+
expired = session[:authentication_expires_at].to_i < Time.current.to_i
|
|
315
|
+
@user = Lato::User.find_by_id(session[:authentication_user_id]) unless expired
|
|
316
|
+
unless @user
|
|
317
|
+
clear_authentication_session
|
|
318
|
+
return respond_to_with_not_found
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
account_limit_reached?(authentication_limit_identifier)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def authentication_limit_identifier
|
|
325
|
+
"second_factor/#{@user.id}"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Counted both in the session and per user in cache, so replaying an older cookie does not reset the budget.
|
|
329
|
+
def track_authentication_failure
|
|
330
|
+
account_limit_track_failure(authentication_limit_identifier)
|
|
331
|
+
session[:authentication_attempts] = session[:authentication_attempts].to_i + 1
|
|
332
|
+
clear_authentication_session if session[:authentication_attempts] >= AUTHENTICATION_PENDING_MAX_ATTEMPTS
|
|
311
333
|
end
|
|
312
334
|
|
|
313
335
|
def determine_authentication_redirect(user)
|
|
@@ -317,6 +339,8 @@ module Lato
|
|
|
317
339
|
return nil unless authenticator_enabled || webauthn_enabled
|
|
318
340
|
|
|
319
341
|
session[:authentication_user_id] = user.id
|
|
342
|
+
session[:authentication_expires_at] = AUTHENTICATION_PENDING_LIFETIME.from_now.to_i
|
|
343
|
+
session[:authentication_attempts] = 0
|
|
320
344
|
|
|
321
345
|
if authenticator_enabled && webauthn_enabled
|
|
322
346
|
lato.authentication_authentication_method_path
|
|
@@ -329,6 +353,8 @@ module Lato
|
|
|
329
353
|
|
|
330
354
|
def clear_authentication_session
|
|
331
355
|
session[:authentication_user_id] = nil
|
|
356
|
+
session[:authentication_expires_at] = nil
|
|
357
|
+
session[:authentication_attempts] = nil
|
|
332
358
|
session[:authentication_method] = nil
|
|
333
359
|
session[:webauthn_challenge] = nil
|
|
334
360
|
end
|
|
@@ -372,35 +398,44 @@ module Lato
|
|
|
372
398
|
|
|
373
399
|
hcaptcha_response = params["h-captcha-response"]
|
|
374
400
|
if hcaptcha_response.blank?
|
|
375
|
-
|
|
376
|
-
respond_to do |format|
|
|
377
|
-
format.html { render render_key, status: :unprocessable_entity }
|
|
378
|
-
format.json { render json: @user.errors, status: :unprocessable_entity }
|
|
379
|
-
end
|
|
380
|
-
return false
|
|
401
|
+
return respond_to_hcaptcha_error(render_key, I18n.t('lato.hcaptcha_missing'))
|
|
381
402
|
end
|
|
382
403
|
|
|
404
|
+
unless hcaptcha_verified?(hcaptcha_response)
|
|
405
|
+
return respond_to_hcaptcha_error(render_key, I18n.t('lato.hcaptcha_failed'))
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
true
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Short timeouts: a slow hCaptcha must not hold server threads. Any network or parsing error counts as failure.
|
|
412
|
+
def hcaptcha_verified?(hcaptcha_response)
|
|
383
413
|
require 'net/http'
|
|
384
|
-
require 'uri'
|
|
385
414
|
require 'json'
|
|
415
|
+
|
|
386
416
|
uri = URI.parse("https://hcaptcha.com/siteverify")
|
|
387
|
-
response = Net::HTTP.
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
Rails.logger.info("[hCaptcha] Verification result: #{result}")
|
|
394
|
-
unless result["success"]
|
|
395
|
-
@user.errors.add(:base, "hCaptcha verification failed")
|
|
396
|
-
respond_to do |format|
|
|
397
|
-
format.html { render render_key, status: :unprocessable_entity }
|
|
398
|
-
format.json { render json: @user.errors, status: :unprocessable_entity }
|
|
399
|
-
end
|
|
400
|
-
return false
|
|
417
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 3, read_timeout: 5) do |http|
|
|
418
|
+
http.post(uri.path, URI.encode_www_form(
|
|
419
|
+
"secret" => Lato.config.hcaptcha_secret,
|
|
420
|
+
"response" => hcaptcha_response,
|
|
421
|
+
"remoteip" => request.remote_ip
|
|
422
|
+
), "Content-Type" => "application/x-www-form-urlencoded")
|
|
401
423
|
end
|
|
424
|
+
result = JSON.parse(response.body)
|
|
425
|
+
Rails.logger.info("[hCaptcha] Verification success: #{result['success'].inspect}")
|
|
426
|
+
result["success"] == true
|
|
427
|
+
rescue StandardError => e
|
|
428
|
+
Rails.logger.error("[hCaptcha] Verification error: #{e.class}")
|
|
429
|
+
false
|
|
430
|
+
end
|
|
402
431
|
|
|
403
|
-
|
|
432
|
+
def respond_to_hcaptcha_error(render_key, message)
|
|
433
|
+
@user.errors.add(:base, message)
|
|
434
|
+
respond_to do |format|
|
|
435
|
+
format.html { render render_key, status: :unprocessable_entity }
|
|
436
|
+
format.json { render json: @user.errors, status: :unprocessable_entity }
|
|
437
|
+
end
|
|
438
|
+
false
|
|
404
439
|
end
|
|
405
440
|
end
|
|
406
441
|
end
|
|
@@ -106,7 +106,12 @@ module Lato
|
|
|
106
106
|
# setup classes
|
|
107
107
|
options[:class] ||= []
|
|
108
108
|
options[:class].push(classes)
|
|
109
|
-
|
|
109
|
+
if _lato_form_errors?(form, key)
|
|
110
|
+
options[:class].push('is-invalid')
|
|
111
|
+
options[:aria] ||= {}
|
|
112
|
+
options[:aria][:invalid] = true
|
|
113
|
+
options[:aria][:describedby] = [options[:aria][:describedby], _lato_form_feedback_id(form, key)].compact.join(' ')
|
|
114
|
+
end
|
|
110
115
|
|
|
111
116
|
# setup stimulus
|
|
112
117
|
options[:data] ||= {}
|
|
@@ -115,6 +120,25 @@ module Lato
|
|
|
115
120
|
options[:data][:lato_form_target] = 'input'
|
|
116
121
|
end
|
|
117
122
|
|
|
123
|
+
def _lato_form_errors?(form, key)
|
|
124
|
+
form.object.respond_to?(:errors) && form.object.errors[key].any?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def _lato_form_feedback_id(form, key)
|
|
128
|
+
form.field_id(key, :feedback)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Inline error message linked to the input through aria-describedby.
|
|
132
|
+
def _lato_form_input_feedback(form, key)
|
|
133
|
+
return ''.html_safe unless _lato_form_errors?(form, key)
|
|
134
|
+
|
|
135
|
+
content_tag :div, form.object.errors[key].to_sentence, id: _lato_form_feedback_id(form, key), class: 'invalid-feedback'
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def _lato_form_input_with_feedback(form, key)
|
|
139
|
+
yield + _lato_form_input_feedback(form, key)
|
|
140
|
+
end
|
|
141
|
+
|
|
118
142
|
def lato_form_notices(options = {})
|
|
119
143
|
return unless notice
|
|
120
144
|
|
|
@@ -134,6 +158,7 @@ module Lato
|
|
|
134
158
|
options[:class] ||= []
|
|
135
159
|
options[:class] += %w[alert alert-danger]
|
|
136
160
|
options[:class] += %w[alert-dismissible fade show] unless options[:fixed]
|
|
161
|
+
options[:role] ||= 'alert'
|
|
137
162
|
|
|
138
163
|
errors_list = content_tag(:ul, class: %w[mb-0 ps-3]) do
|
|
139
164
|
instance.errors.collect do |error|
|
|
@@ -158,48 +183,48 @@ module Lato
|
|
|
158
183
|
def lato_form_item_input_text(form, key, options = {})
|
|
159
184
|
_lato_form_input_options(form, key, options, :keyup, 'form-control')
|
|
160
185
|
|
|
161
|
-
form.text_field key, options
|
|
186
|
+
_lato_form_input_with_feedback(form, key) { form.text_field key, options }
|
|
162
187
|
end
|
|
163
188
|
|
|
164
189
|
def lato_form_item_input_number(form, key, options = {})
|
|
165
190
|
_lato_form_input_options(form, key, options, :keyup, 'form-control')
|
|
166
191
|
|
|
167
|
-
form.number_field key, options
|
|
192
|
+
_lato_form_input_with_feedback(form, key) { form.number_field key, options }
|
|
168
193
|
end
|
|
169
194
|
|
|
170
195
|
def lato_form_item_input_email(form, key, options = {})
|
|
171
196
|
_lato_form_input_options(form, key, options, :keyup, 'form-control')
|
|
172
197
|
|
|
173
|
-
form.email_field key, options
|
|
198
|
+
_lato_form_input_with_feedback(form, key) { form.email_field key, options }
|
|
174
199
|
end
|
|
175
200
|
|
|
176
201
|
def lato_form_item_input_password(form, key, options = {})
|
|
177
202
|
_lato_form_input_options(form, key, options, :keyup, 'form-control')
|
|
178
203
|
|
|
179
|
-
form.password_field key, options
|
|
204
|
+
_lato_form_input_with_feedback(form, key) { form.password_field key, options }
|
|
180
205
|
end
|
|
181
206
|
|
|
182
207
|
def lato_form_item_input_check(form, key, label, options = {})
|
|
183
208
|
_lato_form_input_options(form, key, options, :change, 'form-check-input')
|
|
184
209
|
|
|
185
|
-
#
|
|
186
|
-
|
|
210
|
+
# The label is escaped unless the caller passes an html_safe string (e.g. a translation with a trusted link).
|
|
187
211
|
content_tag :div, class: 'form-check' do
|
|
188
212
|
concat form.check_box(key, options)
|
|
189
|
-
concat
|
|
213
|
+
concat form.label(key, label, class: 'form-check-label', for: options[:id] || form.field_id(key))
|
|
214
|
+
concat _lato_form_input_feedback(form, key)
|
|
190
215
|
end
|
|
191
216
|
end
|
|
192
217
|
|
|
193
218
|
def lato_form_item_input_select(form, key, select_options = [], options = {})
|
|
194
219
|
_lato_form_input_options(form, key, options, :change, 'form-control')
|
|
195
220
|
|
|
196
|
-
form.select key, select_options, {}, options
|
|
221
|
+
_lato_form_input_with_feedback(form, key) { form.select key, select_options, {}, options }
|
|
197
222
|
end
|
|
198
223
|
|
|
199
224
|
def lato_form_item_input_file(form, key, options = {})
|
|
200
225
|
_lato_form_input_options(form, key, options, :change, 'form-control')
|
|
201
226
|
|
|
202
|
-
form.file_field key, options
|
|
227
|
+
_lato_form_input_with_feedback(form, key) { form.file_field key, options }
|
|
203
228
|
end
|
|
204
229
|
|
|
205
230
|
def lato_form_item_input_file_dropzone(form, key, options = {})
|
|
@@ -211,25 +236,25 @@ module Lato
|
|
|
211
236
|
def lato_form_item_input_textarea(form, key, options = {})
|
|
212
237
|
_lato_form_input_options(form, key, options, :keyup, 'form-control')
|
|
213
238
|
|
|
214
|
-
form.text_area key, options
|
|
239
|
+
_lato_form_input_with_feedback(form, key) { form.text_area key, options }
|
|
215
240
|
end
|
|
216
241
|
|
|
217
242
|
def lato_form_item_input_date(form, key, options = {})
|
|
218
243
|
_lato_form_input_options(form, key, options, :change, 'form-control')
|
|
219
244
|
|
|
220
|
-
form.date_field key, options
|
|
245
|
+
_lato_form_input_with_feedback(form, key) { form.date_field key, options }
|
|
221
246
|
end
|
|
222
247
|
|
|
223
248
|
def lato_form_item_input_datetime(form, key, options = {})
|
|
224
249
|
_lato_form_input_options(form, key, options, :change, 'form-control')
|
|
225
250
|
|
|
226
|
-
form.datetime_field key, options
|
|
251
|
+
_lato_form_input_with_feedback(form, key) { form.datetime_field key, options }
|
|
227
252
|
end
|
|
228
253
|
|
|
229
254
|
def lato_form_item_input_time(form, key, options = {})
|
|
230
255
|
_lato_form_input_options(form, key, options, :change, 'form-control')
|
|
231
256
|
|
|
232
|
-
form.time_field key, options
|
|
257
|
+
_lato_form_input_with_feedback(form, key) { form.time_field key, options }
|
|
233
258
|
end
|
|
234
259
|
|
|
235
260
|
def lato_form_item_input_color(form, key, options = {})
|
|
@@ -237,7 +262,7 @@ module Lato
|
|
|
237
262
|
options[:class] ||= []
|
|
238
263
|
options[:class].push('lato-form-item-input-color')
|
|
239
264
|
|
|
240
|
-
form.color_field key, options
|
|
265
|
+
_lato_form_input_with_feedback(form, key) { form.color_field key, options }
|
|
241
266
|
end
|
|
242
267
|
|
|
243
268
|
def lato_form_item_input_list(form, key, partial, partial_params = {})
|
|
@@ -270,7 +295,8 @@ module Lato
|
|
|
270
295
|
image_url ||= image_path('lato/user-150x150.jpg')
|
|
271
296
|
|
|
272
297
|
content_tag :div, class: 'd-flex align-items-center' do
|
|
273
|
-
|
|
298
|
+
# An img attribute is escaped, while a url() inside inline CSS could be broken out of by a crafted image URL.
|
|
299
|
+
concat image_tag(image_url, alt: '', width: 30, height: 30, class: 'border border-2 rounded-circle me-2', style: 'object-fit: cover;')
|
|
274
300
|
concat content_tag :small, label, class: 'text-black'
|
|
275
301
|
end
|
|
276
302
|
end
|
|
@@ -3,23 +3,22 @@ module Lato
|
|
|
3
3
|
def perform(params = {})
|
|
4
4
|
update_operation_percentage(0)
|
|
5
5
|
|
|
6
|
-
# delete all active storage blobs that are not attached to any record
|
|
6
|
+
# delete all active storage blobs that are not attached to any record older than the minimum age (default 12 hours)
|
|
7
|
+
min_age_hours = (params[:min_age_hours] || params['min_age_hours'] || 12).to_i
|
|
7
8
|
query = ActiveStorage::Blob.joins("LEFT JOIN active_storage_attachments ON active_storage_attachments.blob_id = active_storage_blobs.id")
|
|
8
9
|
.where("active_storage_attachments.blob_id IS NULL")
|
|
9
|
-
.where("active_storage_blobs.created_at < ?",
|
|
10
|
+
.where("active_storage_blobs.created_at < ?", min_age_hours.hours.ago)
|
|
10
11
|
totals = query.count
|
|
11
12
|
runned = 0
|
|
12
13
|
query.find_in_batches do |blobs|
|
|
13
14
|
blobs.each(&:purge_later)
|
|
14
15
|
runned += blobs.size
|
|
15
|
-
update_operation_percentage((runned.to_f / totals * 100).round)
|
|
16
|
+
update_operation_percentage((runned.to_f / totals * 100).round) if totals.positive?
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
# delete all empty folders in active storage local service (if exists)
|
|
19
|
-
if ActiveStorage::Blob.service.class.to_s == 'ActiveStorage::Service::DiskService'
|
|
20
|
-
|
|
21
|
-
return unless File.directory?(storage_folder)
|
|
22
|
-
|
|
20
|
+
storage_folder = ActiveStorage::Blob.service.root if ActiveStorage::Blob.service.class.to_s == 'ActiveStorage::Service::DiskService'
|
|
21
|
+
if storage_folder && File.directory?(storage_folder)
|
|
23
22
|
Dir.foreach(storage_folder) do |folder|
|
|
24
23
|
next if folder == '.' || folder == '..'
|
|
25
24
|
|
|
@@ -27,6 +26,8 @@ module Lato
|
|
|
27
26
|
FileUtils.rm_rf(folder_path) if File.directory?(folder_path) && Dir.empty?(folder_path)
|
|
28
27
|
end
|
|
29
28
|
end
|
|
29
|
+
|
|
30
|
+
update_operation_percentage(100)
|
|
30
31
|
end
|
|
31
32
|
end
|
|
32
33
|
end
|
|
@@ -73,9 +73,13 @@ module Lato
|
|
|
73
73
|
|
|
74
74
|
def start
|
|
75
75
|
begin
|
|
76
|
-
active_job_name.
|
|
76
|
+
# Only operation jobs can be started: active_job_name may come from request params in host apps.
|
|
77
|
+
job_class = active_job_name.to_s.safe_constantize
|
|
78
|
+
raise ArgumentError, "#{active_job_name} is not a Lato::ApplicationJob" unless job_class.is_a?(Class) && job_class < Lato::ApplicationJob
|
|
79
|
+
|
|
80
|
+
job_class.perform_later(active_job_input.merge(_operation_id: id))
|
|
77
81
|
rescue StandardError
|
|
78
|
-
errors.add(:base,
|
|
82
|
+
errors.add(:base, :start_error)
|
|
79
83
|
return false
|
|
80
84
|
end
|
|
81
85
|
|