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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +49 -5
  3. data/app/assets/javascripts/lato/controllers/lato_account_security_controller.js +51 -0
  4. data/app/assets/javascripts/lato/controllers/lato_account_webauthn_controller.js +8 -5
  5. data/app/assets/javascripts/lato/controllers/lato_action_controller.js +26 -15
  6. data/app/assets/javascripts/lato/controllers/lato_confirm_controller.js +25 -15
  7. data/app/assets/javascripts/lato/controllers/lato_copyclip_controller.js +28 -10
  8. data/app/assets/javascripts/lato/controllers/lato_form_controller.js +1 -0
  9. data/app/assets/javascripts/lato/controllers/lato_index_controller.js +4 -1
  10. data/app/controllers/concerns/lato/componentable.rb +20 -8
  11. data/app/controllers/concerns/lato/sessionable.rb +58 -16
  12. data/app/controllers/lato/account_controller.rb +98 -24
  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 +42 -16
  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/session.rb +73 -19
  20. data/app/models/lato/session_record.rb +5 -0
  21. data/app/models/lato/user.rb +66 -26
  22. data/app/views/lato/account/_alert-accepted-privacy-policy-version.html.erb +1 -1
  23. data/app/views/lato/account/_alert-accepted-terms-and-conditions-version.html.erb +1 -1
  24. data/app/views/lato/account/_form-authenticator.html.erb +19 -33
  25. data/app/views/lato/account/_form-destroy.html.erb +1 -0
  26. data/app/views/lato/account/_form-password.html.erb +4 -2
  27. data/app/views/lato/account/_form-user.html.erb +1 -0
  28. data/app/views/lato/account/_form-web3.html.erb +5 -3
  29. data/app/views/lato/account/_form-webauthn.html.erb +1 -0
  30. data/app/views/lato/account/_security-confirmation.html.erb +12 -0
  31. data/app/views/lato/authentication/_fields-registration.html.erb +8 -7
  32. data/app/views/lato/authentication/_form-authenticator.html.erb +2 -2
  33. data/app/views/lato/authentication/_form-signin.html.erb +2 -2
  34. data/app/views/lato/authentication/_form-verify-email.html.erb +3 -2
  35. data/app/views/lato/authentication/_form-web3-signin.html.erb +4 -3
  36. data/app/views/lato/components/_index.html.erb +25 -20
  37. data/app/views/lato/components/_navbar_nav_item.html.erb +1 -1
  38. data/app/views/lato/components/_operation.html.erb +4 -1
  39. data/app/views/lato/components/_page_head.html.erb +1 -1
  40. data/app/views/lato/components/_sidebar_nav_item.html.erb +2 -1
  41. data/app/views/lato/mailer/invitation/invite_mail.html.erb +1 -1
  42. data/app/views/lato/mailer/user/email_verification_mail.html.erb +1 -1
  43. data/app/views/lato/mailer/user/password_update_mail.html.erb +1 -1
  44. data/app/views/lato/mailer/user/signin_success_mail.html.erb +1 -1
  45. data/app/views/layouts/lato/_action.html.erb +2 -2
  46. data/app/views/layouts/lato/_aside-opener.html.erb +2 -2
  47. data/app/views/layouts/lato/_confirm.html.erb +6 -8
  48. data/app/views/layouts/lato/_feedbacks.html.erb +9 -8
  49. data/app/views/layouts/lato/_mailer-head_content.html.erb +1 -1
  50. data/app/views/layouts/lato/_network.html.erb +3 -3
  51. data/app/views/layouts/lato/application.html.erb +3 -2
  52. data/config/locales/en.yml +32 -0
  53. data/config/locales/fr.yml +29 -0
  54. data/config/locales/it.yml +29 -0
  55. data/config/locales/ro.yml +29 -0
  56. data/config/routes.rb +1 -0
  57. data/db/migrate/20261024000000_create_lato_session_records.rb +16 -0
  58. data/db/migrate/20261110000000_add_second_factor_counters_to_lato_users.rb +6 -0
  59. data/lib/lato/config.rb +4 -0
  60. data/lib/lato/engine.rb +9 -0
  61. data/lib/lato/version.rb +1 -1
  62. metadata +34 -17
@@ -4,48 +4,102 @@ module Lato
4
4
  @session = parse_session(session)
5
5
  end
6
6
 
7
- # Questions
8
- ##
9
-
10
7
  def valid?
11
- !@session.blank?
8
+ record.present?
12
9
  end
13
10
 
14
11
  def really_valid?
15
- !!user
12
+ valid? && user.present?
16
13
  end
17
14
 
18
- # Helpers
19
- ##
20
-
21
15
  def user
22
- @user ||= Lato::User.find_by(id: user_id)
16
+ @user ||= record&.user if valid?
23
17
  end
24
18
 
25
19
  def user_id
26
- @session&.dig(:user_id)
20
+ record&.lato_user_id
27
21
  end
28
22
 
29
23
  def get(key)
30
- @session&.dig(key)
24
+ @session&.dig(key) if valid?
25
+ end
26
+
27
+ def expires_at
28
+ record&.expires_at
29
+ end
30
+
31
+ def update(extra_params)
32
+ return unless valid?
33
+
34
+ @session.merge!(extra_params.to_h.except(:user_id, :token, "user_id", "token"))
35
+ @session.to_json
31
36
  end
32
37
 
33
- # Class
34
- ##
38
+ def grant_security_continuation(action)
39
+ record.update!(security_grant_action: action, security_grant_expires_at: 5.minutes.from_now)
40
+ end
41
+
42
+ def security_continuation_action
43
+ current = record
44
+ current.security_grant_action if current&.security_grant_expires_at&.future?
45
+ end
46
+
47
+ def consume_security_continuation(action)
48
+ return false unless (current = record)
49
+
50
+ Lato::SessionRecord.where(id: current.id, security_grant_action: action).where("security_grant_expires_at > ?", Time.current).update_all(security_grant_action: nil, security_grant_expires_at: nil) == 1
51
+ end
52
+
53
+ def clear_security_continuation
54
+ record&.update!(security_grant_action: nil, security_grant_expires_at: nil)
55
+ end
56
+
57
+ def set_security_challenge(challenge)
58
+ record.update!(security_challenge: challenge, security_challenge_expires_at: 5.minutes.from_now)
59
+ end
60
+
61
+ def take_security_challenge
62
+ current = record
63
+ return unless current
64
+
65
+ current.with_lock do
66
+ challenge = current.security_challenge if current.security_challenge_expires_at&.future?
67
+ current.update!(security_challenge: nil, security_challenge_expires_at: nil)
68
+ challenge
69
+ end
70
+ end
71
+
72
+ def destroy
73
+ record&.destroy!
74
+ @session = nil
75
+ end
35
76
 
36
77
  def self.generate_session_per_user(user_id, extra_params = {})
37
- extra_params.merge(user_id: user_id).to_json
78
+ user = Lato::User.find(user_id)
79
+ token = SecureRandom.hex(32)
80
+ user.with_lock do
81
+ user.lato_session_records.where("expires_at <= ?", Time.current).delete_all
82
+ user.lato_session_records.create!(token_digest: Digest::SHA256.hexdigest(token), expires_at: Lato.config.session_lifetime.from_now)
83
+ end
84
+ extra_params.to_h.except(:user_id, :token, "user_id", "token").merge(user_id: user.id, token: token).to_json
38
85
  end
39
86
 
40
87
  private
41
88
 
89
+ # Always query revocation state, including when a Session instance was already used.
90
+ def record
91
+ return unless @session&.dig(:token).is_a?(String) && @session[:token].present?
92
+
93
+ Lato::SessionRecord.where(lato_user_id: @session[:user_id], token_digest: Digest::SHA256.hexdigest(@session[:token])).where("expires_at > ?", Time.current).first
94
+ end
95
+
42
96
  def parse_session(session)
43
- return nil if session.blank?
97
+ return if session.blank?
44
98
 
45
- (session.is_a?(String) ? JSON.parse(session) : session).with_indifferent_access
46
- rescue StandardError => e
47
- Rails.logger.error "Lato::Session.parse_session: #{e.message}"
99
+ parsed = session.is_a?(String) ? JSON.parse(session) : session
100
+ parsed.with_indifferent_access if parsed.is_a?(Hash)
101
+ rescue JSON::ParserError
48
102
  nil
49
103
  end
50
104
  end
51
- end
105
+ end
@@ -0,0 +1,5 @@
1
+ module Lato
2
+ class SessionRecord < ApplicationRecord
3
+ belongs_to :user, class_name: "Lato::User", foreign_key: :lato_user_id
4
+ end
5
+ end
@@ -17,10 +17,13 @@ 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
  ##
23
24
 
25
+ has_many :lato_session_records, class_name: "Lato::SessionRecord", foreign_key: :lato_user_id, dependent: :delete_all
26
+
24
27
  has_many :lato_operations, class_name: 'Lato::Operation', foreign_key: :lato_user_id, dependent: :nullify
25
28
  has_many :lato_invitations, class_name: 'Lato::Invitation', foreign_key: :lato_user_id, dependent: :nullify
26
29
  has_many :lato_invitations_as_inviter, class_name: 'Lato::Invitation', foreign_key: :inviter_lato_user_id, dependent: :nullify
@@ -46,6 +49,16 @@ module Lato
46
49
  self.accepted_terms_and_conditions_version = Lato.config.legal_terms_and_conditions_version if accepted_terms_and_conditions_version_changed?
47
50
  end
48
51
 
52
+ after_update do
53
+ lato_session_records.delete_all if saved_change_to_password_digest?
54
+ if saved_change_to_email?
55
+ saved_change_to_email.each do |address|
56
+ Rails.cache.delete("Lato::User/c_email_verification_code/#{id}/#{Digest::SHA256.hexdigest(address.to_s)}")
57
+ end
58
+ c_email_verification_semaphore(false)
59
+ end
60
+ end
61
+
49
62
  # Questions
50
63
  ##
51
64
 
@@ -86,17 +99,17 @@ module Lato
86
99
 
87
100
  def email_protected
88
101
  return email unless email.include?('@')
89
-
102
+
90
103
  local_part, domain = email.split('@', 2)
91
-
104
+
92
105
  # Keep first 2 characters of local part
93
106
  chars_to_keep = [local_part.length, 2].min
94
107
  protected_local = local_part[0, chars_to_keep] + '*' * [local_part.length - chars_to_keep, 0].max
95
-
108
+
96
109
  # Keep domain as is or partially protect it
97
110
  chars_to_keep = [domain.length, 2].min
98
111
  protected_domain = domain[0, chars_to_keep] + '*' * [domain.length - chars_to_keep, 0].max
99
-
112
+
100
113
  "#{protected_local}@#{protected_domain}"
101
114
  end
102
115
 
@@ -104,8 +117,8 @@ module Lato
104
117
  @gravatar_image_url ||= "https://www.gravatar.com/avatar/#{Digest::MD5.hexdigest(email)}?s=#{size}"
105
118
  end
106
119
 
107
- def authenticator_qr_code_base64(size = 200)
108
- "data:image/png;base64,#{Base64.strict_encode64(RQRCode::QRCode.new(ROTP::TOTP.new(authenticator_secret, :issuer => Lato.config.application_title).provisioning_uri(email).to_s).as_png(size: size, border_modules: 0).to_s)}"
120
+ def authenticator_qr_code_base64(size = 200, secret: authenticator_secret)
121
+ "data:image/png;base64,#{Base64.strict_encode64(RQRCode::QRCode.new(ROTP::TOTP.new(secret, :issuer => Lato.config.application_title).provisioning_uri(email).to_s).as_png(size: size, border_modules: 0).to_s)}"
109
122
  end
110
123
 
111
124
  # Operations
@@ -162,16 +175,22 @@ module Lato
162
175
 
163
176
  self.web3_address = params[:web3_address]
164
177
 
165
- 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)
166
179
  unless user
167
180
  errors.add(:web3_address, :not_correct)
168
181
  return
169
182
  end
170
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
+
171
190
  signature_pubkey = Eth::Signature.personal_recover(params[:web3_nonce], params[:web3_signed_nonce])
172
191
  signature_address = Eth::Util.public_key_to_address signature_pubkey
173
192
  unless signature_address.to_s.downcase == params[:web3_address].downcase
174
- errors.add(:web3_signed_nonce, :not_correct)
193
+ errors.add(:base, :web3_address_invalid)
175
194
  return
176
195
  end
177
196
 
@@ -228,8 +247,10 @@ module Lato
228
247
  c_email_verification_code('')
229
248
  c_email_verification_semaphore(false)
230
249
 
231
- update_column(:email_verified_at, Time.now)
232
- true
250
+ # A token for a stale model instance must never verify a concurrently changed address.
251
+ verified = self.class.where(id: id, email: email).update_all(email_verified_at: Time.current) == 1
252
+ errors.add(:base, :email_verification_code_invalid) unless verified
253
+ verified
233
254
  end
234
255
 
235
256
  # NOTE: Always succeeds for a well formed email, registered or not, so the response can not be used to enumerate users.
@@ -275,11 +296,7 @@ module Lato
275
296
  c_password_update_code('')
276
297
  c_password_update_semaphore(false)
277
298
 
278
- update(params.permit(:password, :password_confirmation).merge(
279
- authenticator_secret: nil, # Reset authenticator secret when password is updated
280
- webauthn_id: nil, # Reset webauthn credential when password is updated
281
- webauthn_public_key: nil # Reset webauthn credential when password is updated
282
- ))
299
+ update(params.permit(:password, :password_confirmation))
283
300
  end
284
301
 
285
302
  def update_accepted_privacy_policy_version(params)
@@ -329,6 +346,11 @@ module Lato
329
346
  def add_web3_connection(params)
330
347
  depends_on('eth')
331
348
 
349
+ if params[:web3_nonce].blank?
350
+ errors.add(:base, :web3_address_invalid)
351
+ return
352
+ end
353
+
332
354
  signature_pubkey = Eth::Signature.personal_recover(params[:web3_nonce], params[:web3_signed_nonce])
333
355
  signature_address = Eth::Util.public_key_to_address signature_pubkey
334
356
  unless signature_address.to_s.downcase == params[:web3_address].downcase
@@ -381,7 +403,8 @@ module Lato
381
403
 
382
404
  update(
383
405
  webauthn_id: Base64.strict_encode64(credential.raw_id),
384
- webauthn_public_key: credential.public_key
406
+ webauthn_public_key: credential.public_key,
407
+ webauthn_sign_count: credential.sign_count.to_i
385
408
  )
386
409
  rescue JSON::ParserError, WebAuthn::Error => e
387
410
  Rails.logger.error(e)
@@ -403,21 +426,23 @@ module Lato
403
426
  end
404
427
 
405
428
  parsed_payload = JSON.parse(params[:webauthn_credential])
406
-
429
+
407
430
  # Converti i campi da base64url a base64 standard per il gem webauthn
408
431
  parsed_payload['rawId'] = base64url_to_base64(parsed_payload['rawId'])
409
432
  parsed_payload['response']['clientDataJSON'] = base64url_to_base64(parsed_payload['response']['clientDataJSON'])
410
433
  parsed_payload['response']['authenticatorData'] = base64url_to_base64(parsed_payload['response']['authenticatorData'])
411
434
  parsed_payload['response']['signature'] = base64url_to_base64(parsed_payload['response']['signature'])
412
435
  parsed_payload['response']['userHandle'] = base64url_to_base64(parsed_payload['response']['userHandle']) if parsed_payload['response']['userHandle']
413
-
436
+
414
437
  credential = WebAuthn::Credential.from_get(parsed_payload)
415
-
438
+
439
+ # A counter that does not grow (raises SignCountVerificationError) reveals a cloned authenticator.
416
440
  credential.verify(
417
441
  encoded_challenge,
418
442
  public_key: webauthn_public_key,
419
- sign_count: 0
443
+ sign_count: webauthn_sign_count.to_i
420
444
  )
445
+ update_column(:webauthn_sign_count, credential.sign_count.to_i)
421
446
 
422
447
  true
423
448
  rescue JSON::ParserError, WebAuthn::Error => e
@@ -430,28 +455,38 @@ module Lato
430
455
  def remove_webauthn_credential
431
456
  update(
432
457
  webauthn_id: nil,
433
- webauthn_public_key: nil
458
+ webauthn_public_key: nil,
459
+ webauthn_sign_count: 0
434
460
  )
435
461
  end
436
462
 
437
463
  def generate_authenticator_secret
438
- update(authenticator_secret: ROTP::Base32.random)
464
+ update(authenticator_secret: ROTP::Base32.random, authenticator_last_used_at: nil)
439
465
  end
440
466
 
441
467
  def remove_authenticator_secret
442
- update(authenticator_secret: nil)
468
+ update(authenticator_secret: nil, authenticator_last_used_at: nil)
443
469
  end
444
470
 
445
471
  def authenticator(params)
446
472
  return false unless authenticator_enabled?
447
473
 
448
474
  totp = ROTP::TOTP.new(authenticator_secret)
449
- result = totp.verify(params[:authenticator_code])
450
- 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
451
484
  errors.add(:base, :authenticator_code_invalid)
452
485
  return
453
486
  end
454
487
 
488
+ self.authenticator_last_used_at = used_at
489
+ clear_attribute_change(:authenticator_last_used_at)
455
490
  true
456
491
  end
457
492
 
@@ -480,7 +515,7 @@ module Lato
480
515
  end
481
516
 
482
517
  def c_email_verification_code(value = nil)
483
- cache_key = "Lato::User/c_email_verification_code/#{id}"
518
+ cache_key = "Lato::User/c_email_verification_code/#{id}/#{Digest::SHA256.hexdigest(email.to_s)}"
484
519
  return Rails.cache.read(cache_key) if value.nil?
485
520
 
486
521
  Rails.cache.write(cache_key, value, expires_in: 30.minutes)
@@ -505,6 +540,11 @@ module Lato
505
540
 
506
541
  private
507
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
+
508
548
  def normalize_email(value)
509
549
  value.to_s.downcase.strip
510
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') %>
@@ -1,42 +1,28 @@
1
- <%
2
-
3
- user ||= Lato::User.new
4
-
5
- %>
6
-
1
+ <% user ||= Lato::User.new %>
7
2
  <%= turbo_frame_tag 'account_form-authenticator' do %>
8
3
  <%= form_with model: user, url: lato.account_update_authenticator_action_path, data: { turbo_frame: '_self', controller: 'lato-form' } do |form| %>
9
4
  <%= lato_form_notices class: %w[mb-3] %>
10
5
  <%= lato_form_errors user, class: %w[mb-3] %>
11
-
12
- <% if user.authenticator_secret %>
13
- <div class="d-block d-md-flex align-items-stretch">
14
- <div class="text-center mb-3 mb-md-0 me-md-3">
15
- <img class="shadow-sm rounded" src="<%= user.authenticator_qr_code_base64 %>" />
16
- </div>
17
- <div class="d-flex flex-column justify-content-between w-100">
18
- <div class="alert alert-success">
19
- <h4 class="alert-heading"><%= I18n.t('lato.account_authenticator_ready_title') %></h4>
20
- <p class="mb-0">
21
- <%= raw I18n.t('lato.account_authenticator_ready_qr') %>
22
- </p>
23
- </div>
24
-
25
- <div class="d-flex justify-content-end">
26
- <%= lato_form_submit form, I18n.t('lato.account_authenticator_disable'), class: %w[btn-danger] %>
27
- </div>
28
- </div>
6
+ <% if user.authenticator_enabled? %>
7
+ <div class="alert alert-success"><%= I18n.t('lato.account_authenticator_ready_title') %></div>
8
+ <%= render 'lato/account/security-confirmation', form: form, user: user %>
9
+ <%= lato_form_submit form, I18n.t('lato.account_authenticator_disable'), class: %w[btn-danger] %>
10
+ <% elsif session[:pending_authenticator_secret].present? %>
11
+ <div class="text-center mb-3">
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]) %>" />
29
13
  </div>
30
- <% else %>
31
- <div class="alert alert-light mb-0">
32
- <h4 class="alert-heading"><%= I18n.t('lato.account_authenticator_start_title') %></h4>
33
- <p>
34
- <%= raw I18n.t('lato.account_authenticator_start_description') %>
35
- </p>
36
- <p class="mb-0">
37
- <%= lato_form_submit form, I18n.t('lato.account_authenticator_generate_qr_code'), class: %w[btn-primary] %>
38
- </p>
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>
39
17
  </div>
18
+ <%= form.hidden_field :authenticator_command, value: "confirm" %>
19
+ <%= form.label :authenticator_code, I18n.t('lato.confirm_authenticator_code'), class: "form-label" %>
20
+ <%= form.text_field :authenticator_code, value: nil, required: true, class: "form-control mb-3", autocomplete: "one-time-code", inputmode: "numeric" %>
21
+ <%= lato_form_submit form, I18n.t('lato.confirm'), class: %w[btn-primary] %>
22
+ <% else %>
23
+ <div class="alert alert-light"><%= raw I18n.t('lato.account_authenticator_start_description') %></div>
24
+ <%= render 'lato/account/security-confirmation', form: form, user: user %>
25
+ <%= lato_form_submit form, I18n.t('lato.account_authenticator_generate_qr_code'), class: %w[btn-primary] %>
40
26
  <% end %>
41
27
  <% end %>
42
28
  <% end %>
@@ -8,6 +8,7 @@ user ||= Lato::User.new
8
8
  <%= form_with model: user, url: lato.account_destroy_action_path, method: :delete, data: { turbo_frame: '_self', controller: 'lato-form' } do |form| %>
9
9
  <%= lato_form_notices class: %w[mb-3] %>
10
10
  <%= lato_form_errors user, class: %w[mb-3] %>
11
+ <%= render 'lato/account/security-confirmation', form: form, user: user %>
11
12
 
12
13
  <div class="mb-3">
13
14
  <%= lato_form_item_label form, :email_confirmation, I18n.t('lato.signup_email') %>
@@ -8,16 +8,18 @@ user ||= Lato::User.new
8
8
  <%= form_with model: user, url: lato.account_update_password_action_path, data: { turbo_frame: '_self', controller: 'lato-form' } do |form| %>
9
9
  <%= lato_form_notices class: %w[mb-3] %>
10
10
  <%= lato_form_errors user, class: %w[mb-3] %>
11
+ <%= render 'lato/account/security-confirmation', form: form, user: user %>
11
12
 
12
13
  <div class="row">
13
14
  <div class="col col-12 col-lg-6 mb-3">
14
15
  <%= lato_form_item_label form, :password, I18n.t('lato.new_password') %>
15
- <%= 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>
16
18
  </div>
17
19
 
18
20
  <div class="col col-12 col-lg-6 mb-3">
19
21
  <%= lato_form_item_label form, :password_confirmation, I18n.t('lato.confirm_new_password') %>
20
- <%= lato_form_item_input_password form, :password_confirmation, required: true %>
22
+ <%= lato_form_item_input_password form, :password_confirmation, required: true, autocomplete: 'new-password' %>
21
23
  </div>
22
24
  </div>
23
25
 
@@ -8,6 +8,7 @@ user ||= Lato::User.new
8
8
  <%= form_with model: user, url: lato.account_update_user_action_path, data: { turbo_frame: '_self', controller: 'lato-form lato-account-form-user' } do |form| %>
9
9
  <%= lato_form_notices class: %w[mb-3] %>
10
10
  <%= lato_form_errors user, class: %w[mb-3] %>
11
+ <%= render 'lato/account/security-confirmation', form: form, user: user %>
11
12
 
12
13
  <div class="row">
13
14
  <div class="col col-12 col-lg-4 mb-3">
@@ -8,6 +8,7 @@ user ||= Lato::User.new
8
8
  <%= form_with model: user, url: lato.account_update_web3_action_path, data: { turbo_frame: '_self', controller: 'lato-form' } do |form| %>
9
9
  <%= lato_form_notices class: %w[mb-3] %>
10
10
  <%= lato_form_errors user, class: %w[mb-3] %>
11
+ <%= render 'lato/account/security-confirmation', form: form, user: user if session[:web3_nonce].blank? %>
11
12
 
12
13
  <% if user.web3_address %>
13
14
  <div class="row">
@@ -29,7 +30,7 @@ user ||= Lato::User.new
29
30
  </div>
30
31
  <% elsif session[:web3_nonce] %>
31
32
  <div class="alert alert-light mb-0">
32
- <h4 class="alert-heading">Connecting..</h4>
33
+ <h4 class="alert-heading" role="status"><%= I18n.t('lato.connecting') %></h4>
33
34
  <div class="progress" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">
34
35
  <div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 100%"></div>
35
36
  </div>
@@ -38,13 +39,14 @@ user ||= Lato::User.new
38
39
  <span id="account_form-web3__nonce" style="display: none;"><%= session[:web3_nonce] %></span>
39
40
  <%= form.hidden_field :web3_address, id: 'account_form-web3__input-web3_address' %>
40
41
  <%= form.hidden_field :web3_signed_nonce, id: 'account_form-web3__input-web3_signed_nonce' %>
41
- <%= lato_form_submit form, 'Confirm', class: %w[btn-primary d-none], id: 'account_form-web3__submit' %>
42
+ <%= lato_form_submit form, I18n.t('lato.confirm'), class: %w[btn-primary d-none], id: 'account_form-web3__submit' %>
42
43
 
43
44
  <script>
44
45
  (async () => {
46
+ // Pinned exact version: dynamic import() can not carry an SRI hash (see README security checklist).
45
47
  const ethers = await import('https://cdnjs.cloudflare.com/ajax/libs/ethers/5.7.2/ethers.esm.min.js')
46
48
 
47
- const nonce = document.getElementById('account_form-web3__nonce').innerText
49
+ const nonce = document.getElementById('account_form-web3__nonce').textContent
48
50
  const inputAddress = document.getElementById('account_form-web3__input-web3_address')
49
51
  const inputSignedNonce = document.getElementById('account_form-web3__input-web3_signed_nonce')
50
52
  const submitButton = document.getElementById('account_form-web3__submit')
@@ -16,6 +16,7 @@ end
16
16
  <%= form_with model: user, url: lato.account_update_webauthn_action_path, data: form_data do |form| %>
17
17
  <%= lato_form_notices class: %w[mb-3] %>
18
18
  <%= lato_form_errors user, class: %w[mb-3] %>
19
+ <%= render 'lato/account/security-confirmation', form: form, user: user if registration_options.blank? %>
19
20
 
20
21
  <% if user.webauthn_enabled? %>
21
22
  <div class="alert alert-success mb-3">
@@ -0,0 +1,12 @@
1
+ <div class="mb-3" data-controller="lato-account-security" data-lato-account-security-challenge-url-value="<%= lato.account_security_challenge_path %>" data-lato-account-security-webauthn-value="<%= user.webauthn_enabled? && !user.authenticator_enabled? %>">
2
+ <%= form.label :current_password, I18n.t('lato.current_password'), class: "form-label" %>
3
+ <%= form.password_field :current_password, class: "form-control", autocomplete: "current-password" %>
4
+ <% if user.authenticator_enabled? %>
5
+ <%= form.label :current_authenticator_code, I18n.t('lato.current_authenticator_code'), class: "form-label mt-2" %>
6
+ <%= form.text_field :current_authenticator_code, value: nil, class: "form-control", autocomplete: "one-time-code", inputmode: "numeric" %>
7
+ <% elsif user.webauthn_enabled? %>
8
+ <%= form.hidden_field :current_webauthn_credential, value: nil, data: { lato_account_security_target: "credential" } %>
9
+ <p class="text-muted mt-2"><%= I18n.t('lato.security_key_confirmation_help') %></p>
10
+ <% end %>
11
+ <p class="text-danger mt-2" data-lato-account-security-target="error" role="alert"></p>
12
+ </div>
@@ -7,31 +7,32 @@ email_readonly ||= false
7
7
  <div class="row">
8
8
  <div class="col col-12 col-md-6 mb-3">
9
9
  <%= lato_form_item_label form, :first_name %>
10
- <%= lato_form_item_input_text form, :first_name, required: true %>
10
+ <%= lato_form_item_input_text form, :first_name, required: true, autocomplete: 'given-name' %>
11
11
  </div>
12
12
 
13
13
  <div class="col col-12 col-md-6 mb-3">
14
14
  <%= lato_form_item_label form, :last_name %>
15
- <%= lato_form_item_input_text form, :last_name, required: true %>
15
+ <%= lato_form_item_input_text form, :last_name, required: true, autocomplete: 'family-name' %>
16
16
  </div>
17
17
 
18
18
  <div class="col col-12 mb-3">
19
19
  <%= lato_form_item_label form, :email %>
20
- <%= lato_form_item_input_email form, :email, required: true, readonly: email_readonly %>
20
+ <%= lato_form_item_input_email form, :email, required: true, readonly: email_readonly, autocomplete: 'email' %>
21
21
  </div>
22
22
 
23
23
  <div class="col col-12 col-md-6 mb-3">
24
24
  <%= lato_form_item_label form, :password %>
25
- <%= lato_form_item_input_password form, :password, required: true %>
25
+ <%= 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) } %>
26
+ <div class="form-text" id="<%= form.field_id(:password, :help) %>"><%= I18n.t('lato.password_requirements', count: Lato.config.auth_password_min_length) %></div>
26
27
  </div>
27
28
 
28
29
  <div class="col col-12 col-md-6 mb-3">
29
30
  <%= lato_form_item_label form, :password_confirmation %>
30
- <%= lato_form_item_input_password form, :password_confirmation, required: true %>
31
+ <%= lato_form_item_input_password form, :password_confirmation, required: true, autocomplete: 'new-password' %>
31
32
  </div>
32
33
  </div>
33
34
 
34
35
  <div class="mb-3 text-muted" style="font-size: 14px;">
35
- <%= lato_form_item_input_check form, :accepted_privacy_policy_version, I18n.t('lato.privacy_policy_checkbox', link: link_to(I18n.t('lato.privacy_policy'), Lato.config.legal_privacy_policy_url, target: '_blank')), required: true %>
36
- <%= lato_form_item_input_check form, :accepted_terms_and_conditions_version, I18n.t('lato.terms_and_conditions_checkbox', link: link_to(I18n.t('lato.terms_and_conditions'), Lato.config.legal_terms_and_conditions_url, target: '_blank')), required: true %>
36
+ <%= lato_form_item_input_check form, :accepted_privacy_policy_version, I18n.t('lato.privacy_policy_checkbox', link: link_to(I18n.t('lato.privacy_policy'), Lato.config.legal_privacy_policy_url, target: '_blank')).html_safe, required: true %>
37
+ <%= lato_form_item_input_check form, :accepted_terms_and_conditions_version, I18n.t('lato.terms_and_conditions_checkbox', link: link_to(I18n.t('lato.terms_and_conditions'), Lato.config.legal_terms_and_conditions_url, target: '_blank')).html_safe, required: true %>
37
38
  </div>
@@ -10,11 +10,11 @@ user ||= Lato::User.new
10
10
  <%= lato_form_errors user, class: %w[mb-3] %>
11
11
 
12
12
  <div class="mb-3 text-center">
13
- <p><%= raw I18n.t('lato.authenticator_code_help', email: h(user.email_protected)) %></p>
13
+ <%= form.label :authenticator_code, raw(I18n.t('lato.authenticator_code_help', email: h(user.email_protected))), class: 'form-label' %>
14
14
  </div>
15
15
 
16
16
  <div class="mb-3">
17
- <%= lato_form_item_input_text form, :authenticator_code, required: true %>
17
+ <%= lato_form_item_input_text form, :authenticator_code, required: true, autocomplete: 'one-time-code', inputmode: 'numeric', pattern: '[0-9]{6}', maxlength: 6, autofocus: true %>
18
18
  </div>
19
19
 
20
20
  <div>
@@ -11,12 +11,12 @@ user ||= Lato::User.new
11
11
 
12
12
  <div class="mb-3">
13
13
  <%= lato_form_item_label form, :email %>
14
- <%= lato_form_item_input_email form, :email, required: true %>
14
+ <%= lato_form_item_input_email form, :email, required: true, autocomplete: 'username' %>
15
15
  </div>
16
16
 
17
17
  <div class="mb-3">
18
18
  <%= lato_form_item_label form, :password %>
19
- <%= lato_form_item_input_password form, :password, required: true %>
19
+ <%= lato_form_item_input_password form, :password, required: true, autocomplete: 'current-password' %>
20
20
  </div>
21
21
 
22
22
  <div>
@@ -10,13 +10,14 @@ code ||= nil
10
10
  <%= form_with model: user, url: lato.authentication_verify_email_action_path(id: user.id), class: 'text-center', data: { turbo_frame: '_self' } do |form| %>
11
11
  <% if code %>
12
12
  <%= form.hidden_field :code, value: code %>
13
- <button type="submit" style="display: none;"></button>
13
+ <%# Visible fallback when JavaScript is disabled or blocked by CSP. %>
14
+ <button type="submit" class="btn btn-primary"><%= I18n.t('lato.verify_email') %></button>
14
15
  <script>document.getElementById('authentication_form_verify_email').querySelector('button[type="submit"]').click()</script>
15
16
  <% else %>
16
17
  <%= lato_form_notices class: %w[mb-3], fixed: true %>
17
18
  <%= lato_form_errors user, class: %w[mb-3], fixed: true %>
18
19
 
19
- <%= link_to 'Torna all tuo account', lato.account_path, class: %w[btn btn-primary] %>
20
+ <%= link_to I18n.t('lato.back_to_account'), lato.account_path, class: %w[btn btn-primary] %>
20
21
  <% end %>
21
22
  <% end %>
22
23
  <% end %>