fix: seed builtin admin only on empty DB; recover page-load sessions with one refresh
Auth seeding (last-resort guard) - `_seed_builtin_admin()` in get_db() now skips when VACUUM_WALL_SEED_BUILTIN_ADMIN=0 or when the users table already contains any user — previously a fresh service start after a non-default bootstrap (e.g. --mgmt-user alice) seeded a hard-coded `admin` with an unrecoverable random password, shadowing the operator's account - bootstrap_auth.py sets VACUUM_WALL_SEED_BUILTIN_ADMIN=0: bootstrap creates the operator user itself on a fresh install, so exactly one account exists and no seeded admin can appear Frontend (session recovery) - on page load/restore the in-memory TTL timer is gone, so a valid 7-day refresh token could sit in sessionStorage while the access token is already expired server-side: the session `check` now attempts exactly one refresh (POST /api/auth/refresh with the stored refresh token) on 401 before treating the session as dead - extract shared `_doRefresh()` used by both the `check` 401 fallback and the `refresh` action (removes the duplicated rotation logic) Tests - update seeding tests to the new any-user-present check; add test_seed_skipped_when_users_exist, test_seed_skipped_via_env, test_bootstrap_flow_creates_exactly_one_user, and the auth-model JS test suite (tests/test-auth-model.js) Docs - AGENTS.md: document VACUUM_WALL_SEED_BUILTIN_ADMIN - architecture.md / hoover.md / security.md: describe the bootstrap check 401 → one-refresh fallback path
This commit is contained in:
@@ -329,7 +329,7 @@ The web UI is a single-page application built on **Hoover**, a custom lightweigh
|
||||
|
||||
```
|
||||
Client requests / ──→ nginx ──→ Flask (serves index.html)
|
||||
Client loads /static/app.js ──→ Hoover initializes, checkSession() → if no valid session, render #login
|
||||
Client loads /static/app.js ──→ Hoover initializes, checkSession() (401 with valid refresh token → one refresh) → if no valid session, render #login
|
||||
Authenticated ──→ mounts #sidebar and #main render roots
|
||||
apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API
|
||||
Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions
|
||||
|
||||
+7
-2
@@ -270,7 +270,10 @@ storage cleared, refresh timer cancelled, redirect to `#/login` if not already t
|
||||
|
||||
```
|
||||
app bootstrap → modelFetch('auth', { action: 'check' })
|
||||
→ stores verified user/permissions + stored tokens → schedules refresh
|
||||
→ 200: stores verified user/permissions + stored tokens → schedules refresh
|
||||
→ 401 with a stored refresh token (stale access token after page
|
||||
reload/restore): exactly one refresh attempt, then the same
|
||||
success or terminal path
|
||||
(no auth:login — initApp() calls fetchInitialData()/connect() directly)
|
||||
apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' })
|
||||
→ onSuccess stores rotated tokens (new session_id) or clears + redirects
|
||||
@@ -290,7 +293,9 @@ any terminal no-token result → onSuccess dispatches auth:logout
|
||||
- **Silent topic** — the subsystem topic is `'auth'` and the daemon never broadcasts it
|
||||
(collectors in `lib/state.py` cover `firewall, dnsmasq, nginx, acme, wireguard, networkd,
|
||||
system` only), so `refreshByTopic()` never fetches the auth model. Auth refresh is driven
|
||||
exclusively by the TTL timer, `apiFetch` 401, and WS fail×3.
|
||||
by the TTL timer, `apiFetch` 401, WS fail×3, and the bootstrap `check` 401 fallback
|
||||
(exactly one refresh when the stored access token is rejected at page load while a
|
||||
refresh token is still present).
|
||||
- **No recursion** — the auth model's `fetch` uses vanilla `fetch()`, never `apiFetch`.
|
||||
- **`modelFetch()` never rejects** — errors land in `model.error`; consumers branch on model
|
||||
state (`getAuthToken()` / `isAuthenticated()`), not on promise rejection.
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ 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.
|
||||
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. At page load/restore, if the stored access token is rejected (401) on the session check, the frontend performs exactly one refresh from the stored refresh token before falling to the login page.
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user