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:
@@ -71,6 +71,7 @@ Conventions:
|
|||||||
- `VACUUM_WALLD_WS_PORT` — override WebSocket port (default `9091`)
|
- `VACUUM_WALLD_WS_PORT` — override WebSocket port (default `9091`)
|
||||||
- `VACUUM_WALL_POLL_INTERVALS` — override poll intervals, e.g. `firewall:60,wireguard:5`
|
- `VACUUM_WALL_POLL_INTERVALS` — override poll intervals, e.g. `firewall:60,wireguard:5`
|
||||||
- `VACUUM_WALL_EXTERNAL_IP_URL` — custom URL for external IP detection (acme handler)
|
- `VACUUM_WALL_EXTERNAL_IP_URL` — custom URL for external IP detection (acme handler)
|
||||||
|
- `VACUUM_WALL_SEED_BUILTIN_ADMIN` — set to `0` to skip the last-resort builtin admin seed in `get_db()`. The seed only runs on a completely empty DB (no users); `scripts/bootstrap_auth.py` always sets this since bootstrap creates the operator user itself.
|
||||||
|
|
||||||
## Local Dev
|
## Local Dev
|
||||||
|
|
||||||
|
|||||||
@@ -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 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
|
Authenticated ──→ mounts #sidebar and #main render roots
|
||||||
apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API
|
apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API
|
||||||
Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions
|
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' })
|
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)
|
(no auth:login — initApp() calls fetchInitialData()/connect() directly)
|
||||||
apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' })
|
apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' })
|
||||||
→ onSuccess stores rotated tokens (new session_id) or clears + redirects
|
→ 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
|
- **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,
|
(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
|
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`.
|
- **No recursion** — the auth model's `fetch` uses vanilla `fetch()`, never `apiFetch`.
|
||||||
- **`modelFetch()` never rejects** — errors land in `model.error`; consumers branch on model
|
- **`modelFetch()` never rejects** — errors land in `model.error`; consumers branch on model
|
||||||
state (`getAuthToken()` / `isAuthenticated()`), not on promise rejection.
|
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.
|
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.
|
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()`.
|
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:
|
Token theft protection:
|
||||||
|
|||||||
@@ -288,17 +288,30 @@ def get_db() -> Database:
|
|||||||
|
|
||||||
|
|
||||||
def _seed_builtin_admin(db: Database) -> None:
|
def _seed_builtin_admin(db: Database) -> None:
|
||||||
"""Create the builtin admin user if they don't exist.
|
"""Create the builtin admin user as a last-resort fallback.
|
||||||
|
|
||||||
|
Seeding is skipped when:
|
||||||
|
|
||||||
|
- ``VACUUM_WALL_SEED_BUILTIN_ADMIN`` is set to ``0`` — bootstrap_auth.py
|
||||||
|
sets this, since bootstrap creates the operator user itself and must
|
||||||
|
not leave a hardcoded ``admin`` with an unrecoverable random password, or
|
||||||
|
- the users table already contains users — accounts exist, so bootstrap
|
||||||
|
(or a prior start) has run.
|
||||||
|
|
||||||
|
The seed therefore only fires on a completely empty database, i.e.
|
||||||
|
bootstrap was genuinely skipped and a service starts first.
|
||||||
|
|
||||||
The builtin admin has full (rw) access to all subsystems and cannot
|
The builtin admin has full (rw) access to all subsystems and cannot
|
||||||
be deleted or have permissions modified through the normal API.
|
be deleted or have permissions modified through the normal API.
|
||||||
"""
|
"""
|
||||||
|
if os.environ.get("VACUUM_WALL_SEED_BUILTIN_ADMIN", "1") == "0":
|
||||||
|
return
|
||||||
|
|
||||||
from lib.auth_users import ALL_SUBSYSTEMS, BUILTIN_ADMIN_USERNAME
|
from lib.auth_users import ALL_SUBSYSTEMS, BUILTIN_ADMIN_USERNAME
|
||||||
from lib.password import hash_password
|
from lib.password import hash_password
|
||||||
|
|
||||||
# Check if admin already exists
|
# Last-resort guard: only seed when no users exist at all.
|
||||||
rows = db.query(Q_SELECT_USER_BY_NAME, (BUILTIN_ADMIN_USERNAME,))
|
if db.query(Q_SELECT_ALL_USERS):
|
||||||
if rows:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Generate a random password — this fallback should only fire if
|
# Generate a random password — this fallback should only fire if
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ Idempotent — safe to run on every install (and re-install):
|
|||||||
- Creates the admin user if missing; if the user already exists, updates
|
- Creates the admin user if missing; if the user already exists, updates
|
||||||
the admin password to the provided value (docs/deployment.md: "On
|
the admin password to the provided value (docs/deployment.md: "On
|
||||||
re-run, updates the admin password if already present").
|
re-run, updates the admin password if already present").
|
||||||
|
- Suppresses the last-resort builtin admin seed (VACUUM_WALL_SEED_BUILTIN_ADMIN=0):
|
||||||
|
bootstrap is the operator user's creator on a fresh install, so exactly
|
||||||
|
one account exists and no hardcoded admin with an unrecoverable random
|
||||||
|
password is left behind.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python scripts/bootstrap_auth.py --project-dir /path/to/project \
|
python scripts/bootstrap_auth.py --project-dir /path/to/project \
|
||||||
@@ -39,6 +43,9 @@ def main() -> None:
|
|||||||
db_path = str(project_dir / "data" / "auth.db")
|
db_path = str(project_dir / "data" / "auth.db")
|
||||||
os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite"
|
os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite"
|
||||||
os.environ["VACUUM_WALL_DB_PATH"] = db_path
|
os.environ["VACUUM_WALL_DB_PATH"] = db_path
|
||||||
|
# Suppress the last-resort builtin admin seed in get_db(): bootstrap
|
||||||
|
# creates the operator user itself, so no seeded admin may shadow it.
|
||||||
|
os.environ["VACUUM_WALL_SEED_BUILTIN_ADMIN"] = "0"
|
||||||
|
|
||||||
from lib.auth_users import (
|
from lib.auth_users import (
|
||||||
ALL_SUBSYSTEMS,
|
ALL_SUBSYSTEMS,
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Tests for hoover/auth_model.js
|
||||||
|
*
|
||||||
|
* Model-level tests: the 'check' action (session validation, including the
|
||||||
|
* single refresh fallback on a 401) and the 'refresh' action (rotation).
|
||||||
|
*
|
||||||
|
* auth_model.js only pulls in model.js → reactivity.js (no DOM at import),
|
||||||
|
* so the tests run under plain node with stubbed globals (fetch,
|
||||||
|
* sessionStorage, document, window, timers).
|
||||||
|
*
|
||||||
|
* Run with `node tests/test-auth-model.js`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createAuthModel } from '../webui/static/hoover/auth_model.js';
|
||||||
|
import { modelRegister, modelFetch, getModel } from '../webui/static/hoover/model.js';
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const tests = [];
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
tests.push({ name, fn });
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(cond, msg) {
|
||||||
|
if (!cond) throw new Error(msg || 'Assertion failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEq(a, b, msg) {
|
||||||
|
if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stubs ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function makeStorage(initial = {}) {
|
||||||
|
const m = new Map(Object.entries(initial));
|
||||||
|
return {
|
||||||
|
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
||||||
|
setItem: (k, v) => m.set(k, String(v)),
|
||||||
|
removeItem: (k) => m.delete(k),
|
||||||
|
keys: () => [...m.keys()],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Programmed URL → response queue. Missing routes are 500. */
|
||||||
|
function makeFetch() {
|
||||||
|
const byUrl = new Map();
|
||||||
|
globalThis.fetch = async (url) => {
|
||||||
|
const arr = byUrl.get(url) || [];
|
||||||
|
const r = arr.length ? arr.shift() : { status: 500, body: { ok: false, error: 'unprogrammed route' } };
|
||||||
|
return {
|
||||||
|
ok: r.status >= 200 && r.status < 300,
|
||||||
|
status: r.status,
|
||||||
|
json: async () => r.body,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const state = { calls: [], route: (url, status, body) => { if (!byUrl.has(url)) byUrl.set(url, []); state.calls.push(url); byUrl.get(url).push({ status, body }); } };
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stub the TTL refresh timer so the node process never waits on it. */
|
||||||
|
const _timers = [];
|
||||||
|
globalThis.setTimeout = (fn, ms) => { _timers.push({ fn, ms }); return _timers.length; };
|
||||||
|
globalThis.clearTimeout = (id) => { if (id && _timers[id - 1]) _timers[id - 1] = undefined; };
|
||||||
|
|
||||||
|
const storedSession = {
|
||||||
|
'vw:access': 'access-old',
|
||||||
|
'vw:refresh': 'refresh-old',
|
||||||
|
'vw:session_id': 'sess-old',
|
||||||
|
'vw:access_ttl': '900000',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stubs + model registration for one scenario.
|
||||||
|
* @param {{initialStorage?: object, hash?: string}} [cfg]
|
||||||
|
* @returns {{calls: string[], route: function, events: Array}}
|
||||||
|
*/
|
||||||
|
function setup({ initialStorage = storedSession, hash = '#/dashboard' } = {}) {
|
||||||
|
const { calls, route } = makeFetch();
|
||||||
|
globalThis.sessionStorage = makeStorage(initialStorage);
|
||||||
|
globalThis.document = { location: { hash } };
|
||||||
|
const events = [];
|
||||||
|
globalThis.window = {
|
||||||
|
dispatchEvent: (e) => events.push(e),
|
||||||
|
addEventListener: () => {},
|
||||||
|
};
|
||||||
|
modelRegister('auth', createAuthModel());
|
||||||
|
return { calls, route, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run the auth model action through the real modelFetch (dedup, hooks). */
|
||||||
|
async function act(action) {
|
||||||
|
await modelFetch('auth', { action });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tests ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
test('check 200 returns verified identity on the stored tokens', async () => {
|
||||||
|
const s = setup();
|
||||||
|
s.route('/api/auth/session', 200, {
|
||||||
|
ok: true,
|
||||||
|
data: { user: { username: 'alice' }, permissions: { firewall: 'rw' } },
|
||||||
|
});
|
||||||
|
await act('check');
|
||||||
|
assertEq(s.calls.length, 1, 'session check only');
|
||||||
|
assertEq(s.calls[0], '/api/auth/session');
|
||||||
|
const data = getModel('auth').data;
|
||||||
|
assertEq(data.token, 'access-old', 'stored access token kept');
|
||||||
|
assertEq(data.refresh, 'refresh-old', 'stored refresh token kept');
|
||||||
|
assertEq(data.session_id, 'sess-old', 'stored session_id kept');
|
||||||
|
assertEq(data.user?.username, 'alice', 'verified user merged');
|
||||||
|
assert(data.permissions?.firewall === 'rw', 'verified permissions merged');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check 401 with stored refresh token: one refresh, session preserved', async () => {
|
||||||
|
const s = setup();
|
||||||
|
s.route('/api/auth/session', 401, { ok: false, error: 'unauthorized' });
|
||||||
|
s.route('/api/auth/refresh', 200, {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
tokens: { access_token: 'access-new', refresh_token: 'refresh-new', session_id: 'sess-new' },
|
||||||
|
user: { username: 'alice' },
|
||||||
|
permissions: { firewall: 'rw' },
|
||||||
|
access_ttl: 300,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await act('check');
|
||||||
|
assertEq(s.calls.length, 2, 'exactly one refresh attempt');
|
||||||
|
assertEq(s.calls[0], '/api/auth/session', 'session check first');
|
||||||
|
assertEq(s.calls[1], '/api/auth/refresh', 'refresh fallback second');
|
||||||
|
const data = getModel('auth').data;
|
||||||
|
assertEq(data.token, 'access-new', 'rotated access token');
|
||||||
|
assertEq(data.refresh, 'refresh-new', 'rotated refresh token');
|
||||||
|
assertEq(data.session_id, 'sess-new', 'rotated session_id binding wins');
|
||||||
|
assertEq(data.user?.username, 'alice', 'refreshed identity');
|
||||||
|
assertEq(globalThis.sessionStorage.getItem('vw:session_id'), 'sess-new', 'storage carries rotated session_id');
|
||||||
|
assert(s.events.length === 0, 'no terminal event on recovered session');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check 401 without refresh token: terminal, no refresh attempted', async () => {
|
||||||
|
const s = setup({ initialStorage: { 'vw:access': 'access-old', 'vw:session_id': 'sess-old' } });
|
||||||
|
s.route('/api/auth/session', 401, { ok: false, error: 'unauthorized' });
|
||||||
|
await act('check');
|
||||||
|
assertEq(s.calls.length, 1, 'session check only');
|
||||||
|
assertEq(s.calls[0], '/api/auth/session');
|
||||||
|
const data = getModel('auth').data;
|
||||||
|
assert(!data?.token, 'terminal: no token');
|
||||||
|
assert(globalThis.sessionStorage.keys().length === 0, 'storage cleared');
|
||||||
|
assertEq(globalThis.document.location.hash, '/login', 'redirected to login');
|
||||||
|
assert(s.events.some((e) => e.type === 'auth:logout'), 'terminal auth:logout dispatched');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check 401 with failed refresh: terminal', async () => {
|
||||||
|
const s = setup();
|
||||||
|
s.route('/api/auth/session', 401, { ok: false, error: 'unauthorized' });
|
||||||
|
s.route('/api/auth/refresh', 401, { ok: false, error: 'invalid refresh token' });
|
||||||
|
await act('check');
|
||||||
|
assertEq(s.calls.length, 2, 'refresh was attempted');
|
||||||
|
assert(!getModel('auth').data?.token, 'terminal: no token');
|
||||||
|
assert(globalThis.sessionStorage.keys().length === 0, 'storage cleared');
|
||||||
|
assertEq(globalThis.document.location.hash, '/login', 'redirected to login');
|
||||||
|
assert(s.events.some((e) => e.type === 'auth:logout'), 'terminal auth:logout dispatched');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh action rotates tokens; new session_id wins, omitted fields fall back', async () => {
|
||||||
|
const s = setup();
|
||||||
|
s.route('/api/auth/refresh', 200, {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
tokens: { access_token: 'a2', refresh_token: 'r2', session_id: 's2' },
|
||||||
|
access_ttl: 300,
|
||||||
|
user: { username: 'alice' },
|
||||||
|
permissions: { firewall: 'rw' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await act('refresh');
|
||||||
|
assertEq(s.calls.length, 1, 'single refresh call');
|
||||||
|
assertEq(s.calls[0], '/api/auth/refresh');
|
||||||
|
const data = getModel('auth').data;
|
||||||
|
assertEq(data.token, 'a2', 'rotated access token');
|
||||||
|
assertEq(data.refresh, 'r2', 'rotated refresh token');
|
||||||
|
assertEq(data.session_id, 's2', 'new session_id wins');
|
||||||
|
assertEq(data.ttl, 300 * 1000, 'ttl from access_ttl seconds → ms');
|
||||||
|
assertEq(globalThis.sessionStorage.getItem('vw:session_id'), 's2', 'storage rotated');
|
||||||
|
assertEq(data.user?.username, 'alice', 'user from response');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Runner ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for (const { name, fn } of tests) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
console.log(` \u2713 ${name}`);
|
||||||
|
passed++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(` \u2717 ${name}: ${e.message}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`);
|
||||||
|
process.exitCode = failed ? 1 : 0;
|
||||||
|
})();
|
||||||
+61
-9
@@ -26,6 +26,7 @@ from lib.auth import (
|
|||||||
from lib.auth_users import (
|
from lib.auth_users import (
|
||||||
create_user,
|
create_user,
|
||||||
delete_user,
|
delete_user,
|
||||||
|
find_user,
|
||||||
get_user,
|
get_user,
|
||||||
list_users,
|
list_users,
|
||||||
reset_password,
|
reset_password,
|
||||||
@@ -53,6 +54,7 @@ def _db_reset():
|
|||||||
reset_db_for_test()
|
reset_db_for_test()
|
||||||
old_backend = os.environ.pop("VACUUM_WALL_DB_BACKEND", None)
|
old_backend = os.environ.pop("VACUUM_WALL_DB_BACKEND", None)
|
||||||
old_path = os.environ.pop("VACUUM_WALL_DB_PATH", None)
|
old_path = os.environ.pop("VACUUM_WALL_DB_PATH", None)
|
||||||
|
old_seed = os.environ.pop("VACUUM_WALL_SEED_BUILTIN_ADMIN", None)
|
||||||
|
|
||||||
os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite"
|
os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite"
|
||||||
os.environ["VACUUM_WALL_DB_PATH"] = ":memory:"
|
os.environ["VACUUM_WALL_DB_PATH"] = ":memory:"
|
||||||
@@ -64,6 +66,8 @@ def _db_reset():
|
|||||||
os.environ["VACUUM_WALL_DB_BACKEND"] = old_backend
|
os.environ["VACUUM_WALL_DB_BACKEND"] = old_backend
|
||||||
if old_path is not None:
|
if old_path is not None:
|
||||||
os.environ["VACUUM_WALL_DB_PATH"] = old_path
|
os.environ["VACUUM_WALL_DB_PATH"] = old_path
|
||||||
|
if old_seed is not None:
|
||||||
|
os.environ["VACUUM_WALL_SEED_BUILTIN_ADMIN"] = old_seed
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -1372,8 +1376,8 @@ class TestBuiltinAdminSeeding:
|
|||||||
assert user["permissions"] == {s: "rw" for s in ALL_SUBSYSTEMS}
|
assert user["permissions"] == {s: "rw" for s in ALL_SUBSYSTEMS}
|
||||||
|
|
||||||
def test_seed_noop_when_admin_exists(self) -> None:
|
def test_seed_noop_when_admin_exists(self) -> None:
|
||||||
"""Seeding is a no-op when the admin user already exists."""
|
"""Seeding is a no-op when any user (here: admin) already exists."""
|
||||||
from lib.db import Q_SELECT_USER_BY_NAME, _seed_builtin_admin
|
from lib.db import Q_SELECT_ALL_USERS, _seed_builtin_admin
|
||||||
|
|
||||||
db = get_db()
|
db = get_db()
|
||||||
_seed_builtin_admin(db)
|
_seed_builtin_admin(db)
|
||||||
@@ -1382,19 +1386,20 @@ class TestBuiltinAdminSeeding:
|
|||||||
calls = {"n": 0}
|
calls = {"n": 0}
|
||||||
|
|
||||||
def counting_query(query_id, params=()):
|
def counting_query(query_id, params=()):
|
||||||
if query_id == Q_SELECT_USER_BY_NAME:
|
if query_id == Q_SELECT_ALL_USERS:
|
||||||
calls["n"] += 1
|
calls["n"] += 1
|
||||||
return real_query(query_id, params)
|
return real_query(query_id, params)
|
||||||
|
|
||||||
with patch.object(db, "query", side_effect=counting_query):
|
with patch.object(db, "query", side_effect=counting_query):
|
||||||
_seed_builtin_admin(db)
|
_seed_builtin_admin(db)
|
||||||
# Early-return path: only the existence check runs.
|
# Early-return path: only the users-present check runs.
|
||||||
assert calls["n"] >= 1
|
assert calls["n"] >= 1
|
||||||
|
|
||||||
def test_seed_concurrent_lose_race(self) -> None:
|
def test_seed_concurrent_lose_race(self) -> None:
|
||||||
"""Concurrent seeding: if the insert loses a race, the loser re-checks,
|
"""Concurrent seeding: if the users-present check sees a stale (empty)
|
||||||
finds the winner's admin, and returns instead of raising IntegrityError."""
|
view and the insert then loses the race, the loser re-checks, finds
|
||||||
from lib.db import Q_SELECT_USER_BY_NAME, _seed_builtin_admin
|
the winner's admin, and returns instead of raising IntegrityError."""
|
||||||
|
from lib.db import Q_SELECT_ALL_USERS, _seed_builtin_admin
|
||||||
|
|
||||||
db = get_db()
|
db = get_db()
|
||||||
# get_db() already seeded admin for this fresh in-memory DB.
|
# get_db() already seeded admin for this fresh in-memory DB.
|
||||||
@@ -1404,13 +1409,60 @@ class TestBuiltinAdminSeeding:
|
|||||||
calls = {"n": 0}
|
calls = {"n": 0}
|
||||||
|
|
||||||
def fake_query(query_id, params=()):
|
def fake_query(query_id, params=()):
|
||||||
if query_id == Q_SELECT_USER_BY_NAME and params and params[0] == "admin":
|
if query_id == Q_SELECT_ALL_USERS:
|
||||||
calls["n"] += 1
|
calls["n"] += 1
|
||||||
if calls["n"] == 1:
|
if calls["n"] == 1:
|
||||||
return [] # stale view: existence check misses concurrent seeder
|
return [] # stale view: users-present check misses seeder
|
||||||
return real_query(query_id, params)
|
return real_query(query_id, params)
|
||||||
|
|
||||||
with patch.object(db, "query", side_effect=fake_query):
|
with patch.object(db, "query", side_effect=fake_query):
|
||||||
_seed_builtin_admin(db) # must not raise
|
_seed_builtin_admin(db) # must not raise
|
||||||
|
|
||||||
assert get_user("admin") is not None
|
assert get_user("admin") is not None
|
||||||
|
|
||||||
|
def test_seed_skipped_when_users_exist(self, tmp_path) -> None:
|
||||||
|
"""Last-resort rule: a DB that already has users gets no seeded
|
||||||
|
admin — a non-default bootstrap user must not be shadowed."""
|
||||||
|
from lib.auth_users import ALL_SUBSYSTEMS
|
||||||
|
from lib.db_sqlite import SQLiteBackend
|
||||||
|
|
||||||
|
db_file = tmp_path / "auth.db"
|
||||||
|
|
||||||
|
# A prior process (the bootstrap run) created the operator account.
|
||||||
|
first = SQLiteBackend(str(db_file))
|
||||||
|
first.init_tables()
|
||||||
|
first.run(Q_INSERT_USER, ("alice", hash_password("alice-pw"), "jwt-secret"))
|
||||||
|
for sub in ALL_SUBSYSTEMS:
|
||||||
|
first.run(Q_UPSERT_PERMISSION, ("alice", sub, "rw"))
|
||||||
|
|
||||||
|
# A fresh service process initializes the same DB file.
|
||||||
|
reset_db_for_test()
|
||||||
|
os.environ["VACUUM_WALL_DB_PATH"] = str(db_file)
|
||||||
|
db = get_db()
|
||||||
|
|
||||||
|
usernames = {row["username"] for row in db.query(Q_SELECT_ALL_USERS)}
|
||||||
|
assert usernames == {"alice"}
|
||||||
|
assert get_user("admin") is None
|
||||||
|
|
||||||
|
def test_seed_skipped_via_env(self) -> None:
|
||||||
|
"""VACUUM_WALL_SEED_BUILTIN_ADMIN=0 (set by bootstrap_auth.py)
|
||||||
|
suppresses the seed even on a completely empty DB."""
|
||||||
|
os.environ["VACUUM_WALL_SEED_BUILTIN_ADMIN"] = "0"
|
||||||
|
db = get_db()
|
||||||
|
assert db.query(Q_SELECT_ALL_USERS) == []
|
||||||
|
assert find_user("admin") is None
|
||||||
|
|
||||||
|
def test_bootstrap_flow_creates_exactly_one_user(self) -> None:
|
||||||
|
"""Simulates bootstrap_auth.py's main() on a fresh DB: with the seed
|
||||||
|
suppressed, bootstrap creates exactly the operator account and no
|
||||||
|
hardcoded admin shadow (regression for --mgmt-user != admin leaving
|
||||||
|
an unrecoverable superuser)."""
|
||||||
|
from lib.auth_users import ALL_SUBSYSTEMS
|
||||||
|
|
||||||
|
os.environ["VACUUM_WALL_SEED_BUILTIN_ADMIN"] = "0"
|
||||||
|
assert find_user("alice") is None
|
||||||
|
create_user("alice", "secret123", {sub: "rw" for sub in ALL_SUBSYSTEMS})
|
||||||
|
|
||||||
|
usernames = {row["username"] for row in get_db().query(Q_SELECT_ALL_USERS)}
|
||||||
|
assert usernames == {"alice"}
|
||||||
|
assert find_user("admin") is None
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ export function createAuthModel() {
|
|||||||
},
|
},
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
});
|
});
|
||||||
|
if (!r.ok && stored.refresh) {
|
||||||
|
// Stale access token (e.g. page reload/restore: the in-memory
|
||||||
|
// TTL timer is gone and the token may have expired server-side,
|
||||||
|
// while the 7-day refresh token is still in sessionStorage) —
|
||||||
|
// attempt exactly one refresh before treating the session
|
||||||
|
// as dead. A failed refresh falls through to the terminal path.
|
||||||
|
return _doRefresh();
|
||||||
|
}
|
||||||
if (!r.ok) return null;
|
if (!r.ok) return null;
|
||||||
const json = await r.json();
|
const json = await r.json();
|
||||||
if (!json.ok || !json.data?.user) return null;
|
if (!json.ok || !json.data?.user) return null;
|
||||||
@@ -77,32 +85,7 @@ export function createAuthModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'refresh') {
|
if (action === 'refresh') {
|
||||||
const stored = readStorage();
|
return _doRefresh();
|
||||||
if (!stored.refresh) return null;
|
|
||||||
const r = await fetch('/api/auth/refresh', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
body: JSON.stringify({
|
|
||||||
refresh_token: stored.refresh,
|
|
||||||
session_id: stored.session_id,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!r.ok) return null;
|
|
||||||
const json = await r.json();
|
|
||||||
if (!json.ok || !json.data?.tokens) return null;
|
|
||||||
const t = json.data.tokens;
|
|
||||||
const prev = getModel('auth').data; // fallback for any field the server omits
|
|
||||||
// NOTE: the server mints a NEW session_id on every refresh — the rotated
|
|
||||||
// binding must win over `prev`.
|
|
||||||
return {
|
|
||||||
token: t.access_token,
|
|
||||||
refresh: t.refresh_token,
|
|
||||||
session_id: t.session_id,
|
|
||||||
user: json.data.user ?? prev?.user,
|
|
||||||
permissions: json.data.permissions ?? prev?.permissions,
|
|
||||||
ttl: json.data.access_ttl ? json.data.access_ttl * 1000 : (prev?.ttl || 900 * 1000),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'login') {
|
if (action === 'login') {
|
||||||
@@ -170,6 +153,42 @@ export function createAuthModel() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate the token pair via POST /api/auth/refresh using the stored refresh
|
||||||
|
* token. Shared by the 'refresh' action and the 'check' 401 fallback.
|
||||||
|
* @returns {Promise<{token, refresh, session_id, user, permissions, ttl}|null>}
|
||||||
|
* The rotated token state, or null when the refresh token is missing,
|
||||||
|
* invalid, expired, or blacklisted.
|
||||||
|
*/
|
||||||
|
async function _doRefresh() {
|
||||||
|
const stored = readStorage();
|
||||||
|
if (!stored.refresh) return null;
|
||||||
|
const r = await fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({
|
||||||
|
refresh_token: stored.refresh,
|
||||||
|
session_id: stored.session_id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const json = await r.json();
|
||||||
|
if (!json.ok || !json.data?.tokens) return null;
|
||||||
|
const t = json.data.tokens;
|
||||||
|
const prev = getModel('auth').data; // fallback for any field the server omits
|
||||||
|
// NOTE: the server mints a NEW session_id on every refresh — the rotated
|
||||||
|
// binding must win over `prev`.
|
||||||
|
return {
|
||||||
|
token: t.access_token,
|
||||||
|
refresh: t.refresh_token,
|
||||||
|
session_id: t.session_id,
|
||||||
|
user: json.data.user ?? prev?.user,
|
||||||
|
permissions: json.data.permissions ?? prev?.permissions,
|
||||||
|
ttl: json.data.access_ttl ? json.data.access_ttl * 1000 : (prev?.ttl || 900 * 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read stored token state from sessionStorage.
|
* Read stored token state from sessionStorage.
|
||||||
* @returns {{access: string|null, refresh: string|null, session_id: string|null, ttl: number|null}}
|
* @returns {{access: string|null, refresh: string|null, session_id: string|null, ttl: number|null}}
|
||||||
|
|||||||
Reference in New Issue
Block a user