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
+27 -10
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)
with db.in_transaction() as tx:
tx.run_one(
Q_INSERT_USER, (BUILTIN_ADMIN_USERNAME, placeholder_hash, jwt_secret)
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,
)
for subsystem in ALL_SUBSYSTEMS:
tx.run(Q_UPSERT_PERMISSION, (BUILTIN_ADMIN_USERNAME, subsystem, "rw"))
return
auth_log = Path("/var/log/vacuum-wall/auth.log")
auth_log_written = False