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 +4 -4
- data/README.md +16 -1
- data/app/assets/javascripts/lato/controllers/lato_account_security_controller.js +51 -0
- data/app/controllers/concerns/lato/sessionable.rb +19 -6
- data/app/controllers/lato/account_controller.rb +92 -20
- data/app/models/lato/session.rb +73 -19
- data/app/models/lato/session_record.rb +5 -0
- data/app/models/lato/user.rb +27 -17
- data/app/views/lato/account/_form-authenticator.html.erb +15 -33
- data/app/views/lato/account/_form-destroy.html.erb +1 -0
- data/app/views/lato/account/_form-password.html.erb +1 -0
- data/app/views/lato/account/_form-user.html.erb +1 -0
- data/app/views/lato/account/_form-web3.html.erb +1 -0
- data/app/views/lato/account/_form-webauthn.html.erb +1 -0
- data/app/views/lato/account/_security-confirmation.html.erb +12 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20261024000000_create_lato_session_records.rb +16 -0
- data/lib/lato/engine.rb +4 -0
- data/lib/lato/version.rb +1 -1
- metadata +13 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 737de7ae597862df18ec4aa38fce9ac511c884398349122005f9107b2c8904d8
|
|
4
|
+
data.tar.gz: 2aa2e54234a0cccf18be9343e1132c6d04036126e1fe5ff6b0b0e2c0bdda1729
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 **
|
|
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
|
-
|
|
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:
|
|
53
|
-
expires:
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
149
|
-
format.
|
|
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
|
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
|
@@ -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(
|
|
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
|
-
|
|
232
|
-
|
|
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)
|
|
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
|
-
|
|
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="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
|
|
32
|
-
|
|
33
|
-
|
|
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
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.
|
|
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:
|
|
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:
|
|
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
|