lato 3.25.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 -20
- 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 +39 -10
- data/app/controllers/lato/account_controller.rb +10 -8
- 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/user.rb +39 -9
- 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 +7 -3
- data/app/views/lato/account/_form-password.html.erb +3 -2
- data/app/views/lato/account/_form-web3.html.erb +4 -3
- data/app/views/lato/account/_security-confirmation.html.erb +3 -3
- 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/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 +5 -0
- data/lib/lato/version.rb +1 -1
- metadata +22 -15
|
@@ -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
|
|
data/app/models/lato/user.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Lato
|
|
|
17
17
|
validates :accepted_terms_and_conditions_version, presence: true
|
|
18
18
|
validates :web3_address, uniqueness: true, allow_blank: true
|
|
19
19
|
validates :webauthn_id, uniqueness: true, allow_blank: true
|
|
20
|
+
validate :validate_password_length, unless: -> { password.nil? }
|
|
20
21
|
|
|
21
22
|
# Relations
|
|
22
23
|
##
|
|
@@ -174,16 +175,22 @@ module Lato
|
|
|
174
175
|
|
|
175
176
|
self.web3_address = params[:web3_address]
|
|
176
177
|
|
|
177
|
-
user = Lato::User.find_by(web3_address: params[:web3_address].downcase)
|
|
178
|
+
user = Lato::User.find_by(web3_address: params[:web3_address].to_s.downcase)
|
|
178
179
|
unless user
|
|
179
180
|
errors.add(:web3_address, :not_correct)
|
|
180
181
|
return
|
|
181
182
|
end
|
|
182
183
|
|
|
184
|
+
# Missing or expired challenge message.
|
|
185
|
+
if params[:web3_nonce].blank?
|
|
186
|
+
errors.add(:base, :web3_address_invalid)
|
|
187
|
+
return
|
|
188
|
+
end
|
|
189
|
+
|
|
183
190
|
signature_pubkey = Eth::Signature.personal_recover(params[:web3_nonce], params[:web3_signed_nonce])
|
|
184
191
|
signature_address = Eth::Util.public_key_to_address signature_pubkey
|
|
185
192
|
unless signature_address.to_s.downcase == params[:web3_address].downcase
|
|
186
|
-
errors.add(:
|
|
193
|
+
errors.add(:base, :web3_address_invalid)
|
|
187
194
|
return
|
|
188
195
|
end
|
|
189
196
|
|
|
@@ -339,6 +346,11 @@ module Lato
|
|
|
339
346
|
def add_web3_connection(params)
|
|
340
347
|
depends_on('eth')
|
|
341
348
|
|
|
349
|
+
if params[:web3_nonce].blank?
|
|
350
|
+
errors.add(:base, :web3_address_invalid)
|
|
351
|
+
return
|
|
352
|
+
end
|
|
353
|
+
|
|
342
354
|
signature_pubkey = Eth::Signature.personal_recover(params[:web3_nonce], params[:web3_signed_nonce])
|
|
343
355
|
signature_address = Eth::Util.public_key_to_address signature_pubkey
|
|
344
356
|
unless signature_address.to_s.downcase == params[:web3_address].downcase
|
|
@@ -391,7 +403,8 @@ module Lato
|
|
|
391
403
|
|
|
392
404
|
update(
|
|
393
405
|
webauthn_id: Base64.strict_encode64(credential.raw_id),
|
|
394
|
-
webauthn_public_key: credential.public_key
|
|
406
|
+
webauthn_public_key: credential.public_key,
|
|
407
|
+
webauthn_sign_count: credential.sign_count.to_i
|
|
395
408
|
)
|
|
396
409
|
rescue JSON::ParserError, WebAuthn::Error => e
|
|
397
410
|
Rails.logger.error(e)
|
|
@@ -423,11 +436,13 @@ module Lato
|
|
|
423
436
|
|
|
424
437
|
credential = WebAuthn::Credential.from_get(parsed_payload)
|
|
425
438
|
|
|
439
|
+
# A counter that does not grow (raises SignCountVerificationError) reveals a cloned authenticator.
|
|
426
440
|
credential.verify(
|
|
427
441
|
encoded_challenge,
|
|
428
442
|
public_key: webauthn_public_key,
|
|
429
|
-
sign_count:
|
|
443
|
+
sign_count: webauthn_sign_count.to_i
|
|
430
444
|
)
|
|
445
|
+
update_column(:webauthn_sign_count, credential.sign_count.to_i)
|
|
431
446
|
|
|
432
447
|
true
|
|
433
448
|
rescue JSON::ParserError, WebAuthn::Error => e
|
|
@@ -440,28 +455,38 @@ module Lato
|
|
|
440
455
|
def remove_webauthn_credential
|
|
441
456
|
update(
|
|
442
457
|
webauthn_id: nil,
|
|
443
|
-
webauthn_public_key: nil
|
|
458
|
+
webauthn_public_key: nil,
|
|
459
|
+
webauthn_sign_count: 0
|
|
444
460
|
)
|
|
445
461
|
end
|
|
446
462
|
|
|
447
463
|
def generate_authenticator_secret
|
|
448
|
-
update(authenticator_secret: ROTP::Base32.random)
|
|
464
|
+
update(authenticator_secret: ROTP::Base32.random, authenticator_last_used_at: nil)
|
|
449
465
|
end
|
|
450
466
|
|
|
451
467
|
def remove_authenticator_secret
|
|
452
|
-
update(authenticator_secret: nil)
|
|
468
|
+
update(authenticator_secret: nil, authenticator_last_used_at: nil)
|
|
453
469
|
end
|
|
454
470
|
|
|
455
471
|
def authenticator(params)
|
|
456
472
|
return false unless authenticator_enabled?
|
|
457
473
|
|
|
458
474
|
totp = ROTP::TOTP.new(authenticator_secret)
|
|
459
|
-
|
|
460
|
-
|
|
475
|
+
# after: rejects codes of an already used time step, so an observed code can not be replayed.
|
|
476
|
+
used_at = totp.verify(params[:authenticator_code].to_s, after: authenticator_last_used_at&.to_i, drift_behind: 15)
|
|
477
|
+
used_at = Time.at(used_at) if used_at
|
|
478
|
+
|
|
479
|
+
# Conditional update: of two concurrent requests with the same code only one can claim it.
|
|
480
|
+
claimed = used_at && self.class.where(id: id)
|
|
481
|
+
.where('authenticator_last_used_at IS NULL OR authenticator_last_used_at < ?', used_at)
|
|
482
|
+
.update_all(authenticator_last_used_at: used_at) == 1
|
|
483
|
+
unless claimed
|
|
461
484
|
errors.add(:base, :authenticator_code_invalid)
|
|
462
485
|
return
|
|
463
486
|
end
|
|
464
487
|
|
|
488
|
+
self.authenticator_last_used_at = used_at
|
|
489
|
+
clear_attribute_change(:authenticator_last_used_at)
|
|
465
490
|
true
|
|
466
491
|
end
|
|
467
492
|
|
|
@@ -515,6 +540,11 @@ module Lato
|
|
|
515
540
|
|
|
516
541
|
private
|
|
517
542
|
|
|
543
|
+
def validate_password_length
|
|
544
|
+
minimum = Lato.config.auth_password_min_length.to_i
|
|
545
|
+
errors.add(:password, :too_short, count: minimum) if password.to_s.length < minimum
|
|
546
|
+
end
|
|
547
|
+
|
|
518
548
|
def normalize_email(value)
|
|
519
549
|
value.to_s.downcase.strip
|
|
520
550
|
end
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<% end %>
|
|
12
12
|
<%= form_with model: @session.user, url: lato.account_update_accepted_privacy_policy_version_action_path, data: { turbo_frame: '_self' } do |form| %>
|
|
13
13
|
<%= lato_form_errors @session.user, class: %w[mb-3] %>
|
|
14
|
-
<%= lato_form_item_input_check form, :confirm, I18n.t('lato.privacy_policy_checkbox', link: link_to(I18n.t('lato.privacy_policy'), Lato.config.legal_privacy_policy_url, target: '_blank')), checked: false, required: true %>
|
|
14
|
+
<%= lato_form_item_input_check form, :confirm, I18n.t('lato.privacy_policy_checkbox', link: link_to(I18n.t('lato.privacy_policy'), Lato.config.legal_privacy_policy_url, target: '_blank')).html_safe, checked: false, required: true %>
|
|
15
15
|
|
|
16
16
|
<div class="mt-3">
|
|
17
17
|
<%= lato_form_submit form, I18n.t('lato.confirm') %>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<% end %>
|
|
12
12
|
<%= form_with model: @session.user, url: lato.account_update_accepted_terms_and_conditions_version_action_path, data: { turbo_frame: '_self' } do |form| %>
|
|
13
13
|
<%= lato_form_errors @session.user, class: %w[mb-3] %>
|
|
14
|
-
<%= lato_form_item_input_check form, :confirm, I18n.t('lato.terms_and_conditions_checkbox', link: link_to(I18n.t('lato.terms_and_conditions'), Lato.config.legal_terms_and_conditions_url)), checked: false, required: true %>
|
|
14
|
+
<%= lato_form_item_input_check form, :confirm, I18n.t('lato.terms_and_conditions_checkbox', link: link_to(I18n.t('lato.terms_and_conditions'), Lato.config.legal_terms_and_conditions_url)).html_safe, checked: false, required: true %>
|
|
15
15
|
|
|
16
16
|
<div class="mt-3">
|
|
17
17
|
<%= lato_form_submit form, I18n.t('lato.confirm') %>
|
|
@@ -9,12 +9,16 @@
|
|
|
9
9
|
<%= lato_form_submit form, I18n.t('lato.account_authenticator_disable'), class: %w[btn-danger] %>
|
|
10
10
|
<% elsif session[:pending_authenticator_secret].present? %>
|
|
11
11
|
<div class="text-center mb-3">
|
|
12
|
-
<img class="shadow-sm rounded" alt="
|
|
12
|
+
<img class="shadow-sm rounded" alt="<%= I18n.t('lato.authenticator_qr_code_alt') %>" src="<%= user.authenticator_qr_code_base64(secret: session[:pending_authenticator_secret]) %>" />
|
|
13
|
+
</div>
|
|
14
|
+
<div class="mb-3">
|
|
15
|
+
<label class="form-label" for="account_form-authenticator__manual-key"><%= I18n.t('lato.authenticator_manual_key') %></label>
|
|
16
|
+
<input type="text" class="form-control font-monospace" id="account_form-authenticator__manual-key" value="<%= session[:pending_authenticator_secret] %>" readonly>
|
|
13
17
|
</div>
|
|
14
18
|
<%= form.hidden_field :authenticator_command, value: "confirm" %>
|
|
15
|
-
<%= form.label :authenticator_code,
|
|
19
|
+
<%= form.label :authenticator_code, I18n.t('lato.confirm_authenticator_code'), class: "form-label" %>
|
|
16
20
|
<%= form.text_field :authenticator_code, value: nil, required: true, class: "form-control mb-3", autocomplete: "one-time-code", inputmode: "numeric" %>
|
|
17
|
-
<%= lato_form_submit form,
|
|
21
|
+
<%= lato_form_submit form, I18n.t('lato.confirm'), class: %w[btn-primary] %>
|
|
18
22
|
<% else %>
|
|
19
23
|
<div class="alert alert-light"><%= raw I18n.t('lato.account_authenticator_start_description') %></div>
|
|
20
24
|
<%= render 'lato/account/security-confirmation', form: form, user: user %>
|
|
@@ -13,12 +13,13 @@ user ||= Lato::User.new
|
|
|
13
13
|
<div class="row">
|
|
14
14
|
<div class="col col-12 col-lg-6 mb-3">
|
|
15
15
|
<%= lato_form_item_label form, :password, I18n.t('lato.new_password') %>
|
|
16
|
-
<%= lato_form_item_input_password form, :password, required: true %>
|
|
16
|
+
<%= lato_form_item_input_password form, :password, required: true, autocomplete: 'new-password', minlength: Lato.config.auth_password_min_length, aria: { describedby: form.field_id(:password, :help) } %>
|
|
17
|
+
<div class="form-text" id="<%= form.field_id(:password, :help) %>"><%= I18n.t('lato.password_requirements', count: Lato.config.auth_password_min_length) %></div>
|
|
17
18
|
</div>
|
|
18
19
|
|
|
19
20
|
<div class="col col-12 col-lg-6 mb-3">
|
|
20
21
|
<%= lato_form_item_label form, :password_confirmation, I18n.t('lato.confirm_new_password') %>
|
|
21
|
-
<%= lato_form_item_input_password form, :password_confirmation, required: true %>
|
|
22
|
+
<%= lato_form_item_input_password form, :password_confirmation, required: true, autocomplete: 'new-password' %>
|
|
22
23
|
</div>
|
|
23
24
|
</div>
|
|
24
25
|
|
|
@@ -30,7 +30,7 @@ user ||= Lato::User.new
|
|
|
30
30
|
</div>
|
|
31
31
|
<% elsif session[:web3_nonce] %>
|
|
32
32
|
<div class="alert alert-light mb-0">
|
|
33
|
-
<h4 class="alert-heading"
|
|
33
|
+
<h4 class="alert-heading" role="status"><%= I18n.t('lato.connecting') %></h4>
|
|
34
34
|
<div class="progress" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">
|
|
35
35
|
<div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 100%"></div>
|
|
36
36
|
</div>
|
|
@@ -39,13 +39,14 @@ user ||= Lato::User.new
|
|
|
39
39
|
<span id="account_form-web3__nonce" style="display: none;"><%= session[:web3_nonce] %></span>
|
|
40
40
|
<%= form.hidden_field :web3_address, id: 'account_form-web3__input-web3_address' %>
|
|
41
41
|
<%= form.hidden_field :web3_signed_nonce, id: 'account_form-web3__input-web3_signed_nonce' %>
|
|
42
|
-
<%= lato_form_submit form, '
|
|
42
|
+
<%= lato_form_submit form, I18n.t('lato.confirm'), class: %w[btn-primary d-none], id: 'account_form-web3__submit' %>
|
|
43
43
|
|
|
44
44
|
<script>
|
|
45
45
|
(async () => {
|
|
46
|
+
// Pinned exact version: dynamic import() can not carry an SRI hash (see README security checklist).
|
|
46
47
|
const ethers = await import('https://cdnjs.cloudflare.com/ajax/libs/ethers/5.7.2/ethers.esm.min.js')
|
|
47
48
|
|
|
48
|
-
const nonce = document.getElementById('account_form-web3__nonce').
|
|
49
|
+
const nonce = document.getElementById('account_form-web3__nonce').textContent
|
|
49
50
|
const inputAddress = document.getElementById('account_form-web3__input-web3_address')
|
|
50
51
|
const inputSignedNonce = document.getElementById('account_form-web3__input-web3_signed_nonce')
|
|
51
52
|
const submitButton = document.getElementById('account_form-web3__submit')
|