lato 3.25.0 → 3.26.1

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +50 -20
  3. data/app/assets/javascripts/lato/controllers/lato_account_webauthn_controller.js +8 -5
  4. data/app/assets/javascripts/lato/controllers/lato_action_controller.js +26 -15
  5. data/app/assets/javascripts/lato/controllers/lato_confirm_controller.js +25 -15
  6. data/app/assets/javascripts/lato/controllers/lato_copyclip_controller.js +28 -10
  7. data/app/assets/javascripts/lato/controllers/lato_form_controller.js +1 -0
  8. data/app/assets/javascripts/lato/controllers/lato_index_controller.js +4 -1
  9. data/app/assets/stylesheets/lato/application.scss +17 -0
  10. data/app/controllers/concerns/lato/componentable.rb +20 -8
  11. data/app/controllers/concerns/lato/sessionable.rb +39 -10
  12. data/app/controllers/lato/account_controller.rb +10 -8
  13. data/app/controllers/lato/application_controller.rb +25 -1
  14. data/app/controllers/lato/authentication_controller.rb +74 -39
  15. data/app/helpers/lato/components_helper.rb +52 -17
  16. data/app/jobs/lato/active_storage_cleaner_job.rb +8 -7
  17. data/app/models/lato/invitation.rb +1 -1
  18. data/app/models/lato/operation.rb +6 -2
  19. data/app/models/lato/user.rb +39 -9
  20. data/app/views/lato/account/_alert-accepted-privacy-policy-version.html.erb +1 -1
  21. data/app/views/lato/account/_alert-accepted-terms-and-conditions-version.html.erb +1 -1
  22. data/app/views/lato/account/_form-authenticator.html.erb +7 -3
  23. data/app/views/lato/account/_form-password.html.erb +3 -2
  24. data/app/views/lato/account/_form-web3.html.erb +5 -4
  25. data/app/views/lato/account/_form-webauthn.html.erb +3 -3
  26. data/app/views/lato/account/_security-confirmation.html.erb +8 -5
  27. data/app/views/lato/account/index.html.erb +1 -1
  28. data/app/views/lato/authentication/_fields-registration.html.erb +8 -7
  29. data/app/views/lato/authentication/_form-authenticator.html.erb +2 -2
  30. data/app/views/lato/authentication/_form-signin.html.erb +2 -2
  31. data/app/views/lato/authentication/_form-verify-email.html.erb +3 -2
  32. data/app/views/lato/authentication/_form-web3-signin.html.erb +4 -3
  33. data/app/views/lato/components/_form_item_input_signature.html.erb +1 -1
  34. data/app/views/lato/components/_index.html.erb +27 -22
  35. data/app/views/lato/components/_navbar_nav_item.html.erb +1 -1
  36. data/app/views/lato/components/_operation.html.erb +6 -3
  37. data/app/views/lato/components/_page_head.html.erb +1 -1
  38. data/app/views/lato/components/_sidebar_nav_item.html.erb +2 -1
  39. data/app/views/lato/mailer/invitation/invite_mail.html.erb +1 -1
  40. data/app/views/lato/mailer/user/email_verification_mail.html.erb +1 -1
  41. data/app/views/lato/mailer/user/password_update_mail.html.erb +1 -1
  42. data/app/views/lato/mailer/user/signin_success_mail.html.erb +1 -1
  43. data/app/views/layouts/lato/_action.html.erb +2 -2
  44. data/app/views/layouts/lato/_aside-opener.html.erb +2 -2
  45. data/app/views/layouts/lato/_confirm.html.erb +6 -8
  46. data/app/views/layouts/lato/_feedbacks.html.erb +9 -8
  47. data/app/views/layouts/lato/_mailer-head_content.html.erb +1 -1
  48. data/app/views/layouts/lato/_network.html.erb +3 -3
  49. data/app/views/layouts/lato/application.html.erb +3 -2
  50. data/config/locales/en.yml +33 -0
  51. data/config/locales/fr.yml +30 -0
  52. data/config/locales/it.yml +30 -0
  53. data/config/locales/ro.yml +30 -0
  54. data/db/migrate/20261110000000_add_second_factor_counters_to_lato_users.rb +6 -0
  55. data/lib/lato/config.rb +4 -0
  56. data/lib/lato/engine.rb +5 -0
  57. data/lib/lato/version.rb +1 -1
  58. 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(params.require(:user).permit(:email, :password).merge(
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
- session[:web3_nonce] = SecureRandom.hex(32)
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: session[: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: @user }
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
- @user = Lato::User.find_by_id(session[:authentication_user_id])
310
- respond_to_with_not_found unless @user
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
- @user.errors.add(:base, "hCaptcha response is missing")
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.post_form(uri, {
388
- "secret" => Lato.config.hcaptcha_secret,
389
- "response" => hcaptcha_response,
390
- "remoteip" => request.remote_ip
391
- })
392
- result = JSON.parse(response.body)
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
- true
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
- options[:class].push('is-invalid') if form.object && form.object.errors[key] && form.object.errors[key].any?
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
- # TO-DO: Trovare il modo di calcolare l'id dato da rails a check_input_tag e metterlo nell'attributo :for di check_label_tag
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 label_tag(key, raw(label), class: 'form-check-label')
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 = {})
@@ -259,18 +284,28 @@ module Lato
259
284
  form.submit label, options
260
285
  end
261
286
 
287
+ # Kaminari labels every paginator "pager": several index tables on one page would expose
288
+ # identical navigation landmarks, so each one gets a label from its index key (unique per page).
289
+ def lato_index_paginate(collection, key, **options)
290
+ name = key.to_s == 'default' ? collection.model.model_name.human(count: 2) : key.to_s.humanize
291
+ label = I18n.t('lato.pagination_label', name: name)
292
+ paginate(collection, param_name: "#{key}_page", **options)
293
+ .to_str.sub('aria-label="pager"', "aria-label=\"#{ERB::Util.html_escape(label)}\"").html_safe
294
+ end
295
+
262
296
  # Data
263
297
  ##
264
298
 
265
299
  def lato_data_badge(label, color = 'primary')
266
- content_tag :span, label, class: "badge rounded-pill bg-#{color}"
300
+ content_tag :span, label, class: "badge rounded-pill text-bg-#{color}"
267
301
  end
268
302
 
269
303
  def lato_data_user(label, image_url = nil)
270
304
  image_url ||= image_path('lato/user-150x150.jpg')
271
305
 
272
306
  content_tag :div, class: 'd-flex align-items-center' do
273
- concat content_tag :div, '', class: 'border border-2 rounded-circle me-2', style: "background-position: center; background-size: cover; background-repeat: no-repeat; background-image: url(#{image_url}); width: 30px; height: 30px;"
307
+ # An img attribute is escaped, while a url() inside inline CSS could be broken out of by a crafted image URL.
308
+ concat image_tag(image_url, alt: '', width: 30, height: 30, class: 'border border-2 rounded-circle me-2', style: 'object-fit: cover;')
274
309
  concat content_tag :small, label, class: 'text-black'
275
310
  end
276
311
  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 with more than 12 hours of life
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 < ?", 12.hours.ago)
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
- storage_folder = ActiveStorage::Blob.service.root
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
@@ -23,7 +23,7 @@ module Lato
23
23
  # be sure that email is not already used by another user
24
24
  before_create do
25
25
  if Lato::User.find_by(email: email)
26
- errors.add(:email, 'is already used by another user')
26
+ errors.add(:email, :taken)
27
27
  throw :abort
28
28
  end
29
29
  end
@@ -73,9 +73,13 @@ module Lato
73
73
 
74
74
  def start
75
75
  begin
76
- active_job_name.constantize.perform_later(active_job_input.merge(_operation_id: id))
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, 'Impossibile eseguire il job')
82
+ errors.add(:base, :start_error)
79
83
  return false
80
84
  end
81
85
 
@@ -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(:web3_signed_nonce, :not_correct)
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: 0
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
- result = totp.verify(params[:authenticator_code])
460
- unless result
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="Authenticator enrollment QR code" src="<%= user.authenticator_qr_code_base64(secret: session[:pending_authenticator_secret]) %>" />
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, "Confirm authenticator code", class: "form-label" %>
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, "Confirm", class: %w[btn-primary] %>
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