lato 3.25.0 → 3.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +50 -20
- 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/assets/stylesheets/lato/application.scss +17 -0
- data/app/controllers/concerns/lato/componentable.rb +20 -8
- data/app/controllers/concerns/lato/sessionable.rb +39 -10
- data/app/controllers/lato/account_controller.rb +10 -8
- 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 +52 -17
- 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/user.rb +39 -9
- 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 +7 -3
- data/app/views/lato/account/_form-password.html.erb +3 -2
- data/app/views/lato/account/_form-web3.html.erb +5 -4
- data/app/views/lato/account/_form-webauthn.html.erb +3 -3
- data/app/views/lato/account/_security-confirmation.html.erb +8 -5
- data/app/views/lato/account/index.html.erb +1 -1
- 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/_form_item_input_signature.html.erb +1 -1
- data/app/views/lato/components/_index.html.erb +27 -22
- data/app/views/lato/components/_navbar_nav_item.html.erb +1 -1
- data/app/views/lato/components/_operation.html.erb +6 -3
- 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 +33 -0
- data/config/locales/fr.yml +30 -0
- data/config/locales/it.yml +30 -0
- data/config/locales/ro.yml +30 -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 +5 -0
- data/lib/lato/version.rb +1 -1
- metadata +22 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 59109ceb11a3fb4c78126d99664864266b1d25ac77ff36ef8f6cf3bc5a66ab18
|
|
4
|
+
data.tar.gz: 7ca6befa9186b81529841c3b25409396beb26b154f99c605557c0b1106e32594
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 33934d542029ce620a7ff4e06c03a2eab2bc7fff362d07237641329724cdac95541b6ea98fb0ad171d84a007705593896a796d2e7648387c4269b22c6812665c
|
|
7
|
+
data.tar.gz: 5797d50477282510f006e201009aa8c0e7b21dd6c7cadf5e80c0dfbbda8eabbadf88669a06f7425d1d95953ac8ffa2a8ae809dd1333959849805491d3cec6bf8
|
data/README.md
CHANGED
|
@@ -5,33 +5,19 @@
|
|
|
5
5
|
Lato is a Rails Engine that offers a comprehensive web application starter kit with user management, authentication, and UI components for [Ruby on Rails](https://rubyonrails.org/).
|
|
6
6
|
|
|
7
7
|
This gem includes:
|
|
8
|
-
- User management with **full authentication system** (signin, signup, recover password, email validation, Google Authenticator multi-factor authentication, ETH wallet connection) and **account management** (user settings, password change, email change, etc.);
|
|
8
|
+
- User management with **full authentication system** (signin, signup, recover password, email validation, Google Authenticator and WebAuthn/passkey multi-factor authentication, ETH wallet connection) and **account management** (user settings, password change, email change, etc.);
|
|
9
9
|
- **Web interface** built with [Bootstrap](https://getbootstrap.com/) and [Bootstrap Icons](https://icons.getbootstrap.com/);
|
|
10
10
|
- **UI components integrated with Rails** (data tables, forms, modals, async job executions etc.);
|
|
11
|
+
- **Accessibility built in**: labelled form fields with linked error messages, keyboard friendly modals with focus return, skip link, `aria-sort` on tables, live regions for feedback and WCAG 2.2 target sizes. Core pages report zero axe-core violations on WCAG 2.2 AA rules;
|
|
11
12
|
|
|
12
13
|
The gem is **agnostic to the application stack**. You can use it with different databases (PostgreSQL, MySQL, SQLite), different Job processors (Solid queue, Sidekiq, Delayed Job, etc.), different cache systems (Redis, Memcached, etc.).
|
|
13
14
|
|
|
14
15
|
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
16
|
|
|
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
|
|
17
|
+
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 and mobile friendly, with accessibility improvements in progress (see the component notes in the documentation).
|
|
17
18
|
|
|
18
19
|
<img src="./preview.gif" alt="Lato preview" width="100%">
|
|
19
20
|
|
|
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
|
-
|
|
35
21
|
## Full documentation
|
|
36
22
|
|
|
37
23
|
The full documentation is available here: 👉 👉 [DOCUMENTATION](https://lato-oss.gregoriogalante.com) 👈 👈
|
|
@@ -56,13 +42,13 @@ Add required dependencies to your application's Gemfile:
|
|
|
56
42
|
|
|
57
43
|
```ruby
|
|
58
44
|
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
|
|
59
|
-
gem "importmap-rails" # NOTE: Probably already installed in default
|
|
45
|
+
gem "importmap-rails" # NOTE: Probably already installed in default Rails 8 project
|
|
60
46
|
|
|
61
47
|
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
|
|
62
|
-
gem "turbo-rails" # NOTE: Probably already installed in default
|
|
48
|
+
gem "turbo-rails" # NOTE: Probably already installed in default Rails 8 project
|
|
63
49
|
|
|
64
50
|
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
|
|
65
|
-
gem "stimulus-rails" # NOTE: Probably already installed in default
|
|
51
|
+
gem "stimulus-rails" # NOTE: Probably already installed in default Rails 8 project
|
|
66
52
|
|
|
67
53
|
# Use Sass to process CSS
|
|
68
54
|
gem "sassc-rails"
|
|
@@ -90,6 +76,10 @@ rails lato:install:migrations
|
|
|
90
76
|
rails db:migrate
|
|
91
77
|
```
|
|
92
78
|
|
|
79
|
+
Lato stores rate limits, password recovery codes, email verification codes and semaphores in `Rails.cache`. Use a persistent cache shared across processes (Redis, Solid Cache, Memcached): with `:null_store` password recovery never works and rate limits are disabled, with `:memory_store` and multiple processes limits are per process.
|
|
80
|
+
|
|
81
|
+
To use the Web3 (ETH wallet) authentication add `gem "eth"` to your Gemfile, it is not a dependency of Lato.
|
|
82
|
+
|
|
93
83
|
Mount lato routes on the **config/routes.rb** file:
|
|
94
84
|
|
|
95
85
|
```ruby
|
|
@@ -128,6 +118,8 @@ end
|
|
|
128
118
|
|
|
129
119
|
```
|
|
130
120
|
|
|
121
|
+
Lato ships translations for `it`, `en`, `fr` and `ro`. Any key can be overridden in your application locale files. Emails exist only in Italian and English: users with locale `it` receive Italian emails, every other locale receives English.
|
|
122
|
+
|
|
131
123
|
### Extra tasks
|
|
132
124
|
Lato integrates by default a basic PWAs manifest and service worker. To re-generate them with all required assets follow these steps:
|
|
133
125
|
|
|
@@ -151,6 +143,44 @@ rails lato:install:migrations
|
|
|
151
143
|
rails db:migrate
|
|
152
144
|
```
|
|
153
145
|
|
|
146
|
+
### Upgrading to the latest release
|
|
147
|
+
|
|
148
|
+
- Run `rails lato:install:migrations && rails db:migrate`: the release adds `authenticator_last_used_at` and `webauthn_sign_count` to `lato_users`.
|
|
149
|
+
- `lato_page_head(title)` now escapes the title. Pass an `html_safe` string to render HTML.
|
|
150
|
+
- `lato_form_item_input_check` escapes the label unless it is `html_safe`.
|
|
151
|
+
- `data-turbo-confirm` messages and `data-action-title` are rendered as plain text.
|
|
152
|
+
- New config `Lato.config.auth_password_min_length` (default 8), checked whenever a password is set.
|
|
153
|
+
- Rate limits are stricter: `limit_requests` allows exactly `limit` requests per IP and action, signin is also limited per email (10 failed attempts in 15 minutes) and second factor attempts are counted per user. The pending second factor step expires after 10 minutes or 5 wrong codes.
|
|
154
|
+
- TOTP codes can be used only once and the WebAuthn signature counter is enforced.
|
|
155
|
+
- Web3 signin uses a message bound to host, nonce and issue time (single use, 5 minutes) and requires `gem "eth"`.
|
|
156
|
+
- `switch_locale` redirects only to same-host referers. The GET route `switch_locale_get` is deprecated: use POST `lato.switch_locale_path(locale:)`.
|
|
157
|
+
- `Lato::Operation#start` only starts classes inheriting from `Lato::ApplicationJob`.
|
|
158
|
+
- Schedule the maintenance jobs `Lato::LatoOperationsCleanerJob` and `Lato::ActiveStorageCleanerJob` (see the operations documentation).
|
|
159
|
+
|
|
160
|
+
### Upgrading to 3.25
|
|
161
|
+
|
|
162
|
+
Lato 3.25 requires Rails >= 8.1.3.1 and < 8.2. Install the new session migration before serving requests (`rails lato:install:migrations && rails db:migrate`).
|
|
163
|
+
|
|
164
|
+
Sessions are stored as revocable token hashes in the database. Existing cookies require a fresh sign-in after upgrading (global logout). Signing out revokes the current device; changing or recovering a password revokes all devices. Password recovery preserves existing second factors and does not disable them.
|
|
165
|
+
|
|
166
|
+
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.
|
|
167
|
+
|
|
168
|
+
Extensions updating session attributes must call `session_update(extra_params)` to preserve the revocable session and secure cookie attributes.
|
|
169
|
+
|
|
170
|
+
## Production security checklist
|
|
171
|
+
|
|
172
|
+
- Set `config.force_ssl = true`: the session cookie is `secure` only when the request is SSL.
|
|
173
|
+
- Use a persistent `Rails.cache` shared across processes (Redis, Solid Cache, Memcached). Recovery codes, verification codes and all rate limits depend on it; Lato logs a warning at boot in production if it is a `NullStore`.
|
|
174
|
+
- Set `config.action_controller.raise_on_open_redirects = true`.
|
|
175
|
+
- Content Security Policy: the Web3 and verify-email views use inline scripts, so those pages need `unsafe-inline` or nonces. `ethers` 5.7.2 is loaded from cdnjs with a dynamic `import()`: pinned to an exact version but without SRI. Vendor it if you need strict supply-chain control.
|
|
176
|
+
- `session_lifetime` (default 30 days) is an absolute lifetime of cookie and database record, with no sliding renewal or idle timeout.
|
|
177
|
+
- WebAuthn: use HTTPS in `webauthn_origin` (a string or an array of origins). There is one credential per user, no backup codes, and user verification is not required.
|
|
178
|
+
- hCaptcha is optional (`hcaptcha_site_key`, `hcaptcha_secret`) and protects signup and password recovery.
|
|
179
|
+
- Schedule `Lato::LatoOperationsCleanerJob` and `Lato::ActiveStorageCleanerJob`, they are not scheduled automatically.
|
|
180
|
+
- Invitations do not expire: destroy pending ones that are no longer needed.
|
|
181
|
+
- `authenticator_secret` is stored in clear in the database: consider Active Record encryption.
|
|
182
|
+
- Signin per-email limiting can be used to temporarily lock a known email: monitor failed signins.
|
|
183
|
+
|
|
154
184
|
## Development
|
|
155
185
|
|
|
156
186
|
Clone repository, install dependencies, run migrations and start:
|
|
@@ -83,11 +83,14 @@ export default class extends Controller {
|
|
|
83
83
|
|
|
84
84
|
showError(message) {
|
|
85
85
|
if (this.hasStatusTarget) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
const alert = document.createElement('div')
|
|
87
|
+
alert.className = 'alert alert-danger'
|
|
88
|
+
alert.setAttribute('role', 'alert')
|
|
89
|
+
const heading = document.createElement('h4')
|
|
90
|
+
heading.className = 'alert-heading'
|
|
91
|
+
heading.textContent = message
|
|
92
|
+
alert.appendChild(heading)
|
|
93
|
+
this.statusTarget.replaceChildren(alert)
|
|
91
94
|
}
|
|
92
95
|
}
|
|
93
96
|
}
|
|
@@ -17,6 +17,7 @@ export default class extends Controller {
|
|
|
17
17
|
connect() {
|
|
18
18
|
this.bsModals = []
|
|
19
19
|
this.usedModals = []
|
|
20
|
+
this.returnFocusElements = []
|
|
20
21
|
|
|
21
22
|
this.modalTargets.forEach((modalTarget, index) => {
|
|
22
23
|
this.bsModals.push(new bootstrap.Modal(modalTarget))
|
|
@@ -39,10 +40,16 @@ export default class extends Controller {
|
|
|
39
40
|
}
|
|
40
41
|
}, 1)
|
|
41
42
|
})
|
|
43
|
+
// Programmatic show() does not restore focus like data-bs-toggle does: give it back to the trigger.
|
|
44
|
+
modalTarget.addEventListener('hidden.bs.modal', () => {
|
|
45
|
+
const element = this.returnFocusElements[index]
|
|
46
|
+
this.returnFocusElements[index] = null
|
|
47
|
+
if (element && element.isConnected) element.focus()
|
|
48
|
+
})
|
|
42
49
|
modalTarget.addEventListener('hide.bs.modal', () => {
|
|
43
50
|
setTimeout(() => {
|
|
44
51
|
this.usedModals = this.usedModals.filter((i) => i != index)
|
|
45
|
-
this.modalBodyTargets[index].
|
|
52
|
+
this.modalBodyTargets[index].replaceChildren()
|
|
46
53
|
}, 500)
|
|
47
54
|
})
|
|
48
55
|
})
|
|
@@ -56,12 +63,12 @@ export default class extends Controller {
|
|
|
56
63
|
|
|
57
64
|
// clean modalTitle content
|
|
58
65
|
this.modalTitleTargets.forEach((modalTitleTarget) => {
|
|
59
|
-
modalTitleTarget.
|
|
66
|
+
modalTitleTarget.textContent = ''
|
|
60
67
|
})
|
|
61
68
|
|
|
62
69
|
// clean modalBody content
|
|
63
70
|
this.modalBodyTargets.forEach((modalBodyTarget) => {
|
|
64
|
-
modalBodyTarget.
|
|
71
|
+
modalBodyTarget.replaceChildren()
|
|
65
72
|
})
|
|
66
73
|
}
|
|
67
74
|
|
|
@@ -108,19 +115,22 @@ export default class extends Controller {
|
|
|
108
115
|
const index = this.getFreeModalIndex()
|
|
109
116
|
|
|
110
117
|
if (options.turboFrame && options.turboFrame != '_top') {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
this.modalTitleTargets[index].innerHTML = ''
|
|
118
|
+
// Build nodes instead of HTML strings: frame id and title come from element attributes.
|
|
119
|
+
const turboFrame = document.createElement('turbo-frame')
|
|
120
|
+
turboFrame.id = options.turboFrame
|
|
121
|
+
turboFrame.setAttribute('aria-busy', 'true')
|
|
122
|
+
turboFrame.addEventListener('turbo:frame-load', () => turboFrame.removeAttribute('aria-busy'), { once: true })
|
|
123
|
+
const placeholder = document.createElement('div')
|
|
124
|
+
placeholder.className = 'placeholder-glow'
|
|
125
|
+
for (let i = 0; i < options.actionRows; i++) {
|
|
126
|
+
const row = document.createElement('span')
|
|
127
|
+
row.className = 'placeholder placeholder-lg col-12'
|
|
128
|
+
placeholder.appendChild(row)
|
|
123
129
|
}
|
|
130
|
+
turboFrame.appendChild(placeholder)
|
|
131
|
+
this.modalBodyTargets[index].replaceChildren(turboFrame)
|
|
132
|
+
|
|
133
|
+
this.modalTitleTargets[index].textContent = options.actionTitle || ''
|
|
124
134
|
|
|
125
135
|
if (options.actionSize) {
|
|
126
136
|
this.modalDialogTargets[index].classList.remove('modal-md')
|
|
@@ -130,6 +140,7 @@ export default class extends Controller {
|
|
|
130
140
|
this.modalDialogTargets[index].classList.add(`modal-${options.actionSize}`)
|
|
131
141
|
}
|
|
132
142
|
|
|
143
|
+
this.returnFocusElements[index] = document.activeElement
|
|
133
144
|
this.bsModals[index].show()
|
|
134
145
|
}
|
|
135
146
|
}
|
|
@@ -18,36 +18,46 @@ export default class extends Controller {
|
|
|
18
18
|
if (bsModalBackdropElement) bsModalBackdropElement.style.zIndex = 2000
|
|
19
19
|
}, 1)
|
|
20
20
|
})
|
|
21
|
+
|
|
22
|
+
// Any dismissal (backdrop, ESC, close button) must settle the pending confirm, otherwise the form hangs.
|
|
23
|
+
this.element.addEventListener('hidden.bs.modal', () => {
|
|
24
|
+
this.resolveConfirm(false)
|
|
25
|
+
this.modalBodyTarget.textContent = ''
|
|
26
|
+
// Programmatic show() does not restore focus like data-bs-toggle does: give it back to the trigger.
|
|
27
|
+
if (this._returnFocusElement && this._returnFocusElement.isConnected) this._returnFocusElement.focus()
|
|
28
|
+
this._returnFocusElement = null
|
|
29
|
+
})
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
disconnect() {
|
|
33
|
+
this.resolveConfirm(false)
|
|
24
34
|
Turbo.config.forms.confirm = window.confirm
|
|
25
35
|
}
|
|
26
36
|
|
|
27
|
-
|
|
28
|
-
this.
|
|
37
|
+
customConfirm(message) {
|
|
38
|
+
this.resolveConfirm(false)
|
|
39
|
+
this._returnFocusElement = document.activeElement
|
|
40
|
+
// textContent: the message comes from data-turbo-confirm and may include user data.
|
|
41
|
+
this.modalBodyTarget.textContent = message
|
|
29
42
|
this.modal.show()
|
|
30
43
|
|
|
31
|
-
this.
|
|
32
|
-
while (this._result === undefined) {
|
|
33
|
-
await new Promise(resolve => setTimeout(resolve, 100))
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return this._result
|
|
44
|
+
return new Promise(resolve => { this._resolve = resolve })
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
confirmSuccess() {
|
|
40
|
-
this.
|
|
41
|
-
this.
|
|
48
|
+
this.resolveConfirm(true)
|
|
49
|
+
this.modal.hide()
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
confirmCancel() {
|
|
45
|
-
this.
|
|
46
|
-
this.
|
|
53
|
+
this.resolveConfirm(false)
|
|
54
|
+
this.modal.hide()
|
|
47
55
|
}
|
|
48
56
|
|
|
49
|
-
|
|
50
|
-
this.
|
|
51
|
-
|
|
57
|
+
resolveConfirm(result) {
|
|
58
|
+
if (!this._resolve) return
|
|
59
|
+
|
|
60
|
+
this._resolve(result)
|
|
61
|
+
this._resolve = null
|
|
52
62
|
}
|
|
53
63
|
}
|
|
@@ -10,6 +10,18 @@ export default class extends Controller {
|
|
|
10
10
|
if (this.hasButtonTarget) {
|
|
11
11
|
this.buttonTargetOriginalHTML = this.buttonTarget.innerHTML
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
// Live region created up front: screen readers only announce changes to a region already in the DOM.
|
|
15
|
+
this.statusElement = document.createElement('span')
|
|
16
|
+
this.statusElement.className = 'visually-hidden'
|
|
17
|
+
this.statusElement.setAttribute('role', 'status')
|
|
18
|
+
this.statusElement.setAttribute('aria-live', 'polite')
|
|
19
|
+
this.element.appendChild(this.statusElement)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
disconnect() {
|
|
23
|
+
if (this._copyTimeout) clearTimeout(this._copyTimeout)
|
|
24
|
+
this.statusElement?.remove()
|
|
13
25
|
}
|
|
14
26
|
|
|
15
27
|
onButtonClick(e) {
|
|
@@ -18,7 +30,7 @@ export default class extends Controller {
|
|
|
18
30
|
this.copy()
|
|
19
31
|
}
|
|
20
32
|
|
|
21
|
-
copy() {
|
|
33
|
+
async copy() {
|
|
22
34
|
if (!this.hasInputTarget) return
|
|
23
35
|
|
|
24
36
|
// Copy the text inside the text field
|
|
@@ -26,16 +38,22 @@ export default class extends Controller {
|
|
|
26
38
|
this.inputTarget.setSelectionRange(0, 99999) // For mobile devices
|
|
27
39
|
|
|
28
40
|
// Copy the text inside the text field
|
|
29
|
-
|
|
41
|
+
try {
|
|
42
|
+
await navigator.clipboard.writeText(this.inputTarget.value)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error(error)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const copiedText = (this.hasButtonTarget && this.buttonTarget.dataset.copyText) || 'Copied!'
|
|
49
|
+
this.statusElement.textContent = copiedText
|
|
30
50
|
|
|
31
51
|
// Manage button state
|
|
32
|
-
if (this.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (this.
|
|
36
|
-
this.
|
|
37
|
-
|
|
38
|
-
}, 2000)
|
|
39
|
-
}
|
|
52
|
+
if (this._copyTimeout) clearTimeout(this._copyTimeout)
|
|
53
|
+
if (this.hasButtonTarget) this.buttonTarget.textContent = copiedText
|
|
54
|
+
this._copyTimeout = setTimeout(() => {
|
|
55
|
+
if (this.hasButtonTarget) this.buttonTarget.innerHTML = this.buttonTargetOriginalHTML
|
|
56
|
+
this.statusElement.textContent = ''
|
|
57
|
+
}, 2000)
|
|
40
58
|
}
|
|
41
59
|
}
|
|
@@ -30,6 +30,7 @@ export default class extends Controller {
|
|
|
30
30
|
// This event is used to remove the Bootstrap invalid class from the input when the user starts typing.
|
|
31
31
|
onInputChange(e) {
|
|
32
32
|
e.target.classList.remove('is-invalid')
|
|
33
|
+
e.target.removeAttribute('aria-invalid')
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
// NOTE: You can use this method to submit the form from outside the form or from custom DOM actions.
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
3
|
export default class extends Controller {
|
|
4
|
+
static values = {
|
|
5
|
+
key: String
|
|
6
|
+
}
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* Stimulus
|
|
@@ -15,7 +18,7 @@ export default class extends Controller {
|
|
|
15
18
|
|
|
16
19
|
// This event is used to reset the pagination when the user types in the search input to start a new search.
|
|
17
20
|
onSearchKeyUp(e) {
|
|
18
|
-
const pageInput = this.element.querySelector(
|
|
21
|
+
const pageInput = this.element.querySelector(`input[name="${this.keyValue}_page"]`)
|
|
19
22
|
if (pageInput) pageInput.value = 1
|
|
20
23
|
}
|
|
21
24
|
}
|
|
@@ -38,6 +38,20 @@ $tooltip-max-width: 300px;
|
|
|
38
38
|
margin: 0 4px;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// WCAG 2.2 target size (2.5.8): interactive targets of at least 24x24px.
|
|
42
|
+
.pagination > span > a {
|
|
43
|
+
display: inline-flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
justify-content: center;
|
|
46
|
+
min-width: 24px;
|
|
47
|
+
min-height: 24px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Bootstrap's pink code color fails contrast on tinted alert backgrounds.
|
|
51
|
+
.alert code {
|
|
52
|
+
color: inherit;
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
.pagination .current {
|
|
42
56
|
font-weight: bold;
|
|
43
57
|
}
|
|
@@ -143,6 +157,9 @@ main, aside {
|
|
|
143
157
|
.btn {
|
|
144
158
|
font-size: 12px;
|
|
145
159
|
padding: 0.15rem 0.3rem;
|
|
160
|
+
// 25px: btn-group overlaps adjacent buttons by 1px, the visible target must stay >= 24px (WCAG 2.5.8)
|
|
161
|
+
min-width: 25px;
|
|
162
|
+
min-height: 24px;
|
|
146
163
|
}
|
|
147
164
|
}
|
|
148
165
|
|
|
@@ -35,14 +35,7 @@ module Lato
|
|
|
35
35
|
if collection.respond_to?(:lato_index_search)
|
|
36
36
|
collection = collection.lato_index_search(search)
|
|
37
37
|
else
|
|
38
|
-
|
|
39
|
-
if collection.column_for_attribute(key).type == :string || collection.column_for_attribute(key).type == :text
|
|
40
|
-
"LOWER(#{key}) LIKE :search"
|
|
41
|
-
else
|
|
42
|
-
"CAST(#{key} AS TEXT) LIKE :search"
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
collection = collection.where(query.join(' OR '), search: "%#{search.downcase.strip}%")
|
|
38
|
+
collection = collection.where(lato_index_search_condition(collection, @_lato_index[key][:searchable_columns], search))
|
|
46
39
|
end
|
|
47
40
|
end
|
|
48
41
|
|
|
@@ -64,6 +57,25 @@ module Lato
|
|
|
64
57
|
|
|
65
58
|
private
|
|
66
59
|
|
|
60
|
+
# Built with Arel so columns are quoted and the CAST type fits the adapter (MySQL has no CAST AS TEXT).
|
|
61
|
+
# LIKE wildcards typed by the user are escaped, so "%" and "_" match literally.
|
|
62
|
+
def lato_index_search_condition(collection, columns, search)
|
|
63
|
+
escape_character = '!'
|
|
64
|
+
pattern = "%#{ActiveRecord::Base.sanitize_sql_like(search.downcase.strip, escape_character)}%"
|
|
65
|
+
cast_type = collection.connection.adapter_name.match?(/mysql|trilogy/i) ? 'CHAR' : 'TEXT'
|
|
66
|
+
table = collection.arel_table
|
|
67
|
+
|
|
68
|
+
columns.map do |column|
|
|
69
|
+
attribute = table[column]
|
|
70
|
+
value = if %i[string text].include?(collection.column_for_attribute(column).type)
|
|
71
|
+
Arel::Nodes::NamedFunction.new('LOWER', [attribute])
|
|
72
|
+
else
|
|
73
|
+
Arel::Nodes::NamedFunction.new('CAST', [attribute.as(Arel.sql(cast_type))])
|
|
74
|
+
end
|
|
75
|
+
value.matches(pattern, escape_character)
|
|
76
|
+
end.reduce(:or)
|
|
77
|
+
end
|
|
78
|
+
|
|
67
79
|
def parse_lato_index_sort_by(sort_by, sortable_columns)
|
|
68
80
|
sort_by_column, sort_by_order = sort_by.to_s.split("|", 2)
|
|
69
81
|
sort_by_column = sort_by_column.to_s
|
|
@@ -32,18 +32,46 @@ module Lato
|
|
|
32
32
|
|
|
33
33
|
def limit_requests(limit = 10, time_window = 10.minutes)
|
|
34
34
|
cache_key = "Lato::Sessionable/limit_requests/#{controller_name}/#{action_name}/#{request.remote_ip}"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
attempts
|
|
38
|
-
Rails.cache.write(cache_key, attempts, expires_in: time_window)
|
|
39
|
-
return true unless attempts >= limit
|
|
35
|
+
# increment is atomic on shared stores, so parallel requests can not slip past the limit.
|
|
36
|
+
attempts = Rails.cache.increment(cache_key, 1, expires_in: time_window).to_i
|
|
37
|
+
return true if attempts <= limit
|
|
40
38
|
|
|
39
|
+
respond_to_too_many_requests(time_window)
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Per-account throttle on failed attempts: complements the per-IP limit against distributed brute force.
|
|
44
|
+
ACCOUNT_LIMIT_ATTEMPTS = 10
|
|
45
|
+
ACCOUNT_LIMIT_TIME_WINDOW = 15.minutes
|
|
46
|
+
|
|
47
|
+
def account_limit_reached?(identifier)
|
|
48
|
+
return false if identifier.blank?
|
|
49
|
+
|
|
50
|
+
reached = Rails.cache.read(account_limit_cache_key(identifier), raw: true).to_i >= ACCOUNT_LIMIT_ATTEMPTS
|
|
51
|
+
respond_to_too_many_requests(ACCOUNT_LIMIT_TIME_WINDOW) if reached
|
|
52
|
+
reached
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def account_limit_track_failure(identifier)
|
|
56
|
+
return if identifier.blank?
|
|
57
|
+
|
|
58
|
+
Rails.cache.increment(account_limit_cache_key(identifier), 1, expires_in: ACCOUNT_LIMIT_TIME_WINDOW)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def account_limit_reset(identifier)
|
|
62
|
+
Rails.cache.delete(account_limit_cache_key(identifier)) if identifier.present?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def account_limit_cache_key(identifier)
|
|
66
|
+
"Lato::Sessionable/account_limit/#{Digest::SHA256.hexdigest(identifier.to_s.downcase.strip)}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def respond_to_too_many_requests(time_window)
|
|
70
|
+
message = I18n.t('lato.too_many_requests', minutes: (time_window.to_i / 60.0).ceil)
|
|
41
71
|
respond_to do |format|
|
|
42
|
-
format.html { render plain:
|
|
43
|
-
format.json { render json: {}, status: :too_many_requests }
|
|
72
|
+
format.html { render plain: message, status: :too_many_requests }
|
|
73
|
+
format.json { render json: { error: message }, status: :too_many_requests }
|
|
44
74
|
end
|
|
45
|
-
|
|
46
|
-
false
|
|
47
75
|
end
|
|
48
76
|
|
|
49
77
|
def session_create(user_id)
|
|
@@ -65,7 +93,8 @@ module Lato
|
|
|
65
93
|
value: value,
|
|
66
94
|
expires: @session.expires_at,
|
|
67
95
|
httponly: true,
|
|
68
|
-
secure: request.ssl
|
|
96
|
+
secure: request.ssl?,
|
|
97
|
+
same_site: :lax
|
|
69
98
|
}
|
|
70
99
|
true
|
|
71
100
|
end
|
|
@@ -37,8 +37,9 @@ module Lato
|
|
|
37
37
|
success = user.remove_authenticator_secret
|
|
38
38
|
elsif params.dig(:user, :authenticator_command) == "confirm"
|
|
39
39
|
pending = session.delete(:pending_authenticator_secret)
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
used_at = pending.present? && ROTP::TOTP.new(pending).verify(params.dig(:user, :authenticator_code).to_s, drift_behind: 15)
|
|
41
|
+
# Store the enrollment code timestamp so the same code can not be replayed for the first login.
|
|
42
|
+
success = used_at ? user.update(authenticator_secret: pending, authenticator_last_used_at: Time.at(used_at)) : false
|
|
42
43
|
user.errors.add(:base, :authenticator_code_invalid) unless success
|
|
43
44
|
@session.clear_security_continuation
|
|
44
45
|
else
|
|
@@ -126,12 +127,10 @@ module Lato
|
|
|
126
127
|
end
|
|
127
128
|
elsif session[:web3_nonce]
|
|
128
129
|
respond_to do |format|
|
|
129
|
-
if @session.user.add_web3_connection(params.require(:user).permit(:web3_address, :web3_signed_nonce).merge(web3_nonce:
|
|
130
|
-
session[:web3_nonce] = nil
|
|
130
|
+
if @session.user.add_web3_connection(params.require(:user).permit(:web3_address, :web3_signed_nonce).merge(web3_nonce: web3_message_consume))
|
|
131
131
|
format.html { redirect_to lato.account_path }
|
|
132
132
|
format.json { render json: @session.user }
|
|
133
133
|
else
|
|
134
|
-
session[:web3_nonce] = nil
|
|
135
134
|
format.html { render :index, status: :unprocessable_entity }
|
|
136
135
|
format.json { render json: @session.user.errors, status: :unprocessable_entity }
|
|
137
136
|
end
|
|
@@ -139,7 +138,7 @@ module Lato
|
|
|
139
138
|
else
|
|
140
139
|
respond_to do |format|
|
|
141
140
|
grant_security_continuation
|
|
142
|
-
if
|
|
141
|
+
if web3_message_generate('connect your wallet')
|
|
143
142
|
format.html { redirect_to lato.account_path }
|
|
144
143
|
format.json { render json: @session.user }
|
|
145
144
|
else
|
|
@@ -222,7 +221,10 @@ module Lato
|
|
|
222
221
|
session.delete(:webauthn_registration_challenge)
|
|
223
222
|
session.delete(:webauthn_registration_options)
|
|
224
223
|
end
|
|
225
|
-
|
|
224
|
+
return if action == "update_web3_action"
|
|
225
|
+
|
|
226
|
+
session.delete(:web3_nonce)
|
|
227
|
+
session.delete(:web3_nonce_expires_at)
|
|
226
228
|
end
|
|
227
229
|
|
|
228
230
|
def reauthenticate_sensitive_action
|
|
@@ -254,7 +256,7 @@ module Lato
|
|
|
254
256
|
end
|
|
255
257
|
return if password_valid && factor_valid
|
|
256
258
|
|
|
257
|
-
user.errors.add(:base,
|
|
259
|
+
user.errors.add(:base, I18n.t('lato.security_confirmation_required'))
|
|
258
260
|
respond_to do |format|
|
|
259
261
|
format.html { render :index, status: :unprocessable_entity }
|
|
260
262
|
format.json { render json: user.errors, status: :unprocessable_entity }
|
|
@@ -46,11 +46,35 @@ module Lato
|
|
|
46
46
|
|
|
47
47
|
def respond_to_redirect_same_page(notice = nil)
|
|
48
48
|
respond_to do |format|
|
|
49
|
-
|
|
49
|
+
# Never follow a Referer pointing to another host (open redirect).
|
|
50
|
+
format.html { redirect_back_or_to lato.root_path, allow_other_host: false, notice: notice }
|
|
50
51
|
format.json { render json: {} }
|
|
51
52
|
end
|
|
52
53
|
end
|
|
53
54
|
|
|
55
|
+
# Web3 challenge: the signed text binds the signature to this host, a purpose and a short lifetime,
|
|
56
|
+
# so a signature collected by another site can not be replayed here.
|
|
57
|
+
WEB3_MESSAGE_LIFETIME = 5.minutes
|
|
58
|
+
|
|
59
|
+
def web3_message_generate(purpose)
|
|
60
|
+
session[:web3_nonce] = [
|
|
61
|
+
"#{request.host} wants you to #{purpose} with your Ethereum account.",
|
|
62
|
+
"",
|
|
63
|
+
"URI: #{request.base_url}",
|
|
64
|
+
"Nonce: #{SecureRandom.hex(16)}",
|
|
65
|
+
"Issued At: #{Time.current.utc.iso8601}"
|
|
66
|
+
].join("\n")
|
|
67
|
+
session[:web3_nonce_expires_at] = WEB3_MESSAGE_LIFETIME.from_now.to_i
|
|
68
|
+
session[:web3_nonce]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Single use: the message is removed from the session whatever the outcome.
|
|
72
|
+
def web3_message_consume
|
|
73
|
+
message = session.delete(:web3_nonce)
|
|
74
|
+
expires_at = session.delete(:web3_nonce_expires_at).to_i
|
|
75
|
+
message if message.present? && Time.current.to_i <= expires_at
|
|
76
|
+
end
|
|
77
|
+
|
|
54
78
|
def respond_to_with_offline
|
|
55
79
|
hide_sidebar
|
|
56
80
|
respond_to do |format|
|