lato 3.24.0 → 3.25.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '09a445f7a4001cf512ef060934adeea3a0fd7620ebc7a8334bb95ea3e637939f'
4
- data.tar.gz: aac7020d5b8ae8698a46249870543cb25cb193b2e1d910f856381fd5d1945f72
3
+ metadata.gz: 737de7ae597862df18ec4aa38fce9ac511c884398349122005f9107b2c8904d8
4
+ data.tar.gz: 2aa2e54234a0cccf18be9343e1132c6d04036126e1fe5ff6b0b0e2c0bdda1729
5
5
  SHA512:
6
- metadata.gz: eeaccec11e915e024139107529d49d3878b341de85ecebb9551ce7367cbacc591362d93da08f6760eb5372eb844277e0d31c835923a20b82d240d926b4e599ba
7
- data.tar.gz: a4864739c065fe216835e04b509620c7748fff88a51e8dc57849f9f7515518f0fdb3ab5e9b69f30adefb2680a48e9a149b630674f31dfeae442b919c13997a5f
6
+ metadata.gz: e08b461f1c18d60decbd7599f009fc90d87f94cb625b987202734f466b3182b997a14a4fc4b200e16daa5f13923fa5237f2004210776d88f9badeccdf3ce2544
7
+ data.tar.gz: bc865b8d9043e7a07978f8e7a8609244d5189abd7660acf306ecf9d82026c764cd09c4b98304452dfe680822108b42aca8c399ec3d660ef81b04f3bc448f3c77
data/README.md CHANGED
@@ -13,10 +13,25 @@ The gem is **agnostic to the application stack**. You can use it with different
13
13
 
14
14
  The gem is designed to be easily **customizable and extendable**. You can fully customize the UI and the functionalities of the panel. You can also add new functionalities and components to the panel on the main application or develop new engines to extend the panel.
15
15
 
16
- The gem is ready to be used with the **latest Rails 7+** features like **[ESM import maps](https://github.com/rails/importmap-rails)**, **[Turbo](https://turbo.hotwired.dev/)** and **[Stimulus](https://stimulus.hotwired.dev)**. The front-end is designed to be responsive, mobile friendly and accessible.
16
+ The gem is ready to be used with the **Rails 8.1.3.1 or later within the 8.1 series** features like **[ESM import maps](https://github.com/rails/importmap-rails)**, **[Turbo](https://turbo.hotwired.dev/)** and **[Stimulus](https://stimulus.hotwired.dev)**. The front-end is designed to be responsive, mobile friendly and accessible.
17
17
 
18
18
  <img src="./preview.gif" alt="Lato preview" width="100%">
19
19
 
20
+ ## Security upgrade
21
+
22
+ Lato 3.25 requires Rails >= 8.1.3.1 and < 8.2. Install the new session migration before serving requests:
23
+
24
+ ```bash
25
+ bundle exec rails lato:install:migrations
26
+ bundle exec rails db:migrate
27
+ ```
28
+
29
+ Sessions are stored as revocable token hashes in the database. Existing cookies require a fresh sign-in after upgrading. Signing out revokes the current device; changing or recovering a password revokes all devices. Password recovery preserves existing second factors.
30
+
31
+ Account password, email, authentication factor, wallet, and account deletion changes require the current password plus an enrolled authenticator or security key. Authenticator enrollment requires confirmation with a valid code; an active authenticator's secret is never displayed again. API clients submit `user[current_password]` and, when enrolled, `user[current_authenticator_code]`. Security key clients first POST to `account/security_challenge`, then submit the assertion as `user[current_webauthn_credential]`. Enrollment continuations expire after five minutes and can only be used once.
32
+
33
+ Extensions updating session attributes must call `session_update(extra_params)` to preserve the revocable session and secure cookie attributes.
34
+
20
35
  ## Full documentation
21
36
 
22
37
  The full documentation is available here: 👉 👉 [DOCUMENTATION](https://lato-oss.gregoriogalante.com) 👈 👈
@@ -0,0 +1,51 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ static targets = ['credential', 'error']
5
+ static values = { challengeUrl: String, webauthn: Boolean }
6
+
7
+ connect () {
8
+ this.form = this.element.closest('form')
9
+ this.onSubmit = this.confirm.bind(this)
10
+ this.form.addEventListener('submit', this.onSubmit)
11
+ }
12
+
13
+ disconnect () {
14
+ this.form.removeEventListener('submit', this.onSubmit)
15
+ }
16
+
17
+ async confirm (event) {
18
+ if (!this.webauthnValue || this.confirmed) return
19
+ event.preventDefault()
20
+ event.stopImmediatePropagation()
21
+ this.errorTarget.textContent = ''
22
+ try {
23
+ const response = await fetch(this.challengeUrlValue, {
24
+ method: 'POST',
25
+ headers: { Accept: 'application/json', 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content }
26
+ })
27
+ if (!response.ok) throw new Error('Unable to request security key confirmation.')
28
+ const publicKey = await response.json()
29
+ const decode = value => Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
30
+ publicKey.challenge = decode(publicKey.challenge)
31
+ publicKey.allowCredentials = publicKey.allowCredentials.map(credential => ({ ...credential, id: decode(credential.id) }))
32
+ const credential = await navigator.credentials.get({ publicKey })
33
+ const encode = buffer => btoa(String.fromCharCode(...new Uint8Array(buffer)))
34
+ this.credentialTarget.value = JSON.stringify({
35
+ id: credential.id,
36
+ rawId: encode(credential.rawId),
37
+ type: credential.type,
38
+ response: {
39
+ clientDataJSON: encode(credential.response.clientDataJSON),
40
+ authenticatorData: encode(credential.response.authenticatorData),
41
+ signature: encode(credential.response.signature),
42
+ userHandle: credential.response.userHandle ? encode(credential.response.userHandle) : null
43
+ }
44
+ })
45
+ this.confirmed = true
46
+ this.form.requestSubmit(event.submitter)
47
+ } catch (error) {
48
+ this.errorTarget.textContent = 'Security key confirmation failed. Try again.'
49
+ }
50
+ }
51
+ }
@@ -47,20 +47,33 @@ module Lato
47
47
  end
48
48
 
49
49
  def session_create(user_id)
50
- # httponly keeps the cookie out of reach of injected scripts; secure avoids leaking it over plain HTTP.
50
+ @session&.destroy
51
+ reset_session
52
+ write_session_cookie(Lato::Session.generate_session_per_user(user_id))
53
+ end
54
+
55
+ def session_update(extra_params)
56
+ value = @session.update(extra_params)
57
+ return false unless value
58
+
59
+ write_session_cookie(value)
60
+ end
61
+
62
+ def write_session_cookie(value)
63
+ @session = Lato::Session.new(value)
51
64
  cookies.encrypted[:lato_session] = {
52
- value: Lato::Session.generate_session_per_user(user_id),
53
- expires: Lato.config.session_lifetime.from_now,
65
+ value: value,
66
+ expires: @session.expires_at,
54
67
  httponly: true,
55
68
  secure: request.ssl?
56
69
  }
57
- @session = Lato::Session.new(cookies.encrypted[:lato_session])
58
-
59
70
  true
60
71
  end
61
72
 
62
73
  def session_destroy
63
- cookies.encrypted[:lato_session] = nil
74
+ @session&.destroy
75
+ cookies.delete(:lato_session)
76
+ reset_session
64
77
 
65
78
  true
66
79
  end
@@ -3,8 +3,20 @@ module Lato
3
3
  before_action :authenticate_session
4
4
  before_action { active_navbar(:account) }
5
5
 
6
+ before_action :discard_expired_security_enrollment
7
+ before_action :limit_requests, only: %i[update_user_action update_password_action update_authenticator_action update_webauthn_action update_web3_action destroy_action security_challenge]
8
+ before_action :reauthenticate_sensitive_action, only: %i[update_user_action update_password_action update_authenticator_action update_webauthn_action update_web3_action destroy_action]
9
+
6
10
  def index; end
7
11
 
12
+ def security_challenge
13
+ return head :not_found unless @session.user.webauthn_enabled?
14
+
15
+ options = @session.user.webauthn_authentication_options
16
+ @session.set_security_challenge(options.challenge)
17
+ render json: options.as_json
18
+ end
19
+
8
20
  def update_user_action
9
21
  respond_to do |format|
10
22
  if @session.user.update(params.require(:user).permit(:first_name, :last_name, :email))
@@ -20,25 +32,31 @@ module Lato
20
32
  def update_authenticator_action
21
33
  return respond_to_with_not_found unless Lato.config.authenticator_connection
22
34
 
23
- if @session.user.authenticator_secret
24
- respond_to do |format|
25
- if @session.user.remove_authenticator_secret
26
- format.html { redirect_to lato.account_path }
27
- format.json { render json: @session.user }
28
- else
29
- format.html { render :index, status: :unprocessable_entity }
30
- format.json { render json: @session.user.errors, status: :unprocessable_entity }
31
- end
32
- end
35
+ user = @session.user
36
+ if user.authenticator_enabled?
37
+ success = user.remove_authenticator_secret
38
+ elsif params.dig(:user, :authenticator_command) == "confirm"
39
+ pending = session.delete(:pending_authenticator_secret)
40
+ success = pending.present? && ROTP::TOTP.new(pending).verify(params.dig(:user, :authenticator_code).to_s)
41
+ success = user.update(authenticator_secret: pending) if success
42
+ user.errors.add(:base, :authenticator_code_invalid) unless success
43
+ @session.clear_security_continuation
33
44
  else
34
- respond_to do |format|
35
- if @session.user.generate_authenticator_secret
36
- format.html { redirect_to lato.account_path }
37
- format.json { render json: @session.user }
38
- else
39
- format.html { render :index, status: :unprocessable_entity }
40
- format.json { render json: @session.user.errors, status: :unprocessable_entity }
41
- end
45
+ session[:pending_authenticator_secret] = ROTP::Base32.random
46
+ grant_security_continuation
47
+ return respond_to do |format|
48
+ format.html { redirect_to lato.account_path }
49
+ format.json { render json: { secret: session[:pending_authenticator_secret] } }
50
+ end
51
+ end
52
+
53
+ respond_to do |format|
54
+ if success
55
+ format.html { redirect_to lato.account_path }
56
+ format.json { render json: user }
57
+ else
58
+ format.html { render :index, status: :unprocessable_entity }
59
+ format.json { render json: user.errors, status: :unprocessable_entity }
42
60
  end
43
61
  end
44
62
  end
@@ -76,6 +94,7 @@ module Lato
76
94
  format.json { render json: @session.user.errors, status: :unprocessable_entity }
77
95
  end
78
96
  else
97
+ grant_security_continuation
79
98
  options = @session.user.webauthn_registration_options
80
99
  session[:webauthn_registration_challenge] = Base64.strict_encode64(options.challenge)
81
100
  session[:webauthn_registration_options] = options.as_json
@@ -119,6 +138,7 @@ module Lato
119
138
  end
120
139
  else
121
140
  respond_to do |format|
141
+ grant_security_continuation
122
142
  if session[:web3_nonce] = SecureRandom.hex(32)
123
143
  format.html { redirect_to lato.account_path }
124
144
  format.json { render json: @session.user }
@@ -145,8 +165,9 @@ module Lato
145
165
  def update_password_action
146
166
  respond_to do |format|
147
167
  if @session.user.update(params.require(:user).permit(:password, :password_confirmation))
148
- format.html { redirect_to lato.account_path, notice: I18n.t('lato.account_controller.update_password_action_notice') }
149
- format.json { render json: @session.user }
168
+ session_destroy
169
+ format.html { redirect_to lato.authentication_signin_path, notice: I18n.t('lato.account_controller.update_password_action_notice') }
170
+ format.json { render json: {} }
150
171
  else
151
172
  format.html { render :index, status: :unprocessable_entity }
152
173
  format.json { render json: @session.user.errors, status: :unprocessable_entity }
@@ -194,7 +215,58 @@ module Lato
194
215
 
195
216
  private
196
217
 
218
+ def discard_expired_security_enrollment
219
+ action = @session.security_continuation_action
220
+ session.delete(:pending_authenticator_secret) unless action == "update_authenticator_action"
221
+ unless action == "update_webauthn_action"
222
+ session.delete(:webauthn_registration_challenge)
223
+ session.delete(:webauthn_registration_options)
224
+ end
225
+ session.delete(:web3_nonce) unless action == "update_web3_action"
226
+ end
227
+
228
+ def reauthenticate_sensitive_action
229
+ if action_name == "update_user_action"
230
+ email = params.dig(:user, :email)
231
+ return if email.nil? || email.to_s.downcase.strip == @session.user.email
232
+ end
233
+ return if action_name == "update_webauthn_action" && params.dig(:user, :webauthn_command) == "cancel"
234
+
235
+ continuation = case action_name
236
+ when "update_authenticator_action"
237
+ params.dig(:user, :authenticator_command) == "confirm"
238
+ when "update_webauthn_action"
239
+ params.dig(:user, :webauthn_command) == "complete"
240
+ when "update_web3_action"
241
+ session[:web3_nonce].present?
242
+ end
243
+ return if continuation && @session.consume_security_continuation(action_name)
244
+
245
+ user = @session.user
246
+ password_valid = user.authenticate(params.dig(:user, :current_password).to_s)
247
+ factor_valid = if user.authenticator_enabled?
248
+ user.authenticator(authenticator_code: params.dig(:user, :current_authenticator_code).to_s)
249
+ elsif user.webauthn_enabled?
250
+ challenge = @session.take_security_challenge
251
+ user.webauthn_authentication({ webauthn_credential: params.dig(:user, :current_webauthn_credential) }, challenge)
252
+ else
253
+ true
254
+ end
255
+ return if password_valid && factor_valid
256
+
257
+ user.errors.add(:base, "Confirm your current password and enabled second factor to continue.")
258
+ respond_to do |format|
259
+ format.html { render :index, status: :unprocessable_entity }
260
+ format.json { render json: user.errors, status: :unprocessable_entity }
261
+ end
262
+ end
263
+
264
+ def grant_security_continuation
265
+ @session.grant_security_continuation(action_name)
266
+ end
267
+
197
268
  def reset_webauthn_registration_state
269
+ @session.clear_security_continuation
198
270
  session[:webauthn_registration_challenge] = nil
199
271
  session[:webauthn_registration_options] = nil
200
272
  end
@@ -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
@@ -21,6 +21,8 @@ module Lato
21
21
  # Relations
22
22
  ##
23
23
 
24
+ has_many :lato_session_records, class_name: "Lato::SessionRecord", foreign_key: :lato_user_id, dependent: :delete_all
25
+
24
26
  has_many :lato_operations, class_name: 'Lato::Operation', foreign_key: :lato_user_id, dependent: :nullify
25
27
  has_many :lato_invitations, class_name: 'Lato::Invitation', foreign_key: :lato_user_id, dependent: :nullify
26
28
  has_many :lato_invitations_as_inviter, class_name: 'Lato::Invitation', foreign_key: :inviter_lato_user_id, dependent: :nullify
@@ -46,6 +48,16 @@ module Lato
46
48
  self.accepted_terms_and_conditions_version = Lato.config.legal_terms_and_conditions_version if accepted_terms_and_conditions_version_changed?
47
49
  end
48
50
 
51
+ after_update do
52
+ lato_session_records.delete_all if saved_change_to_password_digest?
53
+ if saved_change_to_email?
54
+ saved_change_to_email.each do |address|
55
+ Rails.cache.delete("Lato::User/c_email_verification_code/#{id}/#{Digest::SHA256.hexdigest(address.to_s)}")
56
+ end
57
+ c_email_verification_semaphore(false)
58
+ end
59
+ end
60
+
49
61
  # Questions
50
62
  ##
51
63
 
@@ -86,17 +98,17 @@ module Lato
86
98
 
87
99
  def email_protected
88
100
  return email unless email.include?('@')
89
-
101
+
90
102
  local_part, domain = email.split('@', 2)
91
-
103
+
92
104
  # Keep first 2 characters of local part
93
105
  chars_to_keep = [local_part.length, 2].min
94
106
  protected_local = local_part[0, chars_to_keep] + '*' * [local_part.length - chars_to_keep, 0].max
95
-
107
+
96
108
  # Keep domain as is or partially protect it
97
109
  chars_to_keep = [domain.length, 2].min
98
110
  protected_domain = domain[0, chars_to_keep] + '*' * [domain.length - chars_to_keep, 0].max
99
-
111
+
100
112
  "#{protected_local}@#{protected_domain}"
101
113
  end
102
114
 
@@ -104,8 +116,8 @@ module Lato
104
116
  @gravatar_image_url ||= "https://www.gravatar.com/avatar/#{Digest::MD5.hexdigest(email)}?s=#{size}"
105
117
  end
106
118
 
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)}"
119
+ def authenticator_qr_code_base64(size = 200, secret: authenticator_secret)
120
+ "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
121
  end
110
122
 
111
123
  # Operations
@@ -228,8 +240,10 @@ module Lato
228
240
  c_email_verification_code('')
229
241
  c_email_verification_semaphore(false)
230
242
 
231
- update_column(:email_verified_at, Time.now)
232
- true
243
+ # A token for a stale model instance must never verify a concurrently changed address.
244
+ verified = self.class.where(id: id, email: email).update_all(email_verified_at: Time.current) == 1
245
+ errors.add(:base, :email_verification_code_invalid) unless verified
246
+ verified
233
247
  end
234
248
 
235
249
  # NOTE: Always succeeds for a well formed email, registered or not, so the response can not be used to enumerate users.
@@ -275,11 +289,7 @@ module Lato
275
289
  c_password_update_code('')
276
290
  c_password_update_semaphore(false)
277
291
 
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
- ))
292
+ update(params.permit(:password, :password_confirmation))
283
293
  end
284
294
 
285
295
  def update_accepted_privacy_policy_version(params)
@@ -403,16 +413,16 @@ module Lato
403
413
  end
404
414
 
405
415
  parsed_payload = JSON.parse(params[:webauthn_credential])
406
-
416
+
407
417
  # Converti i campi da base64url a base64 standard per il gem webauthn
408
418
  parsed_payload['rawId'] = base64url_to_base64(parsed_payload['rawId'])
409
419
  parsed_payload['response']['clientDataJSON'] = base64url_to_base64(parsed_payload['response']['clientDataJSON'])
410
420
  parsed_payload['response']['authenticatorData'] = base64url_to_base64(parsed_payload['response']['authenticatorData'])
411
421
  parsed_payload['response']['signature'] = base64url_to_base64(parsed_payload['response']['signature'])
412
422
  parsed_payload['response']['userHandle'] = base64url_to_base64(parsed_payload['response']['userHandle']) if parsed_payload['response']['userHandle']
413
-
423
+
414
424
  credential = WebAuthn::Credential.from_get(parsed_payload)
415
-
425
+
416
426
  credential.verify(
417
427
  encoded_challenge,
418
428
  public_key: webauthn_public_key,
@@ -480,7 +490,7 @@ module Lato
480
490
  end
481
491
 
482
492
  def c_email_verification_code(value = nil)
483
- cache_key = "Lato::User/c_email_verification_code/#{id}"
493
+ cache_key = "Lato::User/c_email_verification_code/#{id}/#{Digest::SHA256.hexdigest(email.to_s)}"
484
494
  return Rails.cache.read(cache_key) if value.nil?
485
495
 
486
496
  Rails.cache.write(cache_key, value, expires_in: 30.minutes)
@@ -1,42 +1,24 @@
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="Authenticator enrollment QR code" src="<%= user.authenticator_qr_code_base64(secret: session[:pending_authenticator_secret]) %>" />
29
13
  </div>
14
+ <%= form.hidden_field :authenticator_command, value: "confirm" %>
15
+ <%= form.label :authenticator_code, "Confirm authenticator code", class: "form-label" %>
16
+ <%= 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] %>
30
18
  <% 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>
39
- </div>
19
+ <div class="alert alert-light"><%= raw I18n.t('lato.account_authenticator_start_description') %></div>
20
+ <%= render 'lato/account/security-confirmation', form: form, user: user %>
21
+ <%= lato_form_submit form, I18n.t('lato.account_authenticator_generate_qr_code'), class: %w[btn-primary] %>
40
22
  <% end %>
41
23
  <% end %>
42
24
  <% 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,6 +8,7 @@ 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">
@@ -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">
@@ -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, "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, "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">Confirm with your registered security key when submitting.</p>
10
+ <% end %>
11
+ <p class="text-danger mt-2" data-lato-account-security-target="error" role="alert"></p>
12
+ </div>
data/config/routes.rb CHANGED
@@ -40,6 +40,7 @@ Lato::Engine.routes.draw do
40
40
 
41
41
  scope :account do
42
42
  get '', to: 'account#index', as: :account
43
+ post 'security_challenge', to: 'account#security_challenge', as: :account_security_challenge
43
44
  patch 'update_user_action', to: 'account#update_user_action', as: :account_update_user_action
44
45
  patch 'update_authenticator_action', to: 'account#update_authenticator_action', as: :account_update_authenticator_action
45
46
  patch 'update_webauthn_action', to: 'account#update_webauthn_action', as: :account_update_webauthn_action
@@ -0,0 +1,16 @@
1
+ class CreateLatoSessionRecords < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :lato_session_records do |t|
4
+ t.references :lato_user, null: false, foreign_key: true
5
+ t.string :token_digest, null: false
6
+ t.datetime :expires_at, null: false
7
+ t.string :security_grant_action
8
+ t.datetime :security_grant_expires_at
9
+ t.string :security_challenge
10
+ t.datetime :security_challenge_expires_at
11
+ t.timestamps
12
+ end
13
+ add_index :lato_session_records, :token_digest, unique: true
14
+ add_index :lato_session_records, :expires_at
15
+ end
16
+ end
data/lib/lato/engine.rb CHANGED
@@ -12,6 +12,10 @@ module Lato
12
12
  app.config.assets.precompile << "lato_manifest.js"
13
13
  end
14
14
 
15
+ initializer "lato.filter_credentials" do |app|
16
+ app.config.filter_parameters += %i[current_password current_authenticator_code current_webauthn_credential authenticator_code authenticator_secret webauthn_credential web3_signed_nonce code accepted_code]
17
+ end
18
+
15
19
  config.after_initialize do
16
20
  if Lato.config.webauthn_connection
17
21
  WebAuthn.configure do |config|
data/lib/lato/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Lato
2
- VERSION = "3.24.0"
2
+ VERSION = "3.25.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lato
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.24.0
4
+ version: 3.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gregorio Galante
@@ -15,14 +15,20 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 7.0.4
18
+ version: 8.1.3.1
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '8.2'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
22
25
  requirements:
23
26
  - - ">="
24
27
  - !ruby/object:Gem::Version
25
- version: 7.0.4
28
+ version: 8.1.3.1
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '8.2'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: rails-i18n
28
34
  requirement: !ruby/object:Gem::Requirement
@@ -182,6 +188,7 @@ files:
182
188
  - app/assets/javascripts/lato/controllers/application.js
183
189
  - app/assets/javascripts/lato/controllers/index.js
184
190
  - app/assets/javascripts/lato/controllers/lato_account_form_user_controller.js
191
+ - app/assets/javascripts/lato/controllers/lato_account_security_controller.js
185
192
  - app/assets/javascripts/lato/controllers/lato_account_webauthn_controller.js
186
193
  - app/assets/javascripts/lato/controllers/lato_action_controller.js
187
194
  - app/assets/javascripts/lato/controllers/lato_aside_opener_controller.js
@@ -226,6 +233,7 @@ files:
226
233
  - app/models/lato/log/user_signup.rb
227
234
  - app/models/lato/operation.rb
228
235
  - app/models/lato/session.rb
236
+ - app/models/lato/session_record.rb
229
237
  - app/models/lato/user.rb
230
238
  - app/views/lato/account/_alert-accepted-privacy-policy-version.html.erb
231
239
  - app/views/lato/account/_alert-accepted-terms-and-conditions-version.html.erb
@@ -235,6 +243,7 @@ files:
235
243
  - app/views/lato/account/_form-user.html.erb
236
244
  - app/views/lato/account/_form-web3.html.erb
237
245
  - app/views/lato/account/_form-webauthn.html.erb
246
+ - app/views/lato/account/_security-confirmation.html.erb
238
247
  - app/views/lato/account/index.html.erb
239
248
  - app/views/lato/authentication/_fields-registration.html.erb
240
249
  - app/views/lato/authentication/_form-accept-invitation.html.erb
@@ -312,6 +321,7 @@ files:
312
321
  - db/migrate/20240318074025_add_authenticator_secret_to_user.rb
313
322
  - db/migrate/20251206170443_add_webauthn_id_to_user.rb
314
323
  - db/migrate/20251218192614_add_logs_on_lato_operation.rb
324
+ - db/migrate/20261024000000_create_lato_session_records.rb
315
325
  - lib/lato.rb
316
326
  - lib/lato/btstrap.rb
317
327
  - lib/lato/config.rb