lato 3.24.0 → 3.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +49 -5
- data/app/assets/javascripts/lato/controllers/lato_account_security_controller.js +51 -0
- data/app/assets/javascripts/lato/controllers/lato_account_webauthn_controller.js +8 -5
- data/app/assets/javascripts/lato/controllers/lato_action_controller.js +26 -15
- data/app/assets/javascripts/lato/controllers/lato_confirm_controller.js +25 -15
- data/app/assets/javascripts/lato/controllers/lato_copyclip_controller.js +28 -10
- data/app/assets/javascripts/lato/controllers/lato_form_controller.js +1 -0
- data/app/assets/javascripts/lato/controllers/lato_index_controller.js +4 -1
- data/app/controllers/concerns/lato/componentable.rb +20 -8
- data/app/controllers/concerns/lato/sessionable.rb +58 -16
- data/app/controllers/lato/account_controller.rb +98 -24
- data/app/controllers/lato/application_controller.rb +25 -1
- data/app/controllers/lato/authentication_controller.rb +74 -39
- data/app/helpers/lato/components_helper.rb +42 -16
- data/app/jobs/lato/active_storage_cleaner_job.rb +8 -7
- data/app/models/lato/invitation.rb +1 -1
- data/app/models/lato/operation.rb +6 -2
- data/app/models/lato/session.rb +73 -19
- data/app/models/lato/session_record.rb +5 -0
- data/app/models/lato/user.rb +66 -26
- data/app/views/lato/account/_alert-accepted-privacy-policy-version.html.erb +1 -1
- data/app/views/lato/account/_alert-accepted-terms-and-conditions-version.html.erb +1 -1
- data/app/views/lato/account/_form-authenticator.html.erb +19 -33
- data/app/views/lato/account/_form-destroy.html.erb +1 -0
- data/app/views/lato/account/_form-password.html.erb +4 -2
- data/app/views/lato/account/_form-user.html.erb +1 -0
- data/app/views/lato/account/_form-web3.html.erb +5 -3
- data/app/views/lato/account/_form-webauthn.html.erb +1 -0
- data/app/views/lato/account/_security-confirmation.html.erb +12 -0
- data/app/views/lato/authentication/_fields-registration.html.erb +8 -7
- data/app/views/lato/authentication/_form-authenticator.html.erb +2 -2
- data/app/views/lato/authentication/_form-signin.html.erb +2 -2
- data/app/views/lato/authentication/_form-verify-email.html.erb +3 -2
- data/app/views/lato/authentication/_form-web3-signin.html.erb +4 -3
- data/app/views/lato/components/_index.html.erb +25 -20
- data/app/views/lato/components/_navbar_nav_item.html.erb +1 -1
- data/app/views/lato/components/_operation.html.erb +4 -1
- data/app/views/lato/components/_page_head.html.erb +1 -1
- data/app/views/lato/components/_sidebar_nav_item.html.erb +2 -1
- data/app/views/lato/mailer/invitation/invite_mail.html.erb +1 -1
- data/app/views/lato/mailer/user/email_verification_mail.html.erb +1 -1
- data/app/views/lato/mailer/user/password_update_mail.html.erb +1 -1
- data/app/views/lato/mailer/user/signin_success_mail.html.erb +1 -1
- data/app/views/layouts/lato/_action.html.erb +2 -2
- data/app/views/layouts/lato/_aside-opener.html.erb +2 -2
- data/app/views/layouts/lato/_confirm.html.erb +6 -8
- data/app/views/layouts/lato/_feedbacks.html.erb +9 -8
- data/app/views/layouts/lato/_mailer-head_content.html.erb +1 -1
- data/app/views/layouts/lato/_network.html.erb +3 -3
- data/app/views/layouts/lato/application.html.erb +3 -2
- data/config/locales/en.yml +32 -0
- data/config/locales/fr.yml +29 -0
- data/config/locales/it.yml +29 -0
- data/config/locales/ro.yml +29 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20261024000000_create_lato_session_records.rb +16 -0
- data/db/migrate/20261110000000_add_second_factor_counters_to_lato_users.rb +6 -0
- data/lib/lato/config.rb +4 -0
- data/lib/lato/engine.rb +9 -0
- data/lib/lato/version.rb +1 -1
- metadata +34 -17
data/app/models/lato/session.rb
CHANGED
|
@@ -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
|
-
|
|
8
|
+
record.present?
|
|
12
9
|
end
|
|
13
10
|
|
|
14
11
|
def really_valid?
|
|
15
|
-
|
|
12
|
+
valid? && user.present?
|
|
16
13
|
end
|
|
17
14
|
|
|
18
|
-
# Helpers
|
|
19
|
-
##
|
|
20
|
-
|
|
21
15
|
def user
|
|
22
|
-
@user ||=
|
|
16
|
+
@user ||= record&.user if valid?
|
|
23
17
|
end
|
|
24
18
|
|
|
25
19
|
def user_id
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
97
|
+
return if session.blank?
|
|
44
98
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
data/app/models/lato/user.rb
CHANGED
|
@@ -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(
|
|
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(:
|
|
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
|
-
|
|
232
|
-
|
|
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)
|
|
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:
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
<
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
<
|
|
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"
|
|
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, '
|
|
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').
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
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 %>
|