56b200d233
New modules: lib/auth, lib/auth_users, lib/db, lib/db_sqlite, lib/password, lib/webauthn, daemon/handlers/auth, scripts/bootstrap_auth, tests/test_auth Frontend: webui/api/auth, hoover/components/auth, pages/login, passkeys, users Updates: daemon/iface and server, lib/common and nginx, pyproject.toml deps, install script, server.py, app.js, and websocket/api clients
259 lines
6.7 KiB
Python
259 lines
6.7 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
|
|
from typing import Any
|
|
|
|
from lib.auth import blacklist_active_refresh_token, blacklist_expired
|
|
from lib.db import (
|
|
Q_DELETE_PERMISSIONS,
|
|
Q_DELETE_USER,
|
|
Q_INSERT_USER,
|
|
Q_SELECT_ALL_USERS,
|
|
Q_SELECT_PERMISSIONS,
|
|
Q_SELECT_USER_BY_NAME,
|
|
Q_UPDATE_PASSWORD,
|
|
Q_UPSERT_PERMISSION,
|
|
get_db,
|
|
)
|
|
from lib.password import hash_password, verify_password
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 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).
|
|
|
|
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.
|
|
|
|
Used for password verification. 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:
|
|
return None
|
|
if not verify_password(password, user["password_hash"]):
|
|
return None
|
|
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)
|
|
|
|
db = get_db()
|
|
with db.in_transaction() as tx:
|
|
tx.run_one(Q_INSERT_USER, (username, password_hash))
|
|
if permissions:
|
|
for subsystem, level in permissions.items():
|
|
tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level))
|
|
|
|
return {
|
|
"id": db.query(Q_SELECT_USER_BY_NAME, (username,))[0]["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 refresh tokens.
|
|
|
|
Old access tokens expire naturally (15 min TTL). The active refresh
|
|
token is immediately blacklisted to prevent token reuse.
|
|
|
|
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)
|
|
db = get_db()
|
|
db.run(Q_UPDATE_PASSWORD, (new_hash, username))
|
|
_cleanup_blacklist()
|
|
return True
|
|
|
|
|
|
def update_permissions(username: str, permissions: dict[str, str]) -> None:
|
|
"""Update a user's permissions.
|
|
|
|
Replaces all existing permissions with the provided mapping.
|
|
|
|
Args:
|
|
username: The username.
|
|
permissions: Dict mapping subsystem names to permission levels.
|
|
"""
|
|
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:
|
|
tx.run(Q_DELETE_PERMISSIONS, (username,))
|
|
for subsystem, level in permissions.items():
|
|
tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level))
|
|
|
|
|
|
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_ALL_USERS, ())
|
|
|
|
result = []
|
|
for row in rows:
|
|
username = row["username"]
|
|
permissions = _get_permissions(username)
|
|
result.append(
|
|
{
|
|
"id": row["id"],
|
|
"username": username,
|
|
"permissions": permissions,
|
|
"created_at": row["created_at"],
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
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.
|
|
"""
|
|
user = find_user(username)
|
|
if user is None:
|
|
raise ValueError(f"User {username!r} not found")
|
|
|
|
db = get_db()
|
|
db.run(Q_DELETE_USER, (username,))
|
|
return True
|
|
|
|
|
|
def _cleanup_blacklist() -> None:
|
|
"""Clean up expired blacklist entries."""
|
|
blacklist_expired()
|