feat: add auth subsystem with WebAuthn passkeys support
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
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user