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:
+2
-13
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user