Files
mteehan 0ed275835d 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.
2026-08-17 01:45:15 +00:00

325 lines
9.6 KiB
Python

"""User CRUD and permission management for Vacuum Wall authentication.
All operations use DB query IDs through the Database abstract layer.
Password hashing uses Argon2id via lib.password.
"""
from __future__ import annotations
import logging
import re
import secrets
from typing import Any
from lib.auth import (
blacklist_active_refresh_token,
rotate_user_secret,
)
from lib.db import (
Q_DELETE_PERMISSION_SUBSYSTEM,
Q_DELETE_USER,
Q_INSERT_USER,
Q_SELECT_PERMISSIONS,
Q_SELECT_USER_BY_NAME,
Q_SELECT_USERS_WITH_PERMS,
Q_UPDATE_PASSWORD,
Q_UPSERT_PERMISSION,
get_db,
)
from lib.password import hash_password, needs_rehash, verify_password
logger = logging.getLogger(__name__)
# Pre-computed dummy hash for constant-time verification on nonexistent users.
# Generated once at module load to avoid timing leaks from per-call hash generation.
_DUMMY_HASH = hash_password(secrets.token_hex(32))
# Builtin admin — hardcoded, full access, cannot be modified/deleted
BUILTIN_ADMIN_USERNAME = "admin"
# All subsystem names for default permission assignment
ALL_SUBSYSTEMS = [
"firewall",
"network",
"dhcp",
"proxy",
"certs",
"wireguard",
"logs",
"status",
"auth",
]
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_-]{3,32}$")
def _get_permissions(username: str) -> dict[str, str]:
"""Load permissions for *username* from the database.
Args:
username: The username to load permissions for.
Returns:
Dict mapping subsystem names to permission levels.
"""
db = get_db()
rows = db.query(Q_SELECT_PERMISSIONS, (username,))
return {row["subsystem"]: row["level"] for row in rows}
def get_user(username: str) -> dict[str, Any] | None:
"""Get a user by username (without password hash or JWT secret).
Args:
username: The username to look up.
Returns:
User dict with id, username, permissions, or None if not found.
"""
user = find_user(username)
if user is None:
return None
return {
"id": user["id"],
"username": user["username"],
"permissions": _get_permissions(username),
}
def find_user(username: str) -> dict[str, Any] | None:
"""Find a user by username, including password hash and JWT secret.
Used for password verification and token operations. Not returned through APIs.
Args:
username: The username to look up.
Returns:
User dict from the database row, or None.
"""
db = get_db()
rows = db.query(Q_SELECT_USER_BY_NAME, (username,))
if not rows:
return None
return rows[0]
def verify_user_password(username: str, password: str) -> dict[str, Any] | None:
"""Verify a user's password using Argon2id.
Args:
username: The username to verify.
password: Plain-text password.
Returns:
User dict (without password hash) if the password is correct, None otherwise.
"""
user = find_user(username)
if user is None:
# Run a dummy Argon2id verification against a pre-computed hash to
# prevent timing-based user enumeration. Uses module-level hash
# so both paths take ~1 verify call (~200ms) instead of ~400ms.
verify_password(password, _DUMMY_HASH)
return None
if not verify_password(password, user["password_hash"]):
return None
if needs_rehash(user["password_hash"]):
new_hash = hash_password(password)
db = get_db()
db.run(Q_UPDATE_PASSWORD, (new_hash, username))
logger.info("Password hash rehashed for %r (param upgrade)", username)
return {
"id": user["id"],
"username": user["username"],
"permissions": _get_permissions(username),
}
def create_user(
username: str,
password: str,
permissions: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Create a new user with the given password and permissions.
Args:
username: The new username (3-32 alphanumeric chars, dash, underscore).
password: Plain-text password that will be hashed with Argon2id.
permissions: Dict mapping subsystem names to "read" or "rw".
Returns:
The created user dict with id, username, permissions.
Raises:
ValueError: If username is invalid or already exists.
"""
if not _USERNAME_RE.match(username):
raise ValueError(
"Username must be 3-32 characters: letters, digits, dash, underscore"
)
existing = find_user(username)
if existing is not None:
raise ValueError(f"User {username!r} already exists")
password_hash = hash_password(password)
jwt_secret = secrets.token_urlsafe(32)
db = get_db()
with db.in_transaction() as tx:
user_id = tx.run_one(Q_INSERT_USER, (username, password_hash, jwt_secret))
if permissions:
for subsystem, level in permissions.items():
tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level))
return {
"id": user_id,
"username": username,
"permissions": _get_permissions(username),
}
def update_password(username: str, old_password: str, new_password: str) -> bool:
"""Update a user's password and invalidate all active tokens.
Rotates the user's JWT secret, immediately invalidating all existing
access and refresh tokens.
Args:
username: The username.
old_password: Current password.
new_password: New plain-text password.
Returns:
True if password was updated.
Raises:
ValueError: If old password is incorrect.
"""
if not verify_user_password(username, old_password):
raise ValueError("Current password is incorrect")
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))
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.
Replaces all existing permissions with the provided mapping. Rotates the
JWT secret so that permission changes take effect immediately — existing
tokens with stale permissions are no longer valid.
Args:
username: The username.
permissions: Dict mapping subsystem names to permission levels.
Raises:
ValueError: If attempting to modify builtin admin.
"""
if username == BUILTIN_ADMIN_USERNAME:
raise ValueError("Cannot modify permissions for builtin admin")
user = find_user(username)
if user is None:
raise ValueError(f"User {username!r} not found")
db = get_db()
with db.in_transaction() as tx:
# Upsert all new permissions first, then remove stale ones.
# This order ensures that if an upsert fails mid-loop, the user's
# permissions remain intact (transaction rolls back) rather than
# being permanently wiped.
existing = {
row["subsystem"]: row["level"]
for row in db.query(Q_SELECT_PERMISSIONS, (username,))
}
for subsystem, level in permissions.items():
tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level))
for subsystem in existing:
if subsystem not in permissions:
tx.run(Q_DELETE_PERMISSION_SUBSYSTEM, (username, subsystem))
blacklist_active_refresh_token(username)
rotate_user_secret(username)
def list_users() -> list[dict[str, Any]]:
"""List all users with their permissions.
Returns:
List of user summary dicts.
"""
db = get_db()
rows = db.query(Q_SELECT_USERS_WITH_PERMS, ())
users: dict[int, dict[str, Any]] = {}
for row in rows:
uid = row["id"]
if uid not in users:
users[uid] = {
"id": uid,
"username": row["username"],
"permissions": {},
"created_at": row["created_at"],
}
if row["subsystem"] is not None:
users[uid]["permissions"][row["subsystem"]] = row["level"]
return list(users.values())
def delete_user(username: str) -> bool:
"""Delete a user and all their associated data.
Permissions and WebAuthn credentials are CASCADE-deleted by the schema.
Args:
username: The username to delete.
Returns:
True if the user was deleted.
Raises:
ValueError: If the user does not exist or is the builtin admin.
"""
if username == BUILTIN_ADMIN_USERNAME:
raise ValueError("Cannot delete builtin admin user")
user = find_user(username)
if user is None:
raise ValueError(f"User {username!r} not found")
blacklist_active_refresh_token(username)
db = get_db()
db.run(Q_DELETE_USER, (username,))
return True