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
+1 -1
View File
@@ -24,7 +24,7 @@ through the daemon client over a Unix socket.
### Code Layout
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`. SPA root route (`/`) renders `index.html` with server-side `__WS_URL_PLACEHOLDER__` substitution (no Jinja). All other paths return 404.
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`. SPA root route (`/`) serves `index.html` (no templating). All other paths return 404.
- `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api/<subsystem>/`. All call `daemon.client` instead of `lib/` directly.
- `webui/api/common.py` — Shared `_ok()` / `_error()` response helpers used by all blueprints.
- `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh, periodic polling.
-8
View File
@@ -34,7 +34,6 @@ from lib.auth import (
blacklist_token,
check_login_rate,
check_webauthn_rate,
clear_active_refresh_token,
generate_tokens,
get_access_ttl,
record_login_failure,
@@ -70,11 +69,6 @@ from lib.webauthn import (
logger = logging.getLogger(__name__)
def _clear_refresh_token_after_rotation(username: str) -> None:
"""Remove the user's entry from refresh_tokens after a successful refresh rotation."""
clear_active_refresh_token(username)
@registry.register(POST_AUTH_LOGIN)
def auth_login(_request: Any, body: Any) -> dict[str, Any]:
"""Handle user login.
@@ -196,8 +190,6 @@ def auth_refresh(_request: Any, body: Any) -> dict[str, Any]:
jti = payload.get("jti")
if jti:
blacklist_token(jti, token_type="refresh")
if username:
_clear_refresh_token_after_rotation(username)
return {
"tokens": tokens,
+12 -5
View File
@@ -375,17 +375,24 @@ async def _handle_ws(request: web.Request) -> web.Response:
Authentication: JWT access token passed via:
1. WebSocket subprotocol header (Sec-WebSocket-Protocol: "Bearer <token>")
2. X-Auth-Token header (nginx-injected)
— the bundled client path.
2. X-Auth-Token header — fallback for custom nginx setups that inject it
(not set by the bundled nginx config).
"""
from aiohttp import hdrs
from lib.auth import validate_token
token_param = None
matched_proto = None
# Prefer subprotocol header (client JS sends "Bearer <token>")
subprotocols = request.get_subprotocols()
for proto in subprotocols or []:
if proto and proto.startswith("Bearer "):
# Prefer subprotocol header (client JS sends "Bearer <token>").
# Sec-WebSocket-Protocol is a comma-separated list; parse it the same
# way aiohttp's own handshake does (Request has no subprotocol helper).
protocol_header = request.headers.get(hdrs.SEC_WEBSOCKET_PROTOCOL, "")
subprotocols = [p.strip() for p in protocol_header.split(",") if p.strip()]
for proto in subprotocols:
if proto.startswith("Bearer "):
token_param = proto[7:]
matched_proto = proto
break
+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
+2 -13
View File
@@ -217,19 +217,6 @@ def blacklist_active_refresh_token(username: str) -> None:
db.run(Q_DELETE_REFRESH_TOKEN, (username,))
def clear_active_refresh_token(username: str) -> None:
"""Remove the user's stored refresh token entry without blacklisting.
Used when the refresh token has already been blacklisted (e.g., during
a successful refresh rotation).
Args:
username: The username.
"""
db = get_db()
db.run(Q_DELETE_REFRESH_TOKEN, (username,))
def _extract_unverified_sub(token_string: str) -> str | None:
"""Extract the ``sub`` claim from a JWT payload without signature verification.
@@ -254,6 +241,8 @@ def _extract_unverified_sub(token_string: str) -> str | None:
payload_b64 += "=" * padding
payload_json = base64.urlsafe_b64decode(payload_b64)
payload = json.loads(payload_json)
if not isinstance(payload, dict):
return None
return payload.get("sub")
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
return None
+25
View File
@@ -207,6 +207,31 @@ def update_password(username: str, old_password: str, new_password: str) -> bool
return True
def reset_password(username: str, new_password: str) -> None:
"""Force-reset a user's password without verifying the old one.
Non-interactive variant for install-time and lockout recovery: the
installer does not know the previous password by construction. Rotates
the user's JWT secret and blacklists the active refresh token,
invalidating all existing sessions.
Args:
username: The user to reset.
new_password: New plain-text password.
Raises:
ValueError: If the user does not exist.
"""
if find_user(username) is None:
raise ValueError(f"User {username!r} not found")
blacklist_active_refresh_token(username)
new_hash = hash_password(new_password)
rotate_user_secret(username)
db = get_db()
db.run(Q_UPDATE_PASSWORD, (new_hash, username))
def update_permissions(username: str, permissions: dict[str, str]) -> None:
"""Update a user's permissions and invalidate all existing tokens.
+22 -5
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import logging
import os
import secrets
import threading
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, ClassVar
@@ -163,16 +164,22 @@ class Database(ABC):
def __init__(self, connection_string: str) -> None:
self._connection_string = connection_string
self._conn: Any = None
self._prepared: dict[str, Any] = {}
self._in_transaction = False
# Per-thread connections: backend connection objects (e.g. sqlite3)
# are bound to the thread that created them. The Flask WebUI runs
# requests in worker threads while the daemon uses a single event-loop
# thread, so each thread lazily gets its own connection.
self._local = threading.local()
@property
def conn(self) -> Any:
"""Return the cached database connection, creating it lazily."""
if self._conn is None:
self._conn = self._connect(self._connection_string)
return self._conn
"""Return this thread's cached database connection, creating it lazily."""
conn = getattr(self._local, "conn", None)
if conn is None:
conn = self._connect(self._connection_string)
self._local.conn = conn
return conn
@abstractmethod
def _connect(self, cs: str) -> Any: ...
@@ -300,12 +307,22 @@ def _seed_builtin_admin(db: Database) -> None:
placeholder_hash = hash_password(random_password)
jwt_secret = secrets.token_urlsafe(32)
try:
with db.in_transaction() as tx:
tx.run_one(
Q_INSERT_USER, (BUILTIN_ADMIN_USERNAME, placeholder_hash, jwt_secret)
)
for subsystem in ALL_SUBSYSTEMS:
tx.run(Q_UPSERT_PERMISSION, (BUILTIN_ADMIN_USERNAME, subsystem, "rw"))
except Exception as exc:
rows = db.query(Q_SELECT_USER_BY_NAME, (BUILTIN_ADMIN_USERNAME,))
if not rows:
raise
logger.warning(
"Concurrent builtin admin seed detected (%s); proceeding with existing user",
exc,
)
return
auth_log = Path("/var/log/vacuum-wall/auth.log")
auth_log_written = False
+4
View File
@@ -134,6 +134,10 @@ class SQLiteBackend(Database):
"""Create a SQLite connection with WAL mode and row factory."""
conn = sqlite3.connect(cs, isolation_level=None)
conn.execute("PRAGMA journal_mode=WAL")
# Multiple threads/processes hold distinct connections (see
# Database.conn); wait up to 5s for writers instead of failing
# immediately with SQLITE_BUSY.
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("PRAGMA foreign_keys=ON")
conn.row_factory = sqlite3.Row
return conn
+5
View File
@@ -54,6 +54,11 @@ WEBUI_BACKEND: dict[str, Any] = {
"/": {
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
"is_management": True,
# The WebUI is protected by JWT at the Flask layer; nginx must
# not gate it with auth_basic (the SPA sends Bearer tokens, which
# suppress the browser's automatic Basic credentials). auth=None
# renders `auth_basic off` even if legacy auth was harvested.
"auth": None,
},
"/ws": {
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
+8 -1
View File
@@ -223,7 +223,14 @@ def _parse_addr_directive(line: str) -> dict[str, Any] | None:
def import_wireguard() -> bool:
"""Parse /etc/wireguard/wg0.conf -> config/wireguard/config.json."""
if not WG_CONF.exists():
try:
exists = WG_CONF.exists()
except OSError:
# Parent dir may be unreadable to the daemon user (e.g. /etc/wireguard
# is 0700). Treat as not present rather than failing the import.
logger.debug("Skipping wireguard: cannot stat %s", WG_CONF)
return False
if not exists:
logger.debug("Skipping wireguard: %s not found", WG_CONF)
return False
+23 -8
View File
@@ -1,7 +1,12 @@
"""Bootstrap auth: initialize DB and seed admin user at install time.
Run once during installation. Writes config/auth/config.json with a
generated JWT secret and creates the admin user in SQLite.
Idempotent safe to run on every install (and re-install):
- Writes config/auth/config.json only if it does not exist (existing
JWT/WebAuthn settings are preserved).
- Creates the admin user if missing; if the user already exists, updates
the admin password to the provided value (docs/deployment.md: "On
re-run, updates the admin password if already present").
Usage:
python scripts/bootstrap_auth.py --project-dir /path/to/project \
@@ -35,13 +40,20 @@ def main() -> None:
os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite"
os.environ["VACUUM_WALL_DB_PATH"] = db_path
from lib.auth_users import ALL_SUBSYSTEMS, create_user
from lib.auth_users import (
ALL_SUBSYSTEMS,
create_user,
find_user,
reset_password,
)
# Write config
# Write config — only if missing, so re-runs never clobber existing
# JWT/WebAuthn settings (e.g. a customized rp_id/origin).
config_dir = project_dir / "config" / "auth"
config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / "config.json"
if not config_path.exists():
config = {
"jwt": {
"access_token_ttl": 300,
@@ -54,17 +66,20 @@ def main() -> None:
"origin": f"https://{args.domain}",
},
}
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
f.write("\n")
print(f"Wrote auth config: {config_path}")
else:
print(f"Auth config already present, leaving unchanged: {config_path}")
# Initialize DB and create admin user
# Initialize DB and create the admin user, or sync the password on re-run
if find_user(args.username) is not None:
reset_password(args.username, args.password)
print(f"Updated existing user: {args.username} (password synced)")
else:
permissions = {sub: "rw" for sub in ALL_SUBSYSTEMS}
user = create_user(args.username, args.password, permissions)
print(f"Created admin user: {user['username']} (id={user['id']})")
print(f"Permissions: {len(permissions)} subsystems, all 'rw'")
+22 -7
View File
@@ -212,15 +212,26 @@ mkdir -p "${PROJECT_DIR}/config"/{dnsmasq,nginx,wireguard,firewall}
mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme}
mkdir -p /etc/wireguard
mkdir -p /etc/dnsmasq
# Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev
# Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev.
# The top-level .git (directory or worktree pointer file) is left untouched so
# the repo owner's git isn't tripped by git's dubious-ownership check.
if [[ "$_cli_is_dev" == true ]]; then
_dev_owner="$USER_NAME"
else
_dev_owner="$USER_DAEMON_NAME"
fi
chown -R "$_dev_owner:$USER_GROUP" "$PROJECT_DIR"
chmod -R g+rwX "$PROJECT_DIR"
find "$PROJECT_DIR" -type d -exec chmod g+s '{}' +
(
shopt -s dotglob nullglob
for _entry in "$PROJECT_DIR"/*; do
[[ "$(basename "$_entry")" == ".git" ]] && continue
chown -R "$_dev_owner:$USER_GROUP" "$_entry"
chmod -R g+rwX "$_entry"
find "$_entry" -type d -exec chmod g+s '{}' +
done
# Top dir: ownership + shared-group access (never .git)
chown "$_dev_owner:$USER_GROUP" "$PROJECT_DIR"
chmod g+rwX,g+s "$PROJECT_DIR"
)
# --- 4. Template rendering function ---
# Renders Jinja2 templates by injecting env vars as template context.
@@ -344,10 +355,16 @@ else
echo ""
echo " Setting up initial management configuration..."
# Bootstrap auth: generate config + seed admin user
# Bootstrap auth: generate config + seed admin user. bootstrap_auth.py
# is idempotent — on re-run it preserves the existing config and
# updates the admin password to MGMT_PASS (docs/deployment.md).
if [[ ! -f "${PROJECT_DIR}/config/auth/config.json" ]]; then
echo ""
echo " Bootstrapping auth (creating admin user: $MGMT_USER)..."
else
echo ""
echo " Syncing admin password for existing user: $MGMT_USER..."
fi
"${PROJECT_DIR}/.venv/bin/python3" "${PROJECT_DIR}/scripts/bootstrap_auth.py" \
--project-dir "$PROJECT_DIR" \
@@ -357,7 +374,6 @@ else
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/config/auth"
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/data/auth.db" 2>/dev/null || true
fi
# Helper: POST JSON to daemon API over Unix socket
_daemon_post() {
@@ -381,7 +397,6 @@ else
_daemon_post "/acme/self-signed" "{\"domain\":\"$DOMAIN\"}" "Self-signed certificate"
# 1C. Management proxy domain (no auth — JWT auth is handled by Flask)
local mgmt_json
mgmt_json="$(jq -n \
--arg domain "$DOMAIN" \
'{
+2 -1
View File
@@ -22,7 +22,8 @@ Environment=HOME={{ PROJECT_DIR }}
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths={{ PROJECT_DIR }} {{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp
ReadWritePaths={{ PROJECT_DIR }} {{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp /var/log/vacuum-wall
LogsDirectory=vacuum-wall
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
+3 -1
View File
@@ -22,9 +22,11 @@ Environment=HOME={{ PROJECT_DIR }}
RuntimeDirectory=vacuum-wall
RuntimeDirectoryMode=0750
LogsDirectory=vacuum-wall
# Security hardening
ProtectSystem=strict
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/vacuum-wall /run/sudo /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/vacuum-wall /run/sudo /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx /var/log/vacuum-wall
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
+223
View File
@@ -28,6 +28,7 @@ from lib.auth_users import (
delete_user,
get_user,
list_users,
reset_password,
update_password,
update_permissions,
verify_user_password,
@@ -132,6 +133,62 @@ class TestDBLayer:
conn2 = db.conn
assert conn1 is conn2
def test_connections_are_thread_local(self, tmp_path):
"""DB access from multiple threads must work (regression test).
The Flask WebUI validates JWTs in lib.db from worker threads while
the daemon uses its event-loop thread. A single shared connection
raises sqlite3.ProgrammingError ("SQLite objects created in a
thread can only be used in that same thread") on the first
cross-thread query.
"""
import threading
reset_db_for_test()
db_path = str(tmp_path / "thread_local.db")
os.environ["VACUUM_WALL_DB_PATH"] = db_path
try:
db = get_db()
db.run(Q_INSERT_USER, ("touser", "$argon2id$hash", "test-secret"))
results: list = []
threads = [
threading.Thread(
target=lambda: results.append(
db.query(Q_SELECT_USER_BY_NAME, ("touser",))
)
)
for _ in range(4)
]
for t in threads:
t.start()
for t in threads:
t.join()
finally:
reset_db_for_test()
assert len(results) == 4
for rows in results:
assert isinstance(rows, list), f"query raised or returned {rows!r}"
assert len(rows) == 1
assert rows[0]["username"] == "touser"
def test_connections_are_distinct_per_thread(self, db):
"""Each thread gets its own connection object."""
import threading
def conn_in_thread(result: list) -> None:
result.append(db.conn)
main_conn = db.conn
result: list = []
t = threading.Thread(target=conn_in_thread, args=(result,))
t.start()
t.join()
assert len(result) == 1
assert result[0] is not main_conn
def test_insert_user(self, db):
uid = db.run_one(Q_INSERT_USER, ("testuser", "$argon2id$hash", "test-secret"))
assert isinstance(uid, int)
@@ -350,6 +407,17 @@ class TestUserManagement:
with pytest.raises(ValueError, match="incorrect"):
update_password("upwfail", "wrong_old", "newpass123")
def test_reset_password_without_old(self, db):
"""Installer lockout recovery: reset works without knowing the old password."""
create_user("rstuser", "unknown-old-pass")
reset_password("rstuser", "freshpass123")
assert verify_user_password("rstuser", "freshpass123") is not None
assert verify_user_password("rstuser", "unknown-old-pass") is None
def test_reset_password_not_found(self, db):
with pytest.raises(ValueError, match="not found"):
reset_password("ghostuser", "newpass123")
def test_update_permissions(self, db):
create_user("permuser", "password123", {"firewall": "rw"})
update_permissions("permuser", {"firewall": "read", "network": "rw"})
@@ -1191,3 +1259,158 @@ class TestPermissionMiddleware:
assert _subsystem_from_path("/api/dhcp/leases/subpath") == "dhcp"
assert _subsystem_from_path("/") is None
assert _subsystem_from_path("/static/app.js") is None
class TestRefreshRotationLogout:
"""Refresh-rotation + logout interaction (regression for the reorder in
0889ef0: clearing the refresh_tokens row after rotation left logout with
nothing to blacklist, so the rotated token stayed valid)."""
def test_logout_revokes_rotated_refresh_token(self) -> None:
"""Logout must blacklist the current refresh token after rotation."""
from daemon.handlers.auth import auth_logout, auth_refresh
create_user("rotuser", "password123", {"auth": "rw"})
tokens = generate_tokens("rotuser", {"auth": "rw"})
rotated = auth_refresh(
MagicMock(),
{
"refresh_token": tokens["refresh_token"],
"session_id": tokens["session_id"],
},
)["tokens"]
# The rotated token must be valid before logout (rotation works).
payload = validate_token(
rotated["refresh_token"], "refresh", session_id=rotated["session_id"]
)
assert payload is not None
auth_logout(MagicMock(), {"jti": None, "username": "rotuser"})
with pytest.raises(ValueError, match="Invalid or expired refresh token"):
auth_refresh(
MagicMock(),
{
"refresh_token": rotated["refresh_token"],
"session_id": rotated["session_id"],
},
)
def test_old_refresh_token_blacklisted_on_rotation(self) -> None:
"""The pre-rotation refresh token must be blacklisted immediately."""
from daemon.handlers.auth import auth_refresh
create_user("rotuser2", "password123", {"auth": "rw"})
tokens = generate_tokens("rotuser2", {"auth": "rw"})
auth_refresh(
MagicMock(),
{
"refresh_token": tokens["refresh_token"],
"session_id": tokens["session_id"],
},
)
assert (
validate_token(
tokens["refresh_token"], "refresh", session_id=tokens["session_id"]
)
is None
)
class TestMalformedTokenPayload:
"""Malformed/untrusted JWT payloads must be rejected (401), not 500."""
def test_decode_non_object_payload_returns_none(self) -> None:
"""A payload segment decoding to non-object JSON is rejected."""
hdr = (
base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}')
.decode()
.rstrip("=")
)
payload = base64.urlsafe_b64encode(b'"hello"').decode().rstrip("=")
token = f"{hdr}.{payload}.signature"
assert decode_token(token) is None
def test_middleware_crafted_token_returns_401(self) -> None:
"""Crafted Bearer token on a protected route returns JSON 401, not 500."""
from webui.server import app
client = app.test_client()
hdr = (
base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}')
.decode()
.rstrip("=")
)
payload = base64.urlsafe_b64encode(b'"hello"').decode().rstrip("=")
token = f"{hdr}.{payload}.signature"
res = client.get(
"/api/auth/session",
headers={"Authorization": f"Bearer {token}", "X-Session-Id": "x"},
)
assert res.status_code == 401
assert res.get_json() == {"ok": False, "error": "unauthorized"}
class TestBuiltinAdminSeeding:
"""Fallback seeding of the builtin admin user (lib.db._seed_builtin_admin)."""
def test_seed_runs_and_creates_admin(self) -> None:
"""A fresh (empty) DB gets the builtin admin with full permissions."""
from lib.auth_users import ALL_SUBSYSTEMS
from lib.db import _seed_builtin_admin
db = get_db()
_seed_builtin_admin(db)
user = get_user("admin")
assert user is not None
assert user["permissions"] == {s: "rw" for s in ALL_SUBSYSTEMS}
def test_seed_noop_when_admin_exists(self) -> None:
"""Seeding is a no-op when the admin user already exists."""
from lib.db import Q_SELECT_USER_BY_NAME, _seed_builtin_admin
db = get_db()
_seed_builtin_admin(db)
real_query = db.query
calls = {"n": 0}
def counting_query(query_id, params=()):
if query_id == Q_SELECT_USER_BY_NAME:
calls["n"] += 1
return real_query(query_id, params)
with patch.object(db, "query", side_effect=counting_query):
_seed_builtin_admin(db)
# Early-return path: only the existence check runs.
assert calls["n"] >= 1
def test_seed_concurrent_lose_race(self) -> None:
"""Concurrent seeding: if the insert loses a race, the loser re-checks,
finds the winner's admin, and returns instead of raising IntegrityError."""
from lib.db import Q_SELECT_USER_BY_NAME, _seed_builtin_admin
db = get_db()
# get_db() already seeded admin for this fresh in-memory DB.
assert get_user("admin") is not None
real_query = db.query
calls = {"n": 0}
def fake_query(query_id, params=()):
if query_id == Q_SELECT_USER_BY_NAME and params and params[0] == "admin":
calls["n"] += 1
if calls["n"] == 1:
return [] # stale view: existence check misses concurrent seeder
return real_query(query_id, params)
with patch.object(db, "query", side_effect=fake_query):
_seed_builtin_admin(db) # must not raise
assert get_user("admin") is not None
+10 -7
View File
@@ -39,14 +39,17 @@ class TestSPARoutes:
assert data.get("error") == "unauthorized"
class TestWsUrlGeneration:
def test_ws_url_ipv4_host(self, client):
resp = client.get("/", headers={"Host": "192.168.1.1:9090"})
assert b"ws://192.168.1.1:9090/ws" in resp.data
class TestSpaRoot:
def test_serves_index_html_as_is(self, client):
resp = client.get("/")
assert resp.status_code == 200
assert b"/static/app.js" in resp.data
def test_ws_url_ipv6_host(self, client):
resp = client.get("/", headers={"Host": "[::1]:9090"})
assert b"ws://[::1]:9090/ws" in resp.data
def test_no_ws_url_substitution(self, client):
"""index.html is served verbatim — no WS URL placeholder substitution."""
resp = client.get("/", headers={"Host": "192.168.1.1:9090"})
assert b"__WS_URL_PLACEHOLDER__" not in resp.data
assert b"ws://" not in resp.data
class TestBlueprintsRegistered:
+3 -1
View File
@@ -9,7 +9,7 @@ import logging
from flask import Blueprint, request
from daemon.client import delete, get, post
from daemon.client import Conflict, delete, get, post
from daemon.iface import (
DELETE_AUTH_USER,
DELETE_AUTH_WEBAUTHN_CREDENTIAL,
@@ -167,6 +167,8 @@ def create_user():
try:
body = request.get_json(silent=True) or {}
return _ok(post(POST_AUTH_USER_CREATE, body))
except Conflict as exc:
return _error(str(exc), 409)
except Exception as exc:
logger.error("Create user failed: %s", exc)
return _error(str(exc), 400)
+1 -4
View File
@@ -307,10 +307,7 @@ VENDOR_DIR = PROJECT_DIR / "vendor"
@app.route("/")
def spa_root():
"""Serve the SPA entry point. No catch-all — client handles routing."""
scheme = "wss" if request.is_secure else "ws"
ws_url = f"{scheme}://{request.host}/ws"
html = (SPA_DIR / "index.html").read_text()
return html.replace("__WS_URL_PLACEHOLDER__", ws_url)
return (SPA_DIR / "index.html").read_text()
@app.route("/vendor/<path:filename>")
+1
View File
@@ -29,6 +29,7 @@ const _NavBase = [
{ path: '/certs', label: 'Certs' },
{ path: '/wireguard', label: 'WireGuard' },
{ path: '/logs', label: 'Logs' },
{ path: '/passkeys', label: 'Passkeys' },
];
function getNav() {
+16 -1
View File
@@ -12,6 +12,21 @@ import { getAuthToken, getAuthData, refreshAuth } from './auth_model.js';
import { requestUpdate } from './reactivity.js';
import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js';
/**
* Public auth endpoints that may legitimately 401 (bad credentials) while a
* valid session exists elsewhere. 401 recovery (refresh retry logout)
* is skipped for these so a failed login doesn't tear down a live session.
*/
const _PUBLIC_AUTH_URLS = new Set([
'/api/auth/login',
'/api/auth/webauthn/authenticate-begin',
'/api/auth/webauthn/authenticate-finish',
]);
function _isPublicAuthUrl(url) {
return _PUBLIC_AUTH_URLS.has(String(url).split('?')[0]);
}
/**
* JSON-friendly fetch wrapper.
*
@@ -49,7 +64,7 @@ export async function apiFetch(url, options = {}) {
if (safeOpts.signal?.aborted) {
return { ok: false, data: null, error: 'Aborted', status: 0 };
}
if (res.status === 401 && token) {
if (res.status === 401 && token && !_isPublicAuthUrl(url)) {
await refreshAuth();
const auth = getAuthData();
if (auth?.token) {
-15
View File
@@ -11,7 +11,6 @@
* - WebAuthn (passkey) ceremony helpers not state management
*/
import { apiFetch } from '../api.js';
import { modelFetch } from '../model.js';
import { getAuthData } from '../auth_model.js';
@@ -65,20 +64,6 @@ export function webauthnSupported() {
return typeof window !== 'undefined' && !!window.PublicKeyCredential;
}
/**
* Check if WebAuthn is enabled and available on the current domain.
* Calls GET /api/auth/webauthn/capable to query the server.
*
* @returns {Promise<object>} { enabled, rp_id, rp_name, origin, reason? }
*/
export async function checkWebAuthnCapable() {
const result = await apiFetch('/api/auth/webauthn/capable');
if (!result.ok) {
return { enabled: false, reason: 'Unable to check WebAuthn capability' };
}
return result.data || { enabled: false, reason: 'Server returned no data' };
}
/* ─── Base64url helpers ──────────────────────────────────────────────── */
/**
+1 -1
View File
@@ -30,7 +30,7 @@ export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoa
from './api.js';
/* ── UI Components: Auth ──────────────────────────────────────── */
export { logout, doLogin, webauthnSupported, checkWebAuthnCapable,
export { logout, doLogin, webauthnSupported,
startRegistration, startAuthentication } from './components/auth.js';
/* ── Auth model ───────────────────────────────────────────────── */
+2 -4
View File
@@ -25,12 +25,10 @@ let _wsClosingHandled = false;
const _directHandlers = [];
/**
* Build the WebSocket URL. Supports an override via `window.__WS_URL__`
* (useful for proxy setups). Falls back to port 9091 when the current
* origin has no port (nginx fronting the WS on a different port).
* Build the WebSocket URL from the current origin. nginx proxies /ws to
* the daemon's WebSocket port.
*/
function _wsUrl() {
if (window.__WS_URL__) return window.__WS_URL__;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return proto + '//' + location.host + '/ws';
}
-1
View File
@@ -14,7 +14,6 @@
</div>
</div>
<div id="modal-root"></div>
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
<script type="module" src="/static/app.js"></script>
</body>
</html>