fix: auth review fixes — token revocation, WS auth, seeding, and hardening

Refresh/logout and token robustness
- drop the post-rotation refresh_tokens row delete in auth_refresh so
  logout blacklists the current (rotated) refresh token; remove the
  dead _clear_refresh_token_after_rotation helper and clear_active_refresh_token
- reject non-object JWT payloads in _extract_unverified_sub so crafted
  Authorization headers return 401 instead of crashing with 500

SQLite user store
- make builtin-admin seeding idempotent: on a concurrent first start the
  losing seeder re-checks, finds the winner, and returns instead of
  raising IntegrityError
- per-thread sqlite connections + busy_timeout so Flask worker threads
  don't hit cross-thread ProgrammingError / SQLITE_BUSY
- add LogsDirectory + /var/log/vacuum-wall to ReadWritePaths in both
  systemd units so the fallback admin password actually lands on disk

Frontend
- skip apiFetch 401-recovery for public auth endpoints so a failed
  login no longer logs out a valid session
- add /passkeys to the nav (passkey registration was unreachable);
  remove the dead checkWebAuthnCapable export
- drop the CSP-blocked inline WS-URL script and the
  __WS_URL_PLACEHOLDER__ plumbing; the WS URL is derived from location

Daemon / WS
- parse Sec-WebSocket-Protocol manually (web.Request.get_subprotocols
  does not exist in aiohttp 3.13); X-Auth-Token is a custom-nginx
  fallback only — docstring and security docs corrected

Install / system
- bootstrap_auth.py is now idempotent: preserves existing auth config
  and syncs the admin password on re-runs (new reset_password helper)
- WebUI server block renders auth_basic off (the UI is JWT-protected)
- install.sh chown/chmod skips .git to avoid git dubious-ownership
  breakage
- tolerate unreadable /etc/wireguard during system import

Contracts / docs
- create_user returns 409 on duplicate username per docs/api.md
- correct docs/api.md response shapes, docs/security.md blacklist
  cleanup wording + one-refresh-per-user caveat, stale WS-URL
  references, and the .htpasswd description

Tests: +7 regression tests (rotation/logout revocation, crafted-token
401, concurrent seeding); placeholder-substitution tests replaced with
serve-as-is SPA root tests.
This commit is contained in:
2026-08-17 01:45:15 +00:00
parent 1980043afd
commit 0ed275835d
27 changed files with 442 additions and 128 deletions
+15 -3
View File
@@ -206,7 +206,13 @@ Create a new user with password and per-subsystem permissions.
| `password` | `string` | Yes | Plain-text password |
| `permissions` | `object` | No | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
**Response:** `data` is `null` on success.
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `id` | `int` | User ID |
| `username` | `string` | Username |
| `permissions` | `object` | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
Returns HTTP `409` if username already exists.
@@ -226,7 +232,13 @@ Update user's permissions. (To change a password, use `POST /api/auth/password`.
|---|---|---|---|
| `permissions` | `object` | No | New per-subsystem permissions |
**Response:** `data` is `null` on success.
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `id` | `int` | User ID |
| `username` | `string` | Username |
| `permissions` | `object` | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
Returns HTTP `404` if user not found.
@@ -240,7 +252,7 @@ Delete a user and all associated permissions and WebAuthn credentials (CASCADE).
**Auth:** `auth: "rw"` required. Cannot delete self.
**Response:** `data` is `null` on success.
**Response:** `data` is `{"ok": true}` on success.
Returns HTTP `404` if user not found.
+2 -2
View File
@@ -287,7 +287,7 @@ The `data/` directory holds generated files, credentials, and subsystem artifact
data/
├── auth.db # SQLite database: users, permissions, token_blacklist, webauthn_creds
├── nginx/
│ ├── .htpasswd # HTTP Basic Authentication credentials for management UI
│ ├── .htpasswd # HTTP Basic credentials for basic-authed proxy domains (created on demand; the management UI itself uses JWT only)
│ └── sites-enabled/ # Generated nginx server block .conf files (one per domain)
├── dnsmasq/
│ └── fragments/ # User-defined dnsmasq config fragments (appended verbatim)
@@ -328,7 +328,7 @@ The web UI is a single-page application built on **Hoover**, a custom lightweigh
### Request Flow (Frontend)
```
Client requests / ──→ nginx ──→ Flask (server-side __WS_URL_PLACEHOLDER__ substitution)
Client requests / ──→ nginx ──→ Flask (serves index.html)
Client loads /static/app.js ──→ Hoover initializes, checkSession() → if no valid session, render #login
Authenticated ──→ mounts #sidebar and #main render roots
apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API
+2 -2
View File
@@ -34,7 +34,7 @@ index.html — static shell with #sidebar, #main, #modal-root
└── connect() — WebSocket lifecycle
```
The HTML shell (`index.html`) provides named DOM containers (`#sidebar`, `#main`) plus a `#modal-root` anchor for modals. The `app.js` bootstrap mounts Hoover render functions onto `#sidebar` and `#main`, creating two independent render roots. The server substitutes `__WS_URL_PLACEHOLDER__` in `index.html` to set `window.__WS_URL__` for WebSocket routing.
The HTML shell (`index.html`) provides named DOM containers (`#sidebar`, `#main`) plus a `#modal-root` anchor for modals. The `app.js` bootstrap mounts Hoover render functions onto `#sidebar` and `#main`, creating two independent render roots.
Each render root registers a render function via `render(container, fn)`. When reactive state changes, all registered render functions re-execute in a single batched microtask, producing new VNodes that are diffed against the previous tree and patched into the DOM.
@@ -544,7 +544,7 @@ Link({ path: '/zones', class: 'active', children: ['Zones'] })
### `connect()`
Start the WebSocket connection to the daemon at `ws://<host>/ws` (auto-detects `wss:` for HTTPS). Set `window.__WS_URL__` to override. Auto-reconnects with exponential backoff (max 15s).
Start the WebSocket connection to the daemon at `ws://<host>/ws` (auto-detects `wss:` for HTTPS). Auto-reconnects with exponential backoff (max 15s).
The JWT is read from the auth model and sent in the WebSocket subprotocol header (`Bearer <token>`). With no token, no socket is created (the daemon 401s unauthenticated WS connections). After 3 consecutive close failures a token refresh is triggered through the auth model; reconnection branches on the model's token state (`getAuthToken()`), never on the refresh promise.
+2 -2
View File
@@ -97,14 +97,14 @@ JWT-based authentication replaces HTTP Basic Auth for the management WebUI. The
1. **Login**: User submits credentials via `POST /api/auth/login`. The daemon verifies the password hash (Argon2id) against `data/auth.db`. On success, an access token (15 min) and refresh token (7 days) are issued.
2. **Validation**: Every request to Flask includes `Authorization: Bearer <token>`. The `before_request` middleware validates the token signature, checks expiry, queries the SQLite `token_blacklist` table, and verifies per-subsystem permissions.
3. **Auto-refresh**: Before the access token expires, the frontend's `refreshScheduler()` calls `POST /api/auth/refresh` with the refresh token. The old refresh token is blacklisted and a new pair is issued.
4. **Blacklist**: On logout (`POST /api/auth/logout`) or password change, the current token's `jti` is inserted into `token_blacklist`. The expired blacklist entries are cleaned on every refresh operation via `Q_DELETE_EXPIRED`.
4. **Blacklist**: On logout (`POST /api/auth/logout`), password change, or user deletion, the affected token's `jti` is inserted into `token_blacklist`. On refresh rotation the old refresh token's `jti` is blacklisted and the new token replaces the stored row in `refresh_tokens`. One row per user means each user has a single active refresh session: a refresh from a second tab overwrites the first tab's row, and logout blacklists whichever token is currently stored. Expired blacklist entries are cleaned by the daemon's polling loop (default 60s) and by a probabilistic check inside `blacklist_token()`.
Token theft protection:
- Short-lived access tokens (15 min) limit the window of exploitation
- Token blacklist prevents reuse after logout or password change
- XSS mitigations: CSP headers, `X-XSS-Protection` header on management domain
**WebSocket session binding limitation**: WebSocket connections skip `session_id` validation. Browsers cannot send custom headers during the WebSocket handshake — the token is passed via the `Sec-WebSocket-Protocol` subprotocol or an nginx-injected `X-Auth-Token` header. This means a stolen access token can be used to open WebSocket connections for the full 15-minute TTL without session verification. Short-lived TTL and management domain CSP headers mitigate this risk.
**WebSocket session binding limitation**: WebSocket connections skip `session_id` validation. Browsers cannot send custom headers during the WebSocket handshake — the bundled nginx config passes the token via the `Sec-WebSocket-Protocol` subprotocol header (a custom nginx setup may instead inject it as `X-Auth-Token`). This means a stolen access token can be used to open WebSocket connections for the full 15-minute TTL without session verification. Short-lived TTL and management domain CSP headers mitigate this risk.
### WebAuthn Security