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:
@@ -24,7 +24,7 @@ through the daemon client over a Unix socket.
|
|||||||
|
|
||||||
### Code Layout
|
### 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/*.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.
|
- `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.
|
- `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh, periodic polling.
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ from lib.auth import (
|
|||||||
blacklist_token,
|
blacklist_token,
|
||||||
check_login_rate,
|
check_login_rate,
|
||||||
check_webauthn_rate,
|
check_webauthn_rate,
|
||||||
clear_active_refresh_token,
|
|
||||||
generate_tokens,
|
generate_tokens,
|
||||||
get_access_ttl,
|
get_access_ttl,
|
||||||
record_login_failure,
|
record_login_failure,
|
||||||
@@ -70,11 +69,6 @@ from lib.webauthn import (
|
|||||||
logger = logging.getLogger(__name__)
|
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)
|
@registry.register(POST_AUTH_LOGIN)
|
||||||
def auth_login(_request: Any, body: Any) -> dict[str, Any]:
|
def auth_login(_request: Any, body: Any) -> dict[str, Any]:
|
||||||
"""Handle user login.
|
"""Handle user login.
|
||||||
@@ -196,8 +190,6 @@ def auth_refresh(_request: Any, body: Any) -> dict[str, Any]:
|
|||||||
jti = payload.get("jti")
|
jti = payload.get("jti")
|
||||||
if jti:
|
if jti:
|
||||||
blacklist_token(jti, token_type="refresh")
|
blacklist_token(jti, token_type="refresh")
|
||||||
if username:
|
|
||||||
_clear_refresh_token_after_rotation(username)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tokens": tokens,
|
"tokens": tokens,
|
||||||
|
|||||||
+12
-5
@@ -375,17 +375,24 @@ async def _handle_ws(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
Authentication: JWT access token passed via:
|
Authentication: JWT access token passed via:
|
||||||
1. WebSocket subprotocol header (Sec-WebSocket-Protocol: "Bearer <token>")
|
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
|
from lib.auth import validate_token
|
||||||
|
|
||||||
token_param = None
|
token_param = None
|
||||||
matched_proto = None
|
matched_proto = None
|
||||||
|
|
||||||
# Prefer subprotocol header (client JS sends "Bearer <token>")
|
# Prefer subprotocol header (client JS sends "Bearer <token>").
|
||||||
subprotocols = request.get_subprotocols()
|
# Sec-WebSocket-Protocol is a comma-separated list; parse it the same
|
||||||
for proto in subprotocols or []:
|
# way aiohttp's own handshake does (Request has no subprotocol helper).
|
||||||
if proto and proto.startswith("Bearer "):
|
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:]
|
token_param = proto[7:]
|
||||||
matched_proto = proto
|
matched_proto = proto
|
||||||
break
|
break
|
||||||
|
|||||||
+15
-3
@@ -206,7 +206,13 @@ Create a new user with password and per-subsystem permissions.
|
|||||||
| `password` | `string` | Yes | Plain-text password |
|
| `password` | `string` | Yes | Plain-text password |
|
||||||
| `permissions` | `object` | No | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
|
| `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.
|
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 |
|
| `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.
|
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.
|
**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.
|
Returns HTTP `404` if user not found.
|
||||||
|
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ The `data/` directory holds generated files, credentials, and subsystem artifact
|
|||||||
data/
|
data/
|
||||||
├── auth.db # SQLite database: users, permissions, token_blacklist, webauthn_creds
|
├── auth.db # SQLite database: users, permissions, token_blacklist, webauthn_creds
|
||||||
├── nginx/
|
├── 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)
|
│ └── sites-enabled/ # Generated nginx server block .conf files (one per domain)
|
||||||
├── dnsmasq/
|
├── dnsmasq/
|
||||||
│ └── fragments/ # User-defined dnsmasq config fragments (appended verbatim)
|
│ └── 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)
|
### 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
|
Client loads /static/app.js ──→ Hoover initializes, checkSession() → 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
|
||||||
|
|||||||
+2
-2
@@ -34,7 +34,7 @@ index.html — static shell with #sidebar, #main, #modal-root
|
|||||||
└── connect() — WebSocket lifecycle
|
└── 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.
|
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()`
|
### `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.
|
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
@@ -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.
|
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.
|
||||||
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:
|
Token theft protection:
|
||||||
- Short-lived access tokens (15 min) limit the window of exploitation
|
- Short-lived access tokens (15 min) limit the window of exploitation
|
||||||
- Token blacklist prevents reuse after logout or password change
|
- Token blacklist prevents reuse after logout or password change
|
||||||
- XSS mitigations: CSP headers, `X-XSS-Protection` header on management domain
|
- 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
|
### WebAuthn Security
|
||||||
|
|
||||||
|
|||||||
+2
-13
@@ -217,19 +217,6 @@ def blacklist_active_refresh_token(username: str) -> None:
|
|||||||
db.run(Q_DELETE_REFRESH_TOKEN, (username,))
|
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:
|
def _extract_unverified_sub(token_string: str) -> str | None:
|
||||||
"""Extract the ``sub`` claim from a JWT payload without signature verification.
|
"""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_b64 += "=" * padding
|
||||||
payload_json = base64.urlsafe_b64decode(payload_b64)
|
payload_json = base64.urlsafe_b64decode(payload_b64)
|
||||||
payload = json.loads(payload_json)
|
payload = json.loads(payload_json)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
return payload.get("sub")
|
return payload.get("sub")
|
||||||
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -207,6 +207,31 @@ def update_password(username: str, old_password: str, new_password: str) -> bool
|
|||||||
return True
|
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:
|
def update_permissions(username: str, permissions: dict[str, str]) -> None:
|
||||||
"""Update a user's permissions and invalidate all existing tokens.
|
"""Update a user's permissions and invalidate all existing tokens.
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import threading
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
@@ -163,16 +164,22 @@ class Database(ABC):
|
|||||||
|
|
||||||
def __init__(self, connection_string: str) -> None:
|
def __init__(self, connection_string: str) -> None:
|
||||||
self._connection_string = connection_string
|
self._connection_string = connection_string
|
||||||
self._conn: Any = None
|
|
||||||
self._prepared: dict[str, Any] = {}
|
self._prepared: dict[str, Any] = {}
|
||||||
self._in_transaction = False
|
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
|
@property
|
||||||
def conn(self) -> Any:
|
def conn(self) -> Any:
|
||||||
"""Return the cached database connection, creating it lazily."""
|
"""Return this thread's cached database connection, creating it lazily."""
|
||||||
if self._conn is None:
|
conn = getattr(self._local, "conn", None)
|
||||||
self._conn = self._connect(self._connection_string)
|
if conn is None:
|
||||||
return self._conn
|
conn = self._connect(self._connection_string)
|
||||||
|
self._local.conn = conn
|
||||||
|
return conn
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _connect(self, cs: str) -> Any: ...
|
def _connect(self, cs: str) -> Any: ...
|
||||||
@@ -300,12 +307,22 @@ def _seed_builtin_admin(db: Database) -> None:
|
|||||||
placeholder_hash = hash_password(random_password)
|
placeholder_hash = hash_password(random_password)
|
||||||
jwt_secret = secrets.token_urlsafe(32)
|
jwt_secret = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
try:
|
||||||
with db.in_transaction() as tx:
|
with db.in_transaction() as tx:
|
||||||
tx.run_one(
|
tx.run_one(
|
||||||
Q_INSERT_USER, (BUILTIN_ADMIN_USERNAME, placeholder_hash, jwt_secret)
|
Q_INSERT_USER, (BUILTIN_ADMIN_USERNAME, placeholder_hash, jwt_secret)
|
||||||
)
|
)
|
||||||
for subsystem in ALL_SUBSYSTEMS:
|
for subsystem in ALL_SUBSYSTEMS:
|
||||||
tx.run(Q_UPSERT_PERMISSION, (BUILTIN_ADMIN_USERNAME, subsystem, "rw"))
|
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 = Path("/var/log/vacuum-wall/auth.log")
|
||||||
auth_log_written = False
|
auth_log_written = False
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ class SQLiteBackend(Database):
|
|||||||
"""Create a SQLite connection with WAL mode and row factory."""
|
"""Create a SQLite connection with WAL mode and row factory."""
|
||||||
conn = sqlite3.connect(cs, isolation_level=None)
|
conn = sqlite3.connect(cs, isolation_level=None)
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
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.execute("PRAGMA foreign_keys=ON")
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ WEBUI_BACKEND: dict[str, Any] = {
|
|||||||
"/": {
|
"/": {
|
||||||
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
|
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
|
||||||
"is_management": True,
|
"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": {
|
"/ws": {
|
||||||
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
|
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
|
||||||
|
|||||||
@@ -223,7 +223,14 @@ def _parse_addr_directive(line: str) -> dict[str, Any] | None:
|
|||||||
|
|
||||||
def import_wireguard() -> bool:
|
def import_wireguard() -> bool:
|
||||||
"""Parse /etc/wireguard/wg0.conf -> config/wireguard/config.json."""
|
"""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)
|
logger.debug("Skipping wireguard: %s not found", WG_CONF)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
"""Bootstrap auth: initialize DB and seed admin user at install time.
|
"""Bootstrap auth: initialize DB and seed admin user at install time.
|
||||||
|
|
||||||
Run once during installation. Writes config/auth/config.json with a
|
Idempotent — safe to run on every install (and re-install):
|
||||||
generated JWT secret and creates the admin user in SQLite.
|
|
||||||
|
- 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:
|
Usage:
|
||||||
python scripts/bootstrap_auth.py --project-dir /path/to/project \
|
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_BACKEND"] = "sqlite"
|
||||||
os.environ["VACUUM_WALL_DB_PATH"] = db_path
|
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 = project_dir / "config" / "auth"
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
config_path = config_dir / "config.json"
|
config_path = config_dir / "config.json"
|
||||||
|
|
||||||
|
if not config_path.exists():
|
||||||
config = {
|
config = {
|
||||||
"jwt": {
|
"jwt": {
|
||||||
"access_token_ttl": 300,
|
"access_token_ttl": 300,
|
||||||
@@ -54,17 +66,20 @@ def main() -> None:
|
|||||||
"origin": f"https://{args.domain}",
|
"origin": f"https://{args.domain}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
with open(config_path, "w") as f:
|
with open(config_path, "w") as f:
|
||||||
json.dump(config, f, indent=2)
|
json.dump(config, f, indent=2)
|
||||||
f.write("\n")
|
f.write("\n")
|
||||||
|
|
||||||
print(f"Wrote auth config: {config_path}")
|
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}
|
permissions = {sub: "rw" for sub in ALL_SUBSYSTEMS}
|
||||||
user = create_user(args.username, args.password, permissions)
|
user = create_user(args.username, args.password, permissions)
|
||||||
|
|
||||||
print(f"Created admin user: {user['username']} (id={user['id']})")
|
print(f"Created admin user: {user['username']} (id={user['id']})")
|
||||||
print(f"Permissions: {len(permissions)} subsystems, all 'rw'")
|
print(f"Permissions: {len(permissions)} subsystems, all 'rw'")
|
||||||
|
|
||||||
|
|||||||
+22
-7
@@ -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 "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme}
|
||||||
mkdir -p /etc/wireguard
|
mkdir -p /etc/wireguard
|
||||||
mkdir -p /etc/dnsmasq
|
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
|
if [[ "$_cli_is_dev" == true ]]; then
|
||||||
_dev_owner="$USER_NAME"
|
_dev_owner="$USER_NAME"
|
||||||
else
|
else
|
||||||
_dev_owner="$USER_DAEMON_NAME"
|
_dev_owner="$USER_DAEMON_NAME"
|
||||||
fi
|
fi
|
||||||
chown -R "$_dev_owner:$USER_GROUP" "$PROJECT_DIR"
|
(
|
||||||
chmod -R g+rwX "$PROJECT_DIR"
|
shopt -s dotglob nullglob
|
||||||
find "$PROJECT_DIR" -type d -exec chmod g+s '{}' +
|
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 ---
|
# --- 4. Template rendering function ---
|
||||||
# Renders Jinja2 templates by injecting env vars as template context.
|
# Renders Jinja2 templates by injecting env vars as template context.
|
||||||
@@ -344,10 +355,16 @@ else
|
|||||||
echo ""
|
echo ""
|
||||||
echo " Setting up initial management configuration..."
|
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
|
if [[ ! -f "${PROJECT_DIR}/config/auth/config.json" ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo " Bootstrapping auth (creating admin user: $MGMT_USER)..."
|
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}/.venv/bin/python3" "${PROJECT_DIR}/scripts/bootstrap_auth.py" \
|
||||||
--project-dir "$PROJECT_DIR" \
|
--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}/config/auth"
|
||||||
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/data/auth.db" 2>/dev/null || true
|
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
|
# Helper: POST JSON to daemon API over Unix socket
|
||||||
_daemon_post() {
|
_daemon_post() {
|
||||||
@@ -381,7 +397,6 @@ else
|
|||||||
_daemon_post "/acme/self-signed" "{\"domain\":\"$DOMAIN\"}" "Self-signed certificate"
|
_daemon_post "/acme/self-signed" "{\"domain\":\"$DOMAIN\"}" "Self-signed certificate"
|
||||||
|
|
||||||
# 1C. Management proxy domain (no auth — JWT auth is handled by Flask)
|
# 1C. Management proxy domain (no auth — JWT auth is handled by Flask)
|
||||||
local mgmt_json
|
|
||||||
mgmt_json="$(jq -n \
|
mgmt_json="$(jq -n \
|
||||||
--arg domain "$DOMAIN" \
|
--arg domain "$DOMAIN" \
|
||||||
'{
|
'{
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ Environment=HOME={{ PROJECT_DIR }}
|
|||||||
# Security hardening
|
# Security hardening
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
ProtectSystem=strict
|
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
|
PrivateTmp=yes
|
||||||
ProtectKernelTunables=yes
|
ProtectKernelTunables=yes
|
||||||
ProtectKernelModules=yes
|
ProtectKernelModules=yes
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ Environment=HOME={{ PROJECT_DIR }}
|
|||||||
RuntimeDirectory=vacuum-wall
|
RuntimeDirectory=vacuum-wall
|
||||||
RuntimeDirectoryMode=0750
|
RuntimeDirectoryMode=0750
|
||||||
|
|
||||||
|
LogsDirectory=vacuum-wall
|
||||||
|
|
||||||
# Security hardening
|
# Security hardening
|
||||||
ProtectSystem=strict
|
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
|
PrivateTmp=yes
|
||||||
ProtectKernelTunables=yes
|
ProtectKernelTunables=yes
|
||||||
ProtectKernelModules=yes
|
ProtectKernelModules=yes
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from lib.auth_users import (
|
|||||||
delete_user,
|
delete_user,
|
||||||
get_user,
|
get_user,
|
||||||
list_users,
|
list_users,
|
||||||
|
reset_password,
|
||||||
update_password,
|
update_password,
|
||||||
update_permissions,
|
update_permissions,
|
||||||
verify_user_password,
|
verify_user_password,
|
||||||
@@ -132,6 +133,62 @@ class TestDBLayer:
|
|||||||
conn2 = db.conn
|
conn2 = db.conn
|
||||||
assert conn1 is conn2
|
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):
|
def test_insert_user(self, db):
|
||||||
uid = db.run_one(Q_INSERT_USER, ("testuser", "$argon2id$hash", "test-secret"))
|
uid = db.run_one(Q_INSERT_USER, ("testuser", "$argon2id$hash", "test-secret"))
|
||||||
assert isinstance(uid, int)
|
assert isinstance(uid, int)
|
||||||
@@ -350,6 +407,17 @@ class TestUserManagement:
|
|||||||
with pytest.raises(ValueError, match="incorrect"):
|
with pytest.raises(ValueError, match="incorrect"):
|
||||||
update_password("upwfail", "wrong_old", "newpass123")
|
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):
|
def test_update_permissions(self, db):
|
||||||
create_user("permuser", "password123", {"firewall": "rw"})
|
create_user("permuser", "password123", {"firewall": "rw"})
|
||||||
update_permissions("permuser", {"firewall": "read", "network": "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("/api/dhcp/leases/subpath") == "dhcp"
|
||||||
assert _subsystem_from_path("/") is None
|
assert _subsystem_from_path("/") is None
|
||||||
assert _subsystem_from_path("/static/app.js") 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
@@ -39,14 +39,17 @@ class TestSPARoutes:
|
|||||||
assert data.get("error") == "unauthorized"
|
assert data.get("error") == "unauthorized"
|
||||||
|
|
||||||
|
|
||||||
class TestWsUrlGeneration:
|
class TestSpaRoot:
|
||||||
def test_ws_url_ipv4_host(self, client):
|
def test_serves_index_html_as_is(self, client):
|
||||||
resp = client.get("/", headers={"Host": "192.168.1.1:9090"})
|
resp = client.get("/")
|
||||||
assert b"ws://192.168.1.1:9090/ws" in resp.data
|
assert resp.status_code == 200
|
||||||
|
assert b"/static/app.js" in resp.data
|
||||||
|
|
||||||
def test_ws_url_ipv6_host(self, client):
|
def test_no_ws_url_substitution(self, client):
|
||||||
resp = client.get("/", headers={"Host": "[::1]:9090"})
|
"""index.html is served verbatim — no WS URL placeholder substitution."""
|
||||||
assert b"ws://[::1]:9090/ws" in resp.data
|
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:
|
class TestBlueprintsRegistered:
|
||||||
|
|||||||
+3
-1
@@ -9,7 +9,7 @@ import logging
|
|||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
|
|
||||||
from daemon.client import delete, get, post
|
from daemon.client import Conflict, delete, get, post
|
||||||
from daemon.iface import (
|
from daemon.iface import (
|
||||||
DELETE_AUTH_USER,
|
DELETE_AUTH_USER,
|
||||||
DELETE_AUTH_WEBAUTHN_CREDENTIAL,
|
DELETE_AUTH_WEBAUTHN_CREDENTIAL,
|
||||||
@@ -167,6 +167,8 @@ def create_user():
|
|||||||
try:
|
try:
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
return _ok(post(POST_AUTH_USER_CREATE, body))
|
return _ok(post(POST_AUTH_USER_CREATE, body))
|
||||||
|
except Conflict as exc:
|
||||||
|
return _error(str(exc), 409)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Create user failed: %s", exc)
|
logger.error("Create user failed: %s", exc)
|
||||||
return _error(str(exc), 400)
|
return _error(str(exc), 400)
|
||||||
|
|||||||
+1
-4
@@ -307,10 +307,7 @@ VENDOR_DIR = PROJECT_DIR / "vendor"
|
|||||||
@app.route("/")
|
@app.route("/")
|
||||||
def spa_root():
|
def spa_root():
|
||||||
"""Serve the SPA entry point. No catch-all — client handles routing."""
|
"""Serve the SPA entry point. No catch-all — client handles routing."""
|
||||||
scheme = "wss" if request.is_secure else "ws"
|
return (SPA_DIR / "index.html").read_text()
|
||||||
ws_url = f"{scheme}://{request.host}/ws"
|
|
||||||
html = (SPA_DIR / "index.html").read_text()
|
|
||||||
return html.replace("__WS_URL_PLACEHOLDER__", ws_url)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/vendor/<path:filename>")
|
@app.route("/vendor/<path:filename>")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const _NavBase = [
|
|||||||
{ path: '/certs', label: 'Certs' },
|
{ path: '/certs', label: 'Certs' },
|
||||||
{ path: '/wireguard', label: 'WireGuard' },
|
{ path: '/wireguard', label: 'WireGuard' },
|
||||||
{ path: '/logs', label: 'Logs' },
|
{ path: '/logs', label: 'Logs' },
|
||||||
|
{ path: '/passkeys', label: 'Passkeys' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getNav() {
|
function getNav() {
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ import { getAuthToken, getAuthData, refreshAuth } from './auth_model.js';
|
|||||||
import { requestUpdate } from './reactivity.js';
|
import { requestUpdate } from './reactivity.js';
|
||||||
import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.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.
|
* JSON-friendly fetch wrapper.
|
||||||
*
|
*
|
||||||
@@ -49,7 +64,7 @@ export async function apiFetch(url, options = {}) {
|
|||||||
if (safeOpts.signal?.aborted) {
|
if (safeOpts.signal?.aborted) {
|
||||||
return { ok: false, data: null, error: 'Aborted', status: 0 };
|
return { ok: false, data: null, error: 'Aborted', status: 0 };
|
||||||
}
|
}
|
||||||
if (res.status === 401 && token) {
|
if (res.status === 401 && token && !_isPublicAuthUrl(url)) {
|
||||||
await refreshAuth();
|
await refreshAuth();
|
||||||
const auth = getAuthData();
|
const auth = getAuthData();
|
||||||
if (auth?.token) {
|
if (auth?.token) {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
* - WebAuthn (passkey) ceremony helpers — not state management
|
* - WebAuthn (passkey) ceremony helpers — not state management
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiFetch } from '../api.js';
|
|
||||||
import { modelFetch } from '../model.js';
|
import { modelFetch } from '../model.js';
|
||||||
import { getAuthData } from '../auth_model.js';
|
import { getAuthData } from '../auth_model.js';
|
||||||
|
|
||||||
@@ -65,20 +64,6 @@ export function webauthnSupported() {
|
|||||||
return typeof window !== 'undefined' && !!window.PublicKeyCredential;
|
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 ──────────────────────────────────────────────── */
|
/* ─── Base64url helpers ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoa
|
|||||||
from './api.js';
|
from './api.js';
|
||||||
|
|
||||||
/* ── UI Components: Auth ──────────────────────────────────────── */
|
/* ── UI Components: Auth ──────────────────────────────────────── */
|
||||||
export { logout, doLogin, webauthnSupported, checkWebAuthnCapable,
|
export { logout, doLogin, webauthnSupported,
|
||||||
startRegistration, startAuthentication } from './components/auth.js';
|
startRegistration, startAuthentication } from './components/auth.js';
|
||||||
|
|
||||||
/* ── Auth model ───────────────────────────────────────────────── */
|
/* ── Auth model ───────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -25,12 +25,10 @@ let _wsClosingHandled = false;
|
|||||||
const _directHandlers = [];
|
const _directHandlers = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the WebSocket URL. Supports an override via `window.__WS_URL__`
|
* Build the WebSocket URL from the current origin. nginx proxies /ws to
|
||||||
* (useful for proxy setups). Falls back to port 9091 when the current
|
* the daemon's WebSocket port.
|
||||||
* origin has no port (nginx fronting the WS on a different port).
|
|
||||||
*/
|
*/
|
||||||
function _wsUrl() {
|
function _wsUrl() {
|
||||||
if (window.__WS_URL__) return window.__WS_URL__;
|
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
return proto + '//' + location.host + '/ws';
|
return proto + '//' + location.host + '/ws';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="modal-root"></div>
|
<div id="modal-root"></div>
|
||||||
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
|
|
||||||
<script type="module" src="/static/app.js"></script>
|
<script type="module" src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user