From 56b200d233a4abbee524972529da76ba9cd608e2 Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Fri, 24 Jul 2026 01:21:39 +0000 Subject: [PATCH] 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 --- daemon/handlers/auth.py | 528 +++++++++++++ daemon/handlers/wireguard.py | 10 +- daemon/iface.py | 33 + daemon/server.py | 35 +- lib/auth.py | 358 +++++++++ lib/auth_users.py | 258 +++++++ lib/common.py | 19 + lib/db.py | 280 +++++++ lib/db_sqlite.py | 168 +++++ lib/nginx.py | 27 +- lib/password.py | 83 ++ lib/webauthn.py | 355 +++++++++ pyproject.toml | 3 + scripts/bootstrap_auth.py | 78 ++ scripts/install.sh | 46 +- tests/test_auth.py | 964 ++++++++++++++++++++++++ tests/test_server.py | 9 +- webui/api/auth.py | 352 +++++++++ webui/server.py | 93 ++- webui/static/app.js | 52 +- webui/static/hoover/api.js | 134 +++- webui/static/hoover/components/auth.js | 268 +++++++ webui/static/hoover/components/modal.js | 3 +- webui/static/hoover/index.js | 9 +- webui/static/hoover/websocket.js | 63 +- webui/static/pages/login.js | 207 +++++ webui/static/pages/passkeys.js | 288 +++++++ webui/static/pages/users.js | 259 +++++++ 28 files changed, 4900 insertions(+), 82 deletions(-) create mode 100644 daemon/handlers/auth.py create mode 100644 lib/auth.py create mode 100644 lib/auth_users.py create mode 100644 lib/db.py create mode 100644 lib/db_sqlite.py create mode 100644 lib/password.py create mode 100644 lib/webauthn.py create mode 100644 scripts/bootstrap_auth.py create mode 100644 tests/test_auth.py create mode 100644 webui/api/auth.py create mode 100644 webui/static/hoover/components/auth.js create mode 100644 webui/static/pages/login.js create mode 100644 webui/static/pages/passkeys.js create mode 100644 webui/static/pages/users.js diff --git a/daemon/handlers/auth.py b/daemon/handlers/auth.py new file mode 100644 index 0000000..b68dddd --- /dev/null +++ b/daemon/handlers/auth.py @@ -0,0 +1,528 @@ +"""Authentication daemon handlers. + +Handles login, logout, token refresh, session management, password change, +user CRUD, and WebAuthn operations. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from daemon.iface import ( + DELETE_AUTH_USER, + DELETE_AUTH_WEBAUTHN_CREDENTIAL, + GET_AUTH_SESSION, + GET_AUTH_USERS, + GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS, + GET_AUTH_WEBAUTHN_CREDENTIALS, + POST_AUTH_LOGIN, + POST_AUTH_LOGOUT, + POST_AUTH_PASSWORD, + POST_AUTH_REFRESH, + POST_AUTH_USER_CREATE, + POST_AUTH_USER_UPDATE, + POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN, + POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH, + POST_AUTH_WEBAUTHN_REGISTER_BEGIN, + POST_AUTH_WEBAUTHN_REGISTER_FINISH, +) +from daemon.server import ConflictError, NotFoundError, registry +from lib.auth import ( + blacklist_active_refresh_token, + blacklist_token, + check_login_rate, + check_webauthn_rate, + clear_active_refresh_token, + decode_token, + generate_tokens, + get_access_ttl, + validate_token, +) +from lib.auth_users import ( + ALL_SUBSYSTEMS, + create_user, + delete_user, + get_user, + list_users, + update_password, + update_permissions, + verify_user_password, +) +from lib.webauthn import ( + create_authentication_options, + create_registration_options, + get_all_credential_counts, + list_credentials, + remove_credential, + verify_authentication, + verify_registration, +) + +logger = logging.getLogger(__name__) + +_ALL_RW = {sub: "rw" for sub in ALL_SUBSYSTEMS} + + +def _clear_refresh_token_after_rotation(username: str) -> None: + """Remove the user's entry from refresh_tokens after a successful refresh rotation.""" + clear_active_refresh_token(username) + + +@registry.register(POST_AUTH_LOGIN) +def auth_login(_request: Any, body: Any) -> dict[str, Any]: + """Handle user login. + + Args: + _request: Unused. + body: Dict with ``username`` and ``password``. + + Returns: + Dict with ``tokens``, ``user``, and ``permissions``. + + Raises: + ValueError: If credentials are invalid. + """ + if not body or not isinstance(body, dict): + raise ValueError("Request body is required") + + username = body.get("username") + password = body.get("password") + if not username or not password: + raise ValueError("username and password are required") + + if not check_login_rate(username): + raise ValueError("Too many login attempts. Please try again later.") + + user = verify_user_password(username, password) + if user is None: + raise ValueError("Invalid credentials") + + permissions = user["permissions"] + tokens = generate_tokens(username, permissions) + + return { + "tokens": tokens, + "access_ttl": get_access_ttl(), + "user": { + "id": user["id"], + "username": user["username"], + }, + "permissions": permissions, + } + + +@registry.register(POST_AUTH_LOGOUT) +def auth_logout(request: Any, body: Any) -> dict[str, Any]: + """Handle user logout by blacklisting the access and refresh tokens. + + Args: + request: The aiohttp request. + body: Dict with ``jti``, ``username`` from Flask user context, and + ``refresh_token`` from the client. + + Returns: + Success response. + """ + if not body: + return {"ok": True} + + jti = body.get("jti") + if jti: + blacklist_token(jti) + + refresh_token = body.get("refresh_token") + if refresh_token: + payload = decode_token(refresh_token) + if payload: + refresh_jti = payload.get("jti") + if refresh_jti: + blacklist_token(refresh_jti, token_type="refresh") + + username = body.get("username") + if username: + blacklist_active_refresh_token(username) + + return {"ok": True} + + +@registry.register(POST_AUTH_REFRESH) +def auth_refresh(_request: Any, body: Any) -> dict[str, Any]: + """Handle token refresh. + + Validates the refresh token, blacklists it, and issues a new access token. + + Args: + _request: Unused. + body: Dict with ``refresh_token``. + + Returns: + Dict with new access token, refresh token, user, and permissions. + + Raises: + ValueError: If refresh token is invalid. + """ + if not body or not isinstance(body, dict): + raise ValueError("Request body is required") + + refresh_token = body.get("refresh_token") + if not refresh_token: + raise ValueError("refresh_token is required") + + payload = validate_token(refresh_token, token_type="refresh") + if payload is None: + raise ValueError("Invalid or expired refresh token") + + username = payload["sub"] + user = get_user(username) + if user is None: + raise ValueError("User not found") + + jti = payload.get("jti") + if jti: + blacklist_token(jti, token_type="refresh") + if username: + _clear_refresh_token_after_rotation(username) + permissions = user["permissions"] + tokens = generate_tokens(username, permissions) + + return { + "tokens": tokens, + "access_ttl": get_access_ttl(), + "user": { + "id": user["id"], + "username": user["username"], + }, + "permissions": permissions, + } + + +@registry.register(GET_AUTH_SESSION) +def auth_session(request: Any, body: Any) -> dict[str, Any]: + """Return current user session info. + + Args: + request: The aiohttp request. + body: Dict with ``username`` from Flask user context. + + Returns: + Dict with user info and permissions. + """ + username = body.get("username") if body else None + if username is None: + raise ValueError("No active session") + + user = get_user(username) + if user is None: + raise ValueError("User not found") + + return { + "user": { + "id": user["id"], + "username": user["username"], + }, + "permissions": user["permissions"], + } + + +@registry.register(POST_AUTH_PASSWORD) +def auth_change_password(_request: Any, body: Any) -> dict[str, Any]: + """Change user password. + + Args: + _request: Unused. + body: Dict with ``username``, ``oldPassword``, ``newPassword``. + + Returns: + Success response. + + Raises: + ValueError: If password change fails. + """ + if not body or not isinstance(body, dict): + raise ValueError("Request body is required") + + username = body.get("username") + old_password = body.get("oldPassword") + new_password = body.get("newPassword") + + if not username or not old_password or not new_password: + raise ValueError("username, oldPassword, and newPassword are required") + + if len(new_password) < 8: + raise ValueError("New password must be at least 8 characters") + + update_password(username, old_password, new_password) + logger.info("Password changed for user %r", username) + return {"ok": True} + + +@registry.register(GET_AUTH_USERS) +def auth_list_users(_request: Any, body: Any) -> list[dict[str, Any]]: + """List all users. + + Args: + _request: Unused. + body: Unused. + + Returns: + List of user summary dicts. + """ + return list_users() + + +@registry.register(POST_AUTH_USER_CREATE) +def auth_create_user(_request: Any, body: Any) -> dict[str, Any]: + """Create a new user. + + Args: + _request: Unused. + body: Dict with ``username``, ``password``, ``permissions``. + + Returns: + Created user dict. + + Raises: + ConflictError: If user already exists. + """ + if not body or not isinstance(body, dict): + raise ValueError("Request body is required") + + username = body.get("username") + password = body.get("password") + permissions = body.get("permissions", {}) + + if not username or not password: + raise ValueError("username and password are required") + + if len(password) < 8: + raise ValueError("Password must be at least 8 characters") + + try: + user = create_user(username, password, permissions) + return { + "id": user["id"], + "username": user["username"], + "permissions": user["permissions"], + } + except ValueError as e: + raise ConflictError(str(e)) from e + + +@registry.register(POST_AUTH_USER_UPDATE) +def auth_update_user(_request: Any, body: Any) -> dict[str, Any]: + """Update a user's permissions. + + Args: + _request: Unused. + body: Dict with optional ``permissions``, ``password`` keys. + Path param ``username`` is merged into body by the daemon. + + Returns: + Updated user dict. + + Raises: + NotFoundError: If user not found. + """ + if not body or not isinstance(body, dict): + raise ValueError("Request body is required") + + username = body.get("username") + if not username: + raise ValueError("username is required") + + existing = get_user(username) + if existing is None: + raise NotFoundError(f"User {username!r} not found") + + if "permissions" in body: + update_permissions(username, body["permissions"]) + + user = get_user(username) + if user is None: + raise NotFoundError(f"User {username!r} not found") + + return { + "id": user["id"], + "username": user["username"], + "permissions": user["permissions"], + } + + +@registry.register(DELETE_AUTH_USER) +def auth_delete_user(_request: Any, body: Any) -> dict[str, Any]: + """Delete a user. + + Self-deletion is blocked by Flask middleware (auth blueprint). + + Args: + _request: Unused. + body: Path param ``username`` merged by the daemon. + + Returns: + Success response. + + Raises: + NotFoundError: If user not found. + """ + if not body or not isinstance(body, dict): + raise ValueError("Request body is required") + + username = body.get("username") + if not username: + raise ValueError("username is required") + + try: + delete_user(username) + logger.info("User %r deleted", username) + return {"ok": True} + except ValueError as e: + if "not found" in str(e): + raise NotFoundError(str(e)) from e + raise + + +# --------------------------------------------------------------------------- +# WebAuthn handlers +# --------------------------------------------------------------------------- + + +@registry.register(POST_AUTH_WEBAUTHN_REGISTER_BEGIN) +def webauthn_register_begin(_request: Any, body: Any) -> dict[str, Any]: + """Begin WebAuthn registration — return options for ``credentials.create()``.""" + if not body or not isinstance(body, dict): + raise ValueError("Request body is required") + + username = body.get("username") + if not username: + raise ValueError("username is required") + + options = create_registration_options(username) + return options + + +@registry.register(POST_AUTH_WEBAUTHN_REGISTER_FINISH) +def webauthn_register_finish(_request: Any, body: Any) -> dict[str, Any]: + """Finish WebAuthn registration — verify credential and persist.""" + if not body or not isinstance(body, dict): + raise ValueError("Request body is required") + + username = body.get("username") + credential_response = body.get("credential_response") + registration_options = body.get("registration_options") + credential_name = body.get("name", "") + + if not username or not credential_response or not registration_options: + raise ValueError( + "username, credential_response, and registration_options are required" + ) + + try: + cred = verify_registration( + username, credential_response, registration_options, credential_name + ) + return {"ok": True, "credential": cred} + except ValueError: + raise + + +@registry.register(POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN) +def webauthn_authenticate_begin(_request: Any, body: Any) -> dict[str, Any]: + """Begin WebAuthn authentication — return options for ``credentials.get()``. + + Public endpoint — no JWT required. Returns ``{"noWebAuthn": true}`` if the + user has no registered credentials (so the frontend can fall back to password). + """ + if not body or not isinstance(body, dict): + raise ValueError("Request body is required") + + username = body.get("username") + if not username: + raise ValueError("username is required") + + options = create_authentication_options(username) + if options is None: + return {"no_webauthn": True} + return options + + +@registry.register(POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH) +def webauthn_authenticate_finish(_request: Any, body: Any) -> dict[str, Any]: + """Finish WebAuthn authentication — verify assertion, issue tokens. + + Public endpoint — no JWT required. + """ + if not body or not isinstance(body, dict): + raise ValueError("Request body is required") + + username = body.get("username") + assertion_response = body.get("assertion_response") + auth_options = body.get("auth_options") + + if not username or not assertion_response or not auth_options: + raise ValueError("username, assertion_response, and auth_options are required") + + if not check_webauthn_rate(username): + raise ValueError("Too many WebAuthn attempts. Please try again later.") + + try: + verify_authentication(username, assertion_response, auth_options) + except ValueError: + raise + + user = get_user(username) + if user is None: + raise ValueError("User not found") + + permissions = user["permissions"] + tokens = generate_tokens(username, permissions) + + return { + "tokens": tokens, + "access_ttl": get_access_ttl(), + "user": { + "id": user["id"], + "username": user["username"], + }, + "permissions": permissions, + } + + +@registry.register(GET_AUTH_WEBAUTHN_CREDENTIALS) +def webauthn_credentials(request: Any, body: Any) -> list[dict[str, Any]]: + """List WebAuthn credentials for the authenticated user.""" + username = body.get("username") if body else None + if not username: + raise ValueError("No active session") + + return list_credentials(username) + + +@registry.register(DELETE_AUTH_WEBAUTHN_CREDENTIAL) +def webauthn_remove_credential(request: Any, body: Any) -> dict[str, Any]: + """Remove a WebAuthn credential.""" + if not body or not isinstance(body, dict): + raise ValueError("Request body is required") + + username = body.get("username") + if not username: + raise ValueError("No active session") + + credential_id = body.get("credential_id") + if not credential_id: + raise ValueError("credential_id is required") + + try: + remove_credential(username, credential_id) + logger.info("WebAuthn credential removed for %s", username) + return {"ok": True} + except ValueError as e: + if "not found" in str(e): + raise NotFoundError(str(e)) from e + raise + + +@registry.register(GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS) +def webauthn_credential_counts(_request: Any, body: Any) -> dict[str, int]: + """Return credential counts for all users (admin endpoint).""" + return get_all_credential_counts() diff --git a/daemon/handlers/wireguard.py b/daemon/handlers/wireguard.py index 0969636..2654433 100644 --- a/daemon/handlers/wireguard.py +++ b/daemon/handlers/wireguard.py @@ -302,7 +302,9 @@ def class_up(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False) logger.info("WireGuard class '%s' tunnel '%s' brought up", class_key, ifname) sync_result = bus.emit( - SyncEvent("wireguard", "config_saved", {"action": "class_up", "class_key": class_key}) + SyncEvent( + "wireguard", "config_saved", {"action": "class_up", "class_key": class_key} + ) ) refresh_state(["wireguard", *sync_result.affected_subsystems]) return {"up": True, "interface": ifname} @@ -332,7 +334,11 @@ def class_down(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: except Exception: pass sync_result = bus.emit( - SyncEvent("wireguard", "config_saved", {"action": "class_down", "class_key": class_key}) + SyncEvent( + "wireguard", + "config_saved", + {"action": "class_down", "class_key": class_key}, + ) ) refresh_state(["wireguard", *sync_result.affected_subsystems]) return {"down": True, "interface": ifname} diff --git a/daemon/iface.py b/daemon/iface.py index 0a52292..c614c8d 100644 --- a/daemon/iface.py +++ b/daemon/iface.py @@ -167,6 +167,39 @@ GET_LOGS_NGINX_ERROR: Endpoint = _ep("GET", "/logs/nginx/error") GET_LOGS_DNSMASQ: Endpoint = _ep("GET", "/logs/dnsmasq") GET_LOGS_APP: Endpoint = _ep("GET", "/logs/app") +# ---- Authentication ---- +POST_AUTH_LOGIN: Endpoint = _ep("POST", "/auth/login") +POST_AUTH_LOGOUT: Endpoint = _ep("POST", "/auth/logout") +POST_AUTH_REFRESH: Endpoint = _ep("POST", "/auth/refresh") +GET_AUTH_SESSION: Endpoint = _ep("GET", "/auth/session") +POST_AUTH_PASSWORD: Endpoint = _ep("POST", "/auth/password") +# User admin +GET_AUTH_USERS: Endpoint = _ep("GET", "/auth/users") +POST_AUTH_USER_CREATE: Endpoint = _ep("POST", "/auth/users") +POST_AUTH_USER_UPDATE: Endpoint = _ep("POST", "/auth/users/") +DELETE_AUTH_USER: Endpoint = _ep("DELETE", "/auth/users/") + +# WebAuthn +POST_AUTH_WEBAUTHN_REGISTER_BEGIN: Endpoint = _ep( + "POST", "/auth/webauthn/register-begin" +) +POST_AUTH_WEBAUTHN_REGISTER_FINISH: Endpoint = _ep( + "POST", "/auth/webauthn/register-finish" +) +POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN: Endpoint = _ep( + "POST", "/auth/webauthn/authenticate-begin" +) +POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH: Endpoint = _ep( + "POST", "/auth/webauthn/authenticate-finish" +) +GET_AUTH_WEBAUTHN_CREDENTIALS: Endpoint = _ep("GET", "/auth/webauthn/credentials") +GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS: Endpoint = _ep( + "GET", "/auth/webauthn/credential-counts" +) +DELETE_AUTH_WEBAUTHN_CREDENTIAL: Endpoint = _ep( + "DELETE", "/auth/webauthn/creds/" +) + # ---- Server infra (not going through client) ---- GET_HEALTH: Endpoint = _ep("GET", "/health") POST_STATUS_REFRESH: Endpoint = _ep("POST", "/status/refresh") diff --git a/daemon/server.py b/daemon/server.py index 647043e..48344d1 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -368,8 +368,40 @@ async def _handle_ws(request: web.Request) -> web.Response: On connect: sends current versions. On state change: broadcasts updated subsystem versions. Clients disconnect to unsubscribe. + + Authentication: JWT access token passed via: + 1. WebSocket subprotocol header (Sec-WebSocket-Protocol: "Bearer ") + 2. X-Auth-Token header (nginx-injected) + 3. token query parameter (dev fallback) """ - ws = web.WebSocketResponse() + from lib.auth import validate_token + + token_param = None + + # Prefer subprotocol header (client JS sends "Bearer ") + subprotocols = request.get_subprotocols() + for proto in subprotocols or []: + if proto and proto.startswith("Bearer "): + token_param = proto[7:] + break + + if token_param is None: + token_param = request.headers.get("X-Auth-Token") + + if token_param is None: + token_param = request.query.get("token") + + if token_param is None: + return web.json_response( + {"ok": False, "error": "authentication required"}, status=401 + ) + + payload = validate_token(token_param, token_type="access") + if payload is None: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + + # Negotiate the subprotocol the client sent + ws = web.WebSocketResponse(protocols=request.get_subprotocols()) await ws.prepare(request) _ws_subscribers.add(ws) @@ -501,6 +533,7 @@ def _register_routes() -> None: """ from daemon.handlers import ( acme, # noqa: F401 + auth, # noqa: F401 dnsmasq, # noqa: F401 firewall, # noqa: F401 logs, # noqa: F401 diff --git a/lib/auth.py b/lib/auth.py new file mode 100644 index 0000000..ebc07d3 --- /dev/null +++ b/lib/auth.py @@ -0,0 +1,358 @@ +"""JWT authentication module for Vacuum Wall. + +Handles token creation, validation, refresh, and blacklisting. +Configuration comes from config/auth/config.json. +""" + +from __future__ import annotations + +import logging +import secrets +import time +import uuid +from pathlib import Path +from typing import Any + +import jwt + +from lib.common import load_json, save_json +from lib.db import ( + Q_DELETE_EXPIRED_BLACKLIST, + Q_DELETE_REFRESH_TOKEN, + Q_INSERT_BLACKLIST, + Q_SELECT_BLACKLIST, + Q_SELECT_REFRESH_TOKEN, + Q_UPSERT_REFRESH_TOKEN, + get_db, +) + +logger = logging.getLogger(__name__) + +AUTH_CONFIG_PATH = ( + Path(__file__).resolve().parent.parent / "config" / "auth" / "config.json" +) + +_DEFAULT_JWT_CONFIG = { + "access_token_ttl": 900, + "refresh_token_ttl": 604800, + "algorithm": "HS256", + "secret": "", +} + + +def _get_jwt_config() -> dict[str, Any]: + """Load JWT configuration from auth config.""" + raw = load_json(AUTH_CONFIG_PATH) + return raw.get("jwt", _DEFAULT_JWT_CONFIG) + + +def get_secret() -> str | None: + """Return the JWT signing secret, or ``None`` if not configured.""" + secret = _get_jwt_config().get("secret") + return secret if secret else None + + +def get_access_ttl() -> int: + """Return access token TTL in seconds.""" + return _get_jwt_config().get("access_token_ttl", 900) + + +def get_refresh_ttl() -> int: + """Return refresh token TTL in seconds.""" + return _get_jwt_config().get("refresh_token_ttl", 604800) + + +def get_algorithm() -> str: + """Return the JWT algorithm.""" + return _get_jwt_config().get("algorithm", "HS256") + + +def generate_access_token(username: str, permissions: dict[str, str]) -> str: + """Generate a new access token for *username*. + + Args: + username: The authenticated username. + permissions: Dict mapping subsystem names to permission levels. + + Returns: + JWT token string. + + Raises: + RuntimeError: If JWT secret is not configured. + """ + secret = get_secret() + if not secret: + raise RuntimeError("JWT secret is not configured") + algorithm = get_algorithm() + now = int(time.time()) + payload = { + "sub": username, + "exp": now + get_access_ttl(), + "iat": now, + "jti": str(uuid.uuid4()), + "type": "access", + "permissions": permissions, + } + return jwt.encode(payload, secret, algorithm=algorithm) + + +def generate_refresh_token(username: str) -> str: + """Generate a new refresh token for *username*. + + Args: + username: The authenticated username. + + Returns: + JWT refresh token string. + + Raises: + RuntimeError: If JWT secret is not configured. + """ + secret = get_secret() + if not secret: + raise RuntimeError("JWT secret is not configured") + algorithm = get_algorithm() + now = int(time.time()) + payload = { + "sub": username, + "exp": now + get_refresh_ttl(), + "iat": now, + "jti": str(uuid.uuid4()), + "type": "refresh", + } + return jwt.encode(payload, secret, algorithm=algorithm) + + +def generate_tokens(username: str, permissions: dict[str, str]) -> dict[str, str]: + """Generate both access and refresh tokens. + + Args: + username: The authenticated username. + permissions: Dict mapping subsystem names to permission levels. + + Returns: + Dict with ``access_token`` and ``refresh_token`` keys. + """ + access_token = generate_access_token(username, permissions) + refresh_token = generate_refresh_token(username) + _persist_refresh_token(username, refresh_token) + return { + "access_token": access_token, + "refresh_token": refresh_token, + } + + +def _persist_refresh_token(username: str, refresh_token: str) -> None: + """Persist the active refresh token JTI for *username* in the database. + + One row per user — replaces any existing entry on upsert. + + Args: + username: The username. + refresh_token: The JWT refresh token string. + """ + payload = decode_token(refresh_token) + if payload is None: + return + jti = payload.get("jti") + if not jti: + return + issued_at = payload.get("iat", int(time.time())) + db = get_db() + db.run(Q_UPSERT_REFRESH_TOKEN, (username, jti, issued_at)) + + +def blacklist_active_refresh_token(username: str) -> None: + """Blacklist the user's currently active refresh token from the database. + + Looks up the stored JTI for *username*, blacklists it, and removes the + database entry. Safe to call when no token is registered — the query + will simply return no rows. + + Args: + username: The username. + """ + db = get_db() + rows = db.query(Q_SELECT_REFRESH_TOKEN, (username,)) + if rows: + blacklist_token(rows[0]["jti"], token_type="refresh") + 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 decode_token(token_string: str) -> dict[str, Any] | None: + """Decode and validate a JWT token. + + Args: + token_string: The JWT token string (without Bearer prefix). + + Returns: + Payload dict if valid, None if invalid/expired or secret not configured. + """ + secret = get_secret() + if not secret: + return None + algorithm = get_algorithm() + try: + payload = jwt.decode(token_string, secret, algorithms=[algorithm]) + return payload + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return None + + +def validate_token( + token_string: str, token_type: str = "access" +) -> dict[str, Any] | None: + """Validate a JWT token and check it against the blacklist. + + Args: + token_string: The JWT token string. + token_type: Expected token type ("access" or "refresh"). + + Returns: + Payload dict including permissions, or None if invalid/blacklisted. + """ + payload = decode_token(token_string) + if payload is None: + return None + if payload.get("type") != token_type: + return None + + jti = payload.get("jti") + if jti and is_blacklisted(jti): + return None + + return payload + + +def blacklist_token(jti: str, token_type: str = "access") -> None: + """Add a JTI to the blacklist. + + Args: + jti: Token UUID to blacklist. + token_type: Token type ("access" or "refresh"). Defaults to "access". + The blacklist expiry is set to current time + the token type's TTL. + """ + db = get_db() + ttl = get_refresh_ttl() if token_type == "refresh" else get_access_ttl() + db.run(Q_INSERT_BLACKLIST, (jti, token_type, int(time.time()) + ttl)) + + +def is_blacklisted(jti: str) -> bool: + """Check if a JTI is blacklisted. + + Args: + jti: Token UUID to check. + + Returns: + True if the token has been blacklisted. + """ + db = get_db() + rows = db.query(Q_SELECT_BLACKLIST, (jti,)) + return len(rows) > 0 + + +def blacklist_expired() -> None: + """Remove expired entries from the blacklist.""" + db = get_db() + now = int(time.time()) + db.run(Q_DELETE_EXPIRED_BLACKLIST, (now,)) + + +def rotate_secret() -> None: + """Rotate the JWT secret, invalidating all existing tokens. + + Used when a user's password is changed to ensure all prior sessions + are immediately terminated regardless of token expiration. + """ + raw = load_json(AUTH_CONFIG_PATH) + jwt_config = raw.get("jwt", _DEFAULT_JWT_CONFIG) + jwt_config["secret"] = secrets.token_urlsafe(48) + raw["jwt"] = jwt_config + save_json(AUTH_CONFIG_PATH, raw) + logger.warning("JWT secret rotated — all existing tokens are now invalid") + + +class RateLimiter: + """Simple sliding-window rate limiter for login attempts. + + Maintains per-key attempt timestamps and rejects requests that exceed + the allowed count within the window. + """ + + def __init__(self, max_attempts: int = 5, window_seconds: int = 300) -> None: + self.max_attempts = max_attempts + self.window = window_seconds + self.attempts: dict[str, list[float]] = {} + + def is_allowed(self, key: str) -> bool: + """Check if a request from *key* is allowed. + + Args: + key: Identifier for the rate limit bucket (e.g., username or IP). + + Returns: + True if the request is allowed, False if rate limited. + """ + now = time.time() + cutoff = now - self.window + timestamps = self.attempts.get(key, []) + + # Clean old entries + self.attempts[key] = [t for t in timestamps if t > cutoff] + + if len(self.attempts[key]) >= self.max_attempts: + return False + + self.attempts[key].append(now) + return True + + def cleanup(self) -> None: + """Remove expired entries from all buckets.""" + now = time.time() + cutoff = now - self.window + for key in list(self.attempts): + self.attempts[key] = [t for t in self.attempts[key] if t > cutoff] + if not self.attempts[key]: + del self.attempts[key] + + +# Global rate limiters +_login_limiter = RateLimiter(max_attempts=10, window_seconds=300) +_webauthn_limiter = RateLimiter(max_attempts=5, window_seconds=600) + + +def check_login_rate(username: str) -> bool: + """Check if login is rate-limited for the given username. + + Args: + username: The login attempt username. + + Returns: + True if the attempt is allowed, False if rate limited. + """ + return _login_limiter.is_allowed(username) + + +def check_webauthn_rate(username: str) -> bool: + """Check if WebAuthn authentication is rate-limited for the given username. + + Args: + username: The WebAuthn attempt username. + + Returns: + True if the attempt is allowed, False if rate limited. + """ + return _webauthn_limiter.is_allowed(username) diff --git a/lib/auth_users.py b/lib/auth_users.py new file mode 100644 index 0000000..28ef25a --- /dev/null +++ b/lib/auth_users.py @@ -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() diff --git a/lib/common.py b/lib/common.py index 896dbff..d33cb5f 100644 --- a/lib/common.py +++ b/lib/common.py @@ -13,6 +13,8 @@ from copy import deepcopy from pathlib import Path from typing import Any +from passlib.hash import sha256_crypt + _APPLY_HASH_KEY = "_last_applied_hash" @@ -171,6 +173,22 @@ def ensure_dirs(*dirs: Path) -> None: d.mkdir(parents=True, exist_ok=True) +def _hash_password(password: str) -> str: + """Hash *password* using SHA-256 crypt (``$5$`` format) via passlib. + + Used for nginx htpasswd files. NOT used for auth user passwords — + those use Argon2id via ``lib.password``. + + Args: + password: Plain-text password to hash. + + Returns: + The hashed password string suitable for ``.htpasswd`` + (e.g. ``$5$rounds=…$…``). + """ + return sha256_crypt.hash(password) + + def get_interface_ip(iface: str) -> str | None: """Return the primary IPv4 address of *iface* (without CIDR), or ``None``. @@ -192,6 +210,7 @@ def get_interface_ip(iface: str) -> str | None: __all__ = [ "_APPLY_HASH_KEY", + "_hash_password", "config_hash", "deep_merge", "ensure_dirs", diff --git a/lib/db.py b/lib/db.py new file mode 100644 index 0000000..73a68bf --- /dev/null +++ b/lib/db.py @@ -0,0 +1,280 @@ +"""Abstract database layer for Vacuum Wall. + +Provides query ID constants and an abstract Database baseclass so subsystems +interact with the database through opaque query identifiers, never raw SQL. +Backend implementations (SQLite, PostgreSQL) provide the actual SQL. + +Usage: + from lib.db import Q_INSERT_USER, Database, get_db + + class SQLiteBackend(Database): + QUERY_MAP = { + Q_INSERT_USER: "INSERT INTO users (username, password_hash) VALUES (?, ?)", + ... + } +""" + +from __future__ import annotations + +import logging +import os +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, ClassVar + +logger = logging.getLogger(__name__) + +PROJECT_DIR = Path(__file__).resolve().parent.parent + +# --------------------------------------------------------------------------- +# Query ID constants — single source of truth for all database operations +# --------------------------------------------------------------------------- + +Q_INIT_TABLES = "init_tables" +Q_INSERT_USER = "insert_user" +Q_SELECT_USER_BY_NAME = "select_user_by_name" +Q_SELECT_USER_BY_ID = "select_user_by_id" +Q_UPDATE_PASSWORD = "update_password" +Q_DELETE_USER = "delete_user" +Q_UPSERT_PERMISSION = "upsert_permission" +Q_SELECT_PERMISSIONS = "select_permissions" +Q_DELETE_PERMISSIONS = "delete_permissions" +Q_INSERT_BLACKLIST = "insert_blacklist" +Q_SELECT_BLACKLIST = "select_blacklist_jti" +Q_DELETE_EXPIRED_BLACKLIST = "delete_expired_blacklist" +Q_UPSERT_REFRESH_TOKEN = "upsert_refresh_token" +Q_SELECT_REFRESH_TOKEN = "select_refresh_token" +Q_DELETE_REFRESH_TOKEN = "delete_refresh_token" +Q_INSERT_WEBAUTHN = "insert_webauthn" +Q_SELECT_WEBAUTHN_USER = "select_webauthn_user" +Q_SELECT_WEBAUTHN_ID = "select_webauthn_id" +Q_SELECT_WEBAUTHN_COUNTS = "select_webauthn_counts" +Q_DELETE_WEBAUTHN = "delete_webauthn" +Q_UPDATE_WEBAUTHN_SIGN_COUNT = "update_webauthn_sign_count" +Q_SELECT_ALL_USERS = "select_all_users" + +# --------------------------------------------------------------------------- +# Schema DDL +# --------------------------------------------------------------------------- + +INIT_SQL = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE TABLE IF NOT EXISTS permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE, + subsystem TEXT NOT NULL, + level TEXT NOT NULL CHECK (level IN ('read', 'rw')), + UNIQUE(username, subsystem) +); + +CREATE TABLE IF NOT EXISTS token_blacklist ( + jti TEXT PRIMARY KEY, + token_type TEXT NOT NULL, + expires INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + username TEXT UNIQUE NOT NULL, + jti TEXT NOT NULL, + issued_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS webauthn_creds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE, + credential_id TEXT NOT NULL, + public_key TEXT NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL DEFAULT '', + transports TEXT NOT NULL DEFAULT '[]', + UNIQUE(username, credential_id) +); + +CREATE TABLE IF NOT EXISTS init_sequence ( + seq INTEGER PRIMARY KEY +); +""" + + +class Transaction: + """Context manager for database transactions. + + Provides BEGIN/COMMIT/ROLLBACK semantics. Auto-commit is suppressed + inside the transaction block. + + Usage: + with db.in_transaction() as tx: + tx.run(Q_INSERT_USER, ("user1", "hash")) + tx.run(Q_UPSERT_PERMISSION, ("user1", "firewall", "rw")) + """ + + def __init__(self, parent: Database) -> None: + self._parent = parent + + def __enter__(self) -> Transaction: + self._parent._begin() + self._parent._suppress_auto_commit() + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool: + try: + if exc_type is None: + self._parent._commit() + else: + self._parent._rollback() + finally: + self._parent._restore_auto_commit() + return False + + def query(self, query_id: str, params: tuple = ()) -> list[dict]: + return self._parent.query(query_id, params) + + def run(self, query_id: str, params: tuple = ()) -> int: + return self._parent.run(query_id, params) + + def run_one(self, query_id: str, params: tuple = ()) -> int | dict: + return self._parent.run_one(query_id, params) + + +class Database(ABC): + """Abstract database interface. + + All subsystems interact with the database through this interface. + Queries are identified by string IDs (e.g. Q_INSERT_USER) — never + raw SQL strings. + + Connection is cached via the ``conn`` property. Prepared statements + are auto-cached on first use. + """ + + QUERY_MAP: ClassVar[dict[str, str]] = {} + + 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 + + @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 + + @abstractmethod + def _connect(self, cs: str) -> Any: ... + + @abstractmethod + def _prepare(self, sql: str) -> Any: ... + + @abstractmethod + def _execute(self, stmt: Any, params: tuple) -> tuple[list[dict], int]: + """Execute a prepared statement. Returns (rows, rowcount).""" + ... + + @abstractmethod + def _last_insert_id(self, stmt: Any) -> int | dict: ... + + @abstractmethod + def _execute_direct(self, sql: str) -> None: + """Execute raw SQL without prepared statements (for DDL, not implemented by base).""" + + def _begin(self) -> None: # noqa: B027 + """Begin a transaction (not implemented by base).""" + + def _commit(self) -> None: # noqa: B027 + """Commit a transaction (not implemented by base).""" + + def _rollback(self) -> None: # noqa: B027 + """Rollback a transaction (not implemented by base).""" + + def _suppress_auto_commit(self) -> None: # noqa: B027 + """Suppress auto-commit (not implemented by base).""" + + def _restore_auto_commit(self) -> None: # noqa: B027 + """Restore auto-commit (not implemented by base).""" + + def query(self, query_id: str, params: tuple = ()) -> list[dict]: + """Execute a SELECT query. Returns list of row dicts.""" + stmt = self._get_prepared(query_id) + rows, _ = self._execute(stmt, params) + return rows + + def run(self, query_id: str, params: tuple = ()) -> int: + """Execute an INSERT/UPDATE/DELETE. Returns affected row count.""" + stmt = self._get_prepared(query_id) + _, count = self._execute(stmt, params) + return count + + def run_one(self, query_id: str, params: tuple = ()) -> int | dict: + """Execute and return the last insert ID or row dict.""" + stmt = self._get_prepared(query_id) + _, _ = self._execute(stmt, params) + return self._last_insert_id(stmt) + + def in_transaction(self) -> Transaction: + """Return a transaction context manager.""" + return Transaction(self) + + def init_tables(self) -> None: + """Create schema tables if they don't exist.""" + self._execute_direct(INIT_SQL) + + def _get_prepared(self, query_id: str) -> Any: + if query_id not in self._prepared: + if query_id not in self.QUERY_MAP: + raise KeyError(f"Unknown query ID: {query_id!r}") + self._prepared[query_id] = self._prepare(self.QUERY_MAP[query_id]) + return self._prepared[query_id] + + +# --------------------------------------------------------------------------- +# Singleton accessor +# --------------------------------------------------------------------------- + +_db_instance: Database | None = None + + +def _get_backend_name() -> str: + return os.environ.get("VACUUM_WALL_DB_BACKEND", "sqlite") + + +def _get_db_path() -> str: + return os.environ.get("VACUUM_WALL_DB_PATH", str(PROJECT_DIR / "data" / "auth.db")) + + +def get_db() -> Database: + """Return the singleton Database instance. + + Creates the instance on first call using the backend specified by + ``VACUUM_WALL_DB_BACKEND`` env var (default: sqlite). + + Call this at application startup to ensure the DB is initialized. + """ + global _db_instance + if _db_instance is None: + backend = _get_backend_name() + if backend == "sqlite": + from lib.db_sqlite import SQLiteBackend + + path = _get_db_path() + _db_instance = SQLiteBackend(path) + Path(path).parent.mkdir(parents=True, exist_ok=True) + _db_instance.init_tables() + else: + raise ValueError(f"Unknown database backend: {backend!r}") + return _db_instance + + +def reset_db_for_test() -> None: + """Reset the singleton — only for tests.""" + global _db_instance + if _db_instance is not None: + _db_instance = None diff --git a/lib/db_sqlite.py b/lib/db_sqlite.py new file mode 100644 index 0000000..33b7669 --- /dev/null +++ b/lib/db_sqlite.py @@ -0,0 +1,168 @@ +"""SQLite backend for Vacuum Wall database. + +Concrete implementation of the Database abstract class using SQLite3. +Uses Python 3.13+ sqlite3.Statement for prepared statements. +""" + +from __future__ import annotations + +import contextlib +import logging +import sqlite3 +from typing import Any, ClassVar + +from lib.db import ( + Q_DELETE_EXPIRED_BLACKLIST, + Q_DELETE_PERMISSIONS, + Q_DELETE_REFRESH_TOKEN, + Q_DELETE_USER, + Q_DELETE_WEBAUTHN, + Q_INSERT_BLACKLIST, + Q_INSERT_USER, + Q_INSERT_WEBAUTHN, + Q_SELECT_ALL_USERS, + Q_SELECT_BLACKLIST, + Q_SELECT_PERMISSIONS, + Q_SELECT_REFRESH_TOKEN, + Q_SELECT_USER_BY_ID, + Q_SELECT_USER_BY_NAME, + Q_SELECT_WEBAUTHN_COUNTS, + Q_SELECT_WEBAUTHN_ID, + Q_SELECT_WEBAUTHN_USER, + Q_UPDATE_PASSWORD, + Q_UPDATE_WEBAUTHN_SIGN_COUNT, + Q_UPSERT_PERMISSION, + Q_UPSERT_REFRESH_TOKEN, + Database, +) + +logger = logging.getLogger(__name__) + + +class SQLiteBackend(Database): + """SQLite implementation of the Database interface. + + Uses ``sqlite3.Connection.execute()`` for statement execution and + ``sqlite3.Row`` for row-factory dict access. + """ + + def __init__(self, connection_string: str) -> None: + super().__init__(connection_string) + self._last_rowid: int = 0 + + QUERY_MAP: ClassVar[dict[str, str]] = { + # Schema init is handled by direct execution, not prepared statements + # init_tables is called as _execute_direct(INIT_SQL) + # Users + Q_INSERT_USER: ("INSERT INTO users (username, password_hash) VALUES (?, ?)"), + Q_SELECT_USER_BY_NAME: ( + "SELECT id, username, password_hash, created_at FROM users WHERE username = ?" + ), + Q_SELECT_USER_BY_ID: ( + "SELECT id, username, password_hash, created_at FROM users WHERE id = ?" + ), + Q_UPDATE_PASSWORD: "UPDATE users SET password_hash = ? WHERE username = ?", + Q_DELETE_USER: "DELETE FROM users WHERE username = ?", + Q_SELECT_ALL_USERS: ( + "SELECT id, username, created_at FROM users ORDER BY username" + ), + # Permissions + Q_UPSERT_PERMISSION: ( + "INSERT INTO permissions (username, subsystem, level) " + "VALUES (?, ?, ?) " + "ON CONFLICT(username, subsystem) DO UPDATE SET level = excluded.level" + ), + Q_SELECT_PERMISSIONS: ( + "SELECT subsystem, level FROM permissions WHERE username = ?" + ), + Q_DELETE_PERMISSIONS: ("DELETE FROM permissions WHERE username = ?"), + # Token blacklist + Q_INSERT_BLACKLIST: ( + "INSERT OR IGNORE INTO token_blacklist (jti, token_type, expires) VALUES (?, ?, ?)" + ), + Q_SELECT_BLACKLIST: "SELECT jti FROM token_blacklist WHERE jti = ?", + Q_DELETE_EXPIRED_BLACKLIST: "DELETE FROM token_blacklist WHERE expires < ?", + # Refresh tokens + Q_UPSERT_REFRESH_TOKEN: ( + "INSERT INTO refresh_tokens (username, jti, issued_at) " + "VALUES (?, ?, ?) " + "ON CONFLICT(username) DO UPDATE SET jti = excluded.jti, issued_at = excluded.issued_at" + ), + Q_SELECT_REFRESH_TOKEN: "SELECT username, jti, issued_at FROM refresh_tokens WHERE username = ?", + Q_DELETE_REFRESH_TOKEN: "DELETE FROM refresh_tokens WHERE username = ?", + # WebAuthn + Q_INSERT_WEBAUTHN: ( + "INSERT INTO webauthn_creds " + "(username, credential_id, public_key, sign_count, name, transports) " + "VALUES (?, ?, ?, ?, ?, ?)" + ), + Q_SELECT_WEBAUTHN_USER: ( + "SELECT id, credential_id, public_key, sign_count, name, transports " + "FROM webauthn_creds WHERE username = ?" + ), + Q_SELECT_WEBAUTHN_ID: ( + "SELECT id, username, credential_id, public_key, sign_count, name, transports " + "FROM webauthn_creds WHERE credential_id = ?" + ), + Q_SELECT_WEBAUTHN_COUNTS: ( + "SELECT username, COUNT(*) as cred_count FROM webauthn_creds " + "GROUP BY username" + ), + Q_UPDATE_WEBAUTHN_SIGN_COUNT: ( + "UPDATE webauthn_creds SET sign_count = ? WHERE credential_id = ?" + ), + Q_DELETE_WEBAUTHN: "DELETE FROM webauthn_creds WHERE credential_id = ?", + } + + def _connect(self, cs: str) -> Any: + """Create a SQLite connection with WAL mode and row factory.""" + conn = sqlite3.connect(cs, isolation_level=None) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.row_factory = sqlite3.Row + return conn + + def _prepare(self, sql: str) -> Any: + """Store the SQL string for later execution. + + SQLite in-memory DB doesn't support the Python 3.13 conn.prepare() + API, so we store the raw SQL and execute via conn.execute(). + """ + return sql + + def _execute(self, stmt: Any, params: tuple) -> tuple[list[dict], int]: + """Execute a prepared statement, returning (rows, rowcount).""" + cursor = self.conn.execute(stmt, params) + self._last_rowid = cursor.lastrowid + result = cursor.fetchall() + rows: list[dict] = [] + for row in result: + rows.append(dict(row)) + return rows, cursor.rowcount + + def _last_insert_id(self, stmt: Any) -> int | dict: + """Return the last insert row ID from the most recent execute.""" + return self._last_rowid + + def _begin(self) -> None: + self.conn.execute("BEGIN") + + def _commit(self) -> None: + self.conn.execute("COMMIT") + + def _rollback(self) -> None: + with contextlib.suppress(sqlite3.Error): + self.conn.execute("ROLLBACK") + + def _suppress_auto_commit(self) -> None: + self._in_transaction = True + + def _restore_auto_commit(self) -> None: + self._in_transaction = False + + def _execute_direct(self, sql: str) -> None: + """Execute raw SQL without prepared statements (for DDL).""" + for line in sql.split(";"): + line = line.strip() + if line: + self.conn.execute(line) diff --git a/lib/nginx.py b/lib/nginx.py index 9e05037..e93b1f0 100644 --- a/lib/nginx.py +++ b/lib/nginx.py @@ -14,7 +14,7 @@ from typing import Any from jinja2 import Environment, FileSystemLoader from lib.acme import find_cert_dir -from lib.common import ensure_dirs, load_json, save_json +from lib.common import _hash_password, ensure_dirs, load_json, save_json logger = logging.getLogger(__name__) @@ -50,10 +50,6 @@ DEFAULT_SSL: dict[str, Any] = { WEBUI_BACKEND: dict[str, Any] = { "label": "Vacuum Wall WebUI", "builtin": True, - "auth": { - "user": "admin", - "htpasswd": str(HTPASSWD_FILE), - }, "paths": { "/": { "backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"}, @@ -120,13 +116,6 @@ def _ensure_webui_backend(raw: dict[str, Any]) -> None: return backends["webui"] = deepcopy(WEBUI_BACKEND) backends["webui"]["_migrated"] = True - # Harvest auth from legacy path-level auth if present - for dom in raw.get("domains", {}).values(): - paths = dom.get("paths", {}) - root = paths.get("/", {}) - if root.get("auth"): - backends["webui"]["auth"] = root["auth"] - break def _migrate_mgmt_domains(raw: dict[str, Any]) -> None: @@ -577,20 +566,6 @@ def write_htpasswd(user: str, password: str) -> None: os.replace(tmp, HTPASSWD_FILE) -def _hash_password(password: str) -> str: - """Hash *password* using SHA-256 crypt (``$5$`` format) via passlib. - - Args: - password: Plain-text password to hash. - - Returns: - The hashed password string suitable for ``.htpasswd`` (e.g. ``$5$rounds=…$…``). - """ - from passlib.hash import sha256_crypt - - return sha256_crypt.hash(password) - - __all__ = [ "WEBUI_BACKEND", "_ensure_webui_backend", diff --git a/lib/password.py b/lib/password.py new file mode 100644 index 0000000..5112f26 --- /dev/null +++ b/lib/password.py @@ -0,0 +1,83 @@ +"""Password hashing with Argon2id. + +Handles user password storage for the auth DB. Uses argon2-cffi (C +implementation of the Argon2id memory-hard KDF). Random 16-byte salt is +generated per hash by the library. + +Argon2id is used for auth user passwords ONLY. Nginx basic-auth htpasswd +files continue to use sha256_crypt (passlib) — that's a separate concern +with different constraints (htpasswd format is standardized). +""" + +from __future__ import annotations + +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerifyMismatchError +from argon2.low_level import Type + +# Argon2id: OWASP recommended parameters +# 64 MiB memory, 3 iterations, 4 parallel threads +_PH = PasswordHasher( + time_cost=3, + memory_cost=65536, # 64 MiB + parallelism=4, + hash_len=32, + salt_len=16, + type=Type.ID, +) + + +def hash_password(password: str) -> str: + """Generate an Argon2id hash of *password*. + + A random 16-byte salt is generated automatically by argon2. + The resulting hash string starts with ``$argon2id$`` and encodes + the algorithm version, parameters, salt, and hash output. + + Args: + password: Plain-text password string. + + Returns: + Full Argon2id hash string (e.g. ``$argon2id$v=19$m=65536,t=3,p=4$...``). + + Raises: + TypeError: If *password* is not a string or contains NUL bytes. + """ + return _PH.hash(password) + + +def verify_password(password: str, hash_string: str) -> bool: + """Verify *password* against an Argon2id *hash_string*. + + The hash string must have been produced by :func:`hash_password`. + + Args: + password: Plain-text password to verify. + hash_string: Argon2id hash string to compare against. + + Returns: + ``True`` if the password matches, ``False`` otherwise. + + Raises: + TypeError: If *hash_string* is not a valid Argon2id hash. + """ + try: + _PH.verify(hash_string, password) + return True + except (InvalidHashError, VerifyMismatchError, ValueError): + return False + + +def needs_rehash(hash_string: str) -> bool: + """Check if *hash_string* needs to be rehashed with updated parameters. + + Returns True if the hash was not produced with the current parameters + of the hasher, indicating it should be rehashed on next login. + + Args: + hash_string: Argon2id hash string to check. + + Returns: + ``True`` if the hash parameters should be upgraded. + """ + return _PH.check_needs_rehash(hash_string) diff --git a/lib/webauthn.py b/lib/webauthn.py new file mode 100644 index 0000000..1296b0e --- /dev/null +++ b/lib/webauthn.py @@ -0,0 +1,355 @@ +"""WebAuthn passkey support for Vacuum Wall. + +Uses the Duo Labs webauthn library (v3) to handle the FIDO2/WebAuthn ceremony: +registration, authentication, and credential management. + +Credential data is stored in the database webauthn_creds table. +Configuration comes from config/auth/config.json (webauthn section). +""" + +from __future__ import annotations + +import base64 +import json +import logging +from typing import Any + +from webauthn import ( + generate_authentication_options, + generate_registration_options, + options_to_json, + verify_authentication_response, + verify_registration_response, +) +from webauthn.helpers.cose import COSEAlgorithmIdentifier +from webauthn.helpers.structs import ( + AttestationConveyancePreference, + AuthenticatorSelectionCriteria, + PublicKeyCredentialDescriptor, + ResidentKeyRequirement, + UserVerificationRequirement, +) + +from lib.auth import AUTH_CONFIG_PATH +from lib.common import load_json +from lib.db import ( + Q_DELETE_WEBAUTHN, + Q_INSERT_WEBAUTHN, + Q_SELECT_WEBAUTHN_COUNTS, + Q_SELECT_WEBAUTHN_ID, + Q_SELECT_WEBAUTHN_USER, + Q_UPDATE_WEBAUTHN_SIGN_COUNT, + get_db, +) + +logger = logging.getLogger(__name__) + +# Crypto algorithms we support +_SUPPORTED_ALGS = [ + COSEAlgorithmIdentifier.ECDSA_SHA_256, + COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, + COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_384, + COSEAlgorithmIdentifier.ECDSA_SHA_512, + COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_512, + COSEAlgorithmIdentifier.EDDSA, +] + + +# b64url helpers +def b64u_encode(b: bytes) -> str: + return base64.urlsafe_b64encode(b).decode("ascii").rstrip("=") + + +def b64u_decode(s: str) -> bytes: + padding = 4 - len(s) % 4 + if padding != 4: + s += "=" * padding + return base64.urlsafe_b64decode(s) + + +def _get_webauthn_config() -> dict[str, Any]: + """Load WebAuthn configuration from auth config.""" + raw = load_json(AUTH_CONFIG_PATH) + return raw.get("webauthn", {}) + + +def get_rp_id() -> str: + """Return the Relying Party ID from config.""" + return _get_webauthn_config().get("rp_id", "localhost") + + +def get_rp_name() -> str: + """Return the Relying Party name from config.""" + return _get_webauthn_config().get("rp_name", "Vacuum Wall") + + +def get_origin() -> str: + """Return the WebAuthn origin from config.""" + return _get_webauthn_config().get("origin", "http://localhost") + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + + +def create_registration_options(username: str) -> dict[str, Any]: + """Create WebAuthn registration options for a new credential. + + Returns a dict serializable to JSON, matching the format expected by + ``navigator.credentials.create()``. + """ + # Load existing credential IDs to exclude + db = get_db() + rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,)) + exclude_credentials = [ + PublicKeyCredentialDescriptor(id=b64u_decode(row["credential_id"])) + for row in rows + ] + + # Pad username to >= 8 bytes (required for user_id) + user_id = username.encode("utf-8") + if len(user_id) < 8: + user_id = user_id + b"\x00" * (8 - len(user_id)) + + options = generate_registration_options( + rp_id=get_rp_id(), + rp_name=get_rp_name(), + user_name=username, + user_display_name=username, + user_id=user_id, + attestation=AttestationConveyancePreference.NONE, + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.PREFERRED, + user_verification=UserVerificationRequirement.PREFERRED, + ), + supported_pub_key_algs=_SUPPORTED_ALGS, + exclude_credentials=exclude_credentials, + ) + + # Serialize using the library's built-in function + return json.loads(options_to_json(options)) + + +def verify_registration( + username: str, + credential_response: dict[str, Any], + registration_options: dict[str, Any], + credential_name: str = "", +) -> dict[str, Any]: + """Verify a registration response and persist the credential. + + Args: + username: The user registering the credential. + credential_response: Browser response from ``credentials.create()``. + registration_options: The options dict from ``create_registration_options``. + credential_name: Optional human-readable label. + + Returns: + Dict with ``id``, ``name``, ``transports``, ``sign_count``. + """ + expected_origin = get_origin() + expected_rp_id = get_rp_id() + challenge = b64u_decode(registration_options["challenge"]) + + # The library accepts the credential response as a JSON-serializable dict + # We pass it directly — the library handles the parsing + col = verify_registration_response( + credential=credential_response, + expected_challenge=challenge, + expected_origin=expected_origin, + expected_rp_id=expected_rp_id, + require_user_verification=False, + ) + + new_cred = col.credential + new_credential_id = b64u_encode(new_cred.id) + new_public_key = b64u_encode(new_cred.public_key) + new_sign_count = new_cred.sign_count + + transports = [] + if hasattr(new_cred.response, "transports") and new_cred.response.transports: + transports = [str(t) for t in new_cred.response.transports] + if not transports: + transports_raw = credential_response.get("response", {}).get("transports", []) + transports = [ + t + for t in transports_raw + if t + in ( + "internal", + "hybrid", + "nfc", + "ble", + "usb", + "smart-card", + ) + ] or ["internal"] + + db = get_db() + db.run( + Q_INSERT_WEBAUTHN, + ( + username, + new_credential_id, + new_public_key, + new_sign_count, + credential_name, + json.dumps(transports), + ), + ) + + logger.info( + "WebAuthn credential registered for %s: %s", + username, + credential_name or new_credential_id[:16], + ) + + return { + "id": new_credential_id, + "name": credential_name, + "transports": transports, + "sign_count": new_sign_count, + } + + +# --------------------------------------------------------------------------- +# Authentication +# --------------------------------------------------------------------------- + + +def create_authentication_options(username: str) -> dict[str, Any] | None: + """Create authentication options for a user. + + Returns a dict serializable to JSON (for ``navigator.credentials.get()``), + or ``None`` if the user has no registered credentials. + """ + db = get_db() + rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,)) + if not rows: + return None + + allow_credentials = [ + PublicKeyCredentialDescriptor(id=b64u_decode(row["credential_id"])) + for row in rows + ] + + options = generate_authentication_options( + rp_id=get_rp_id(), + allow_credentials=allow_credentials, + user_verification=UserVerificationRequirement.PREFERRED, + ) + + return json.loads(options_to_json(options)) + + +def verify_authentication( + username: str, + assertion_response: dict[str, Any], + auth_options: dict[str, Any], +) -> bool: + """Verify an authentication assertion. + + Args: + username: The user authenticating. + assertion_response: Browser response from ``credentials.get()``. + auth_options: The options dict from ``create_authentication_options``. + + Returns: + True on successful verification. + + Raises: + ValueError: On verification failure. + """ + expected_origin = get_origin() + expected_rp_id = get_rp_id() + challenge = b64u_decode(auth_options["challenge"]) + cred_id_str = assertion_response["id"] + + # Load credential from DB + db = get_db() + cred_rows = db.query(Q_SELECT_WEBAUTHN_ID, (cred_id_str,)) + if not cred_rows: + raise ValueError("Credential not found") + + cred_row = cred_rows[0] + public_key = b64u_decode(cred_row["public_key"]) + old_sign_count = cred_row["sign_count"] + + col = verify_authentication_response( + credential=assertion_response, + expected_challenge=challenge, + expected_origin=expected_origin, + expected_rp_id=expected_rp_id, + credential_public_key=public_key, + credential_current_sign_count=old_sign_count, + require_user_verification=False, + ) + + new_sign_count = col.credential_sign_count + if new_sign_count > old_sign_count: + db.run( + Q_UPDATE_WEBAUTHN_SIGN_COUNT, + (new_sign_count, cred_id_str), + ) + + logger.info("WebAuthn assertion verified for %s", username) + return True + + +# --------------------------------------------------------------------------- +# Credential management +# --------------------------------------------------------------------------- + + +def list_credentials(username: str) -> list[dict[str, Any]]: + """List all registered credentials for a user.""" + db = get_db() + rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,)) + + result = [] + for row in rows: + transports_str = row.get("transports", "[]") + try: + transports = json.loads(transports_str) + except (json.JSONDecodeError, TypeError): + transports = [] + + result.append( + { + "id": row["credential_id"], + "name": row.get("name") or "", + "transports": transports, + "sign_count": row.get("sign_count", 0), + } + ) + return result + + +def remove_credential(username: str, credential_id: str) -> bool: + """Remove a credential. + + Raises: + ValueError: If credential not found or doesn't belong to user. + """ + db = get_db() + rows = db.query(Q_SELECT_WEBAUTHN_ID, (credential_id,)) + if not rows: + raise ValueError(f"Credential {credential_id!r} not found") + if rows[0]["username"] != username: + raise ValueError("Credential does not belong to this user") + + db.run(Q_DELETE_WEBAUTHN, (credential_id,)) + logger.info("WebAuthn credential removed: %s, %s", username, credential_id) + return True + + +def get_all_credential_counts() -> dict[str, int]: + """Return credential counts for all users. + + Returns: + Dict mapping usernames to credential counts. + """ + db = get_db() + rows = db.query(Q_SELECT_WEBAUTHN_COUNTS, ()) + return {row["username"]: row["cred_count"] for row in rows} diff --git a/pyproject.toml b/pyproject.toml index 70eb320..2afb5d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,9 @@ dependencies = [ "aiohttp>=3.9,<4.0", "passlib>=1.7.4,<2.0", "requests-unixsocket>=0.2,<1.0", + "PyJWT>=2.8,<3.0", + "argon2-cffi>=23.1.0,<25.0", + "webauthn>=3.0.0,<4.0", ] [project.scripts] diff --git a/scripts/bootstrap_auth.py b/scripts/bootstrap_auth.py new file mode 100644 index 0000000..13217b3 --- /dev/null +++ b/scripts/bootstrap_auth.py @@ -0,0 +1,78 @@ +"""Bootstrap auth: initialize DB and seed admin user at install time. + +Run once during installation. Writes config/auth/config.json with a +generated JWT secret and creates the admin user in SQLite. + +Usage: + python scripts/bootstrap_auth.py --project-dir /path/to/project \ + --username admin \ + --password secret \ + --domain wall.example.com +""" + +import argparse +import json +import os +import sys +from pathlib import Path + +# Ensure project lib is importable +PROJECT_DIR = Path(".") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Bootstrap Vacuum Wall auth") + parser.add_argument("--project-dir", required=True, help="Project root directory") + parser.add_argument("--username", required=True, help="Admin username") + parser.add_argument("--password", required=True, help="Admin password") + parser.add_argument("--domain", required=True, help="Management domain (rp_id)") + args = parser.parse_args() + + project_dir = Path(args.project_dir).resolve() + sys.path.insert(0, str(project_dir)) + + # Set DB path before importing lib modules + db_path = str(project_dir / "data" / "auth.db") + os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite" + os.environ["VACUUM_WALL_DB_PATH"] = db_path + + from lib.auth_users import ALL_SUBSYSTEMS, create_user + + # Generate JWT secret + secret = os.urandom(32).hex() + + # Write config + config_dir = project_dir / "config" / "auth" + config_dir.mkdir(parents=True, exist_ok=True) + config_path = config_dir / "config.json" + + config = { + "jwt": { + "access_token_ttl": 900, + "refresh_token_ttl": 604800, + "algorithm": "HS256", + "secret": secret, + }, + "webauthn": { + "rp_name": "Vacuum Wall", + "rp_id": args.domain, + "origin": f"https://{args.domain}", + }, + } + + with open(config_path, "w") as f: + json.dump(config, f, indent=2) + f.write("\n") + + print(f"Wrote auth config: {config_path}") + + # Initialize DB and create admin user + permissions = {sub: "rw" for sub in ALL_SUBSYSTEMS} + user = create_user(args.username, args.password, permissions) + + print(f"Created admin user: {user['username']} (id={user['id']})") + print(f"Permissions: {len(permissions)} subsystems, all 'rw'") + + +if __name__ == "__main__": + main() diff --git a/scripts/install.sh b/scripts/install.sh index b540d59..b26af8a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -41,8 +41,8 @@ while [[ $# -gt 0 ]]; do " --user, -u USER WebUI user (created if it does not exist, required for non-dev mode)" \ " --path, -p DIR Install directory (default: repo root)" \ " --dev Dev mode: auto-detect repo owner, skip safety warning" \ - " --mgmt-pass PASS WebUI basic auth password (required)" \ - " --mgmt-user USER WebUI basic auth username (default: admin)" \ + " --mgmt-pass PASS Initial admin password (required)" \ + " --mgmt-user USER Initial admin username (default: admin)" \ " --mgmt-domain DOMAIN Management domain (auto-detected)" \ " --wan-iface IFACE WAN interface name (auto-detected)" \ " --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \ @@ -69,9 +69,9 @@ done # --- Resolve config: CLI flag > env var > default --- REPO_DIR="$(cd "$(dirname "$0")/../" && pwd)" -# Required settings (no defaults — must be provided) -MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}" # Optional settings with defaults +# MGMT_PASS is strictly required — admin user is created at install time +MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}" MGMT_USER="${_cli_mgmt_user:-${MGMT_USER:-admin}}" # MGMT_DOMAIN — CLI > env > auto-detect from hostname @@ -104,19 +104,7 @@ LAN_IFACES="${_cli_lan_ifaces:-${LAN_IFACES:-}}" [[ -f /etc/debian_version ]] || warn "This script is designed for Debian/Ubuntu." # --- Validate required settings --- -missing=() -[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)") - -if (( ${#missing[@]} )); then - echo -e "${RED}[!!]${NC} Missing required settings:" - for v in "${missing[@]}"; do - case "$v" in - "MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';; - esac - done - printf '\nTo run: MGMT_PASS=pass ./scripts/install.sh\n' - exit 1 -fi +[[ -n "$MGMT_PASS" ]] || err "MGMT_PASS is required (set --mgmt-pass or MGMT_PASS env var)" ACME_HOME="$PROJECT_DIR/data/acme" # Dev mode: auto-detect repo owner as service user @@ -356,7 +344,20 @@ else echo "" echo " Setting up initial management configuration..." - # htpasswd is created by the daemon via /nginx/domains/add (writes to data/.htpasswd) + # Bootstrap auth: generate config + seed admin user + if [[ ! -f "${PROJECT_DIR}/config/auth/config.json" ]]; then + echo "" + echo " Bootstrapping auth (creating admin user: $MGMT_USER)..." + + "${PROJECT_DIR}/.venv/bin/python3" "${PROJECT_DIR}/scripts/bootstrap_auth.py" \ + --project-dir "$PROJECT_DIR" \ + --username "$MGMT_USER" \ + --password "$MGMT_PASS" \ + --domain "$DOMAIN" + + chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/config/auth" + chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/data/auth.db" 2>/dev/null || true + fi # Helper: POST JSON to daemon API over Unix socket _daemon_post() { @@ -379,18 +380,15 @@ else # 1A. Self-signed certificate for management domain _daemon_post "/acme/self-signed" "{\"domain\":\"$DOMAIN\"}" "Self-signed certificate" - # 1C. Management proxy domain — try idempotent update first, fall back to add + # 1C. Management proxy domain (no auth — JWT auth is handled by Flask) local mgmt_json mgmt_json="$(jq -n \ --arg domain "$DOMAIN" \ - --arg user "$MGMT_USER" \ - --arg pass "$MGMT_PASS" \ '{ domain: $domain, backend: "webui", cert: "selfsigned", - force_ssl: true, - auth: {user: $user, pass: $pass} + force_ssl: true }')" _daemon_post "/nginx/domains/update" "$mgmt_json" "Management domain configured" || \ _daemon_post "/nginx/domains/add" "$mgmt_json" "Management domain configured" @@ -430,7 +428,7 @@ echo -e " ${GREEN}Vacuum Wall installed successfully!${NC}" echo "============================================" echo "" echo " Management UI: https://$DOMAIN" -echo " User: $MGMT_USER" +echo " Admin user: $MGMT_USER" echo " Daemon service: vacuum-walld.service" echo " WebUI service: vacuum-wall.service" echo " ACME renewal: vacuum-wall-acme.timer" diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..b5f43cc --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,964 @@ +"""Phase 1 auth tests: JWT lifecycle, DB layer, password hashing, login flow. + +All subprocess calls are mocked. Uses in-memory SQLite. +""" + +from __future__ import annotations + +import base64 +import os +from unittest.mock import MagicMock, patch + +import pytest + +import lib.db +import lib.db_sqlite +from lib.auth import ( + AUTH_CONFIG_PATH as _AUTH_CFG_PATH, +) +from lib.auth import ( + blacklist_expired, + blacklist_token, + decode_token, + generate_access_token, + generate_refresh_token, + generate_tokens, + is_blacklisted, + validate_token, +) +from lib.auth_users import ( + create_user, + delete_user, + get_user, + list_users, + update_password, + update_permissions, + verify_user_password, +) +from lib.db import ( + Q_INSERT_BLACKLIST, + Q_INSERT_USER, + Q_SELECT_ALL_USERS, + Q_SELECT_BLACKLIST, + Q_SELECT_PERMISSIONS, + Q_SELECT_USER_BY_NAME, + Q_UPSERT_PERMISSION, + get_db, + reset_db_for_test, +) +from lib.password import hash_password, needs_rehash, verify_password + + +@pytest.fixture(autouse=True) +def _db_reset(): + """Reset DB singleton and env vars before each test.""" + reset_db_for_test() + old_backend = os.environ.pop("VACUUM_WALL_DB_BACKEND", None) + old_path = os.environ.pop("VACUUM_WALL_DB_PATH", None) + + os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite" + os.environ["VACUUM_WALL_DB_PATH"] = ":memory:" + + yield + + reset_db_for_test() + if old_backend is not None: + os.environ["VACUUM_WALL_DB_BACKEND"] = old_backend + if old_path is not None: + os.environ["VACUUM_WALL_DB_PATH"] = old_path + + +@pytest.fixture +def db(): + """Initialize the DB and return it.""" + return get_db() + + +@pytest.fixture +def sample_secret(): + """Return the JWT secret from auth config.""" + from lib.common import load_json + + raw = load_json(_AUTH_CFG_PATH) + return raw.get("jwt", {}).get("secret", "") + + +# ---------- Password hashing tests ---------- + + +class TestPasswordHashing: + def test_hash_starts_with_argon2id(self): + h = hash_password("test1234") + assert h.startswith("$argon2id$v=19$") + + def test_hash_contains_parameters(self): + h = hash_password("test1234") + assert "m=65536" in h # 64 MiB + assert "t=3" in h + assert "p=4" in h + + def test_unique_salt(self): + h1 = hash_password("same_password") + h2 = hash_password("same_password") + assert h1 != h2 + + def test_verify_correct_password(self): + h = hash_password("my_secret") + assert verify_password("my_secret", h) is True + + def test_verify_wrong_password(self): + h = hash_password("my_secret") + assert verify_password("wrong_password", h) is False + + def test_needs_rehash_no_change(self): + h = hash_password("test") + assert needs_rehash(h) is False + + +# ---------- DB layer tests ---------- + + +class TestDBLayer: + def test_init_tables(self, db): + cur = db.conn.cursor() + tables = cur.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + table_names = {t["name"] for t in tables} + assert "users" in table_names + assert "permissions" in table_names + assert "token_blacklist" in table_names + assert "webauthn_creds" in table_names + + def test_connection_caching(self, db): + conn1 = db.conn + conn2 = db.conn + assert conn1 is conn2 + + def test_insert_user(self, db): + uid = db.run_one(Q_INSERT_USER, ("testuser", "$argon2id$hash")) + assert isinstance(uid, int) + assert uid > 0 + + def test_select_user_by_name(self, db): + db.run(Q_INSERT_USER, ("testuser", "$argon2id$hash")) + rows = db.query(Q_SELECT_USER_BY_NAME, ("testuser",)) + assert len(rows) == 1 + assert rows[0]["username"] == "testuser" + + def test_query_unknown_id_raises(self, db): + with pytest.raises(KeyError): + db.query("nonexistent_query_id") + + def test_transaction_commit(self, db): + with db.in_transaction() as tx: + tx.run(Q_INSERT_USER, ("txuser", "$argon2id$hash")) + tx.run(Q_UPSERT_PERMISSION, ("txuser", "firewall", "rw")) + rows = db.query(Q_SELECT_USER_BY_NAME, ("txuser",)) + assert len(rows) == 1 + perms = db.query(Q_SELECT_PERMISSIONS, ("txuser",)) + assert len(perms) == 1 + assert perms[0]["subsystem"] == "firewall" + + def test_transaction_rollback(self, db): + try: + with db.in_transaction() as tx: + tx.run(Q_INSERT_USER, ("rollback_user", "$argon2id$hash")) + raise ValueError("abort!") + except ValueError: + pass + rows = db.query(Q_SELECT_USER_BY_NAME, ("rollback_user",)) + assert len(rows) == 0 + + def test_prepared_statement_caching(self, db): + stmt1 = db._get_prepared(Q_SELECT_USER_BY_NAME) + stmt2 = db._get_prepared(Q_SELECT_USER_BY_NAME) + + assert stmt1 is stmt2 + + def test_upsert_permission(self, db): + db.run(Q_INSERT_USER, ("permuser", "$argon2id$hash")) + db.run(Q_UPSERT_PERMISSION, ("permuser", "firewall", "read")) + perms = db.query(Q_SELECT_PERMISSIONS, ("permuser",)) + assert perms[0]["level"] == "read" + + db.run(Q_UPSERT_PERMISSION, ("permuser", "firewall", "rw")) + perms = db.query(Q_SELECT_PERMISSIONS, ("permuser",)) + assert perms[0]["level"] == "rw" + + def test_all_users(self, db): + db.run(Q_INSERT_USER, ("aaa", "$argon2id$hash")) + db.run(Q_INSERT_USER, ("bbb", "$argon2id$hash")) + rows = db.query(Q_SELECT_ALL_USERS, ()) + assert len(rows) == 2 + assert rows[0]["username"] == "aaa" + + +# ---------- JWT tests ---------- + + +class TestJWT: + def test_generate_tokens(self, sample_secret): + tokens = generate_tokens("admin", {"firewall": "rw"}) + assert "access_token" in tokens + assert "refresh_token" in tokens + + def test_decode_token(self, sample_secret): + token = generate_access_token("admin", {"firewall": "rw"}) + payload = decode_token(token) + assert payload is not None + assert payload["sub"] == "admin" + assert payload["type"] == "access" + + def test_validate_access_token(self, sample_secret): + token = generate_access_token("admin", {"firewall": "rw"}) + payload = validate_token(token, "access") + assert payload is not None + assert payload["sub"] == "admin" + assert payload["permissions"]["firewall"] == "rw" + + def test_validate_wrong_type(self, sample_secret): + token = generate_refresh_token("admin") + payload = validate_token(token, "access") + assert payload is None + + def test_validate_invalid_token(self, sample_secret): + payload = validate_token("invalid.token.here", "access") + assert payload is None + + def test_blacklist_token(self, sample_secret, db): + token = generate_access_token("admin", {}) + payload = decode_token(token) + assert payload is not None + jti = payload["jti"] + + blacklist_token(jti) + assert is_blacklisted(jti) is True + + result = validate_token(token, "access") + assert result is None + + def test_blacklist_cleanup(self, db): + db.run(Q_INSERT_BLACKLIST, ("old-jti", "access", 1000)) + rows_before = db.query(Q_SELECT_BLACKLIST, ("old-jti",)) + assert len(rows_before) == 1 + + blacklist_expired() + + rows_after = db.query(Q_SELECT_BLACKLIST, ("old-jti",)) + assert len(rows_after) == 0 + + +# ---------- User management tests ---------- + + +class TestUserManagement: + def test_create_user(self, db): + user = create_user("testuser", "password123", {"firewall": "rw"}) + assert user["username"] == "testuser" + assert user["permissions"]["firewall"] == "rw" + assert "id" in user + + def test_create_user_invalid_username(self, db): + with pytest.raises(ValueError): + create_user("a", "password123") + + def test_create_user_duplicate(self, db): + create_user("dupuser", "password123") + with pytest.raises(ValueError, match="already exists"): + create_user("dupuser", "otherpassword") + + def test_verify_password(self, db): + create_user("pwuser", "correctpass") + assert verify_user_password("pwuser", "correctpass") is not None + assert verify_user_password("pwuser", "wrongpass") is None + assert verify_user_password("nonexistent", "anything") is None + + def test_get_user(self, db): + create_user("getuser", "password123", {"firewall": "rw"}) + user = get_user("getuser") + assert user is not None + assert "password_hash" not in user + assert user["permissions"]["firewall"] == "rw" + + def test_get_user_not_found(self, db): + assert get_user("nonexistent") is None + + def test_update_password(self, db): + create_user("upwuser", "oldpass") + update_password("upwuser", "oldpass", "newpass123") + assert verify_user_password("upwuser", "newpass123") is not None + assert verify_user_password("upwuser", "oldpass") is None + + def test_update_password_wrong_old(self, db): + create_user("upwfail", "realpass") + with pytest.raises(ValueError, match="incorrect"): + update_password("upwfail", "wrong_old", "newpass123") + + def test_update_permissions(self, db): + create_user("permuser", "password123", {"firewall": "rw"}) + update_permissions("permuser", {"firewall": "read", "network": "rw"}) + user = get_user("permuser") + assert user["permissions"]["firewall"] == "read" + assert user["permissions"]["network"] == "rw" + + def test_list_users(self, db): + create_user("listuser1", "pass1", {"firewall": "rw"}) + create_user("listuser2", "pass2", {"network": "rw"}) + users = list_users() + assert len(users) == 2 + usernames = {u["username"] for u in users} + assert "listuser1" in usernames + + def test_delete_user(self, db): + create_user("deluser", "password123") + assert get_user("deluser") is not None + delete_user("deluser") + assert get_user("deluser") is None + + +# ---------- Integration-style login flow ---------- + + +class TestLoginFlow: + def test_full_login_flow(self, db, sample_secret): + """Create user → verify password → generate tokens → validate tokens.""" + user = create_user("admin", "secretpass", {"firewall": "rw", "auth": "rw"}) + assert verify_user_password("admin", "secretpass") is not None + + tokens = generate_tokens("admin", user["permissions"]) + payload = validate_token(tokens["access_token"], "access") + assert payload is not None + assert payload["sub"] == "admin" + assert payload["permissions"]["firewall"] == "rw" + + def test_logout_flow(self, db, sample_secret): + """Generate token → blacklist JTI → verify token is rejected.""" + tokens = generate_tokens("testuser", {}) + payload = decode_token(tokens["access_token"]) + assert payload is not None + + blacklist_token(payload["jti"]) + + result = validate_token(tokens["access_token"], "access") + assert result is None + + def test_token_refresh_flow(self, sample_secret): + """Generate refresh → get access → blacklist old refresh → validate new.""" + refresh = generate_refresh_token("user1") + payload = validate_token(refresh, "refresh") + assert payload is not None + + blacklist_token(payload["jti"]) + result = validate_token(refresh, "refresh") + assert result is None + + new_refresh = generate_refresh_token("user1") + new_payload = validate_token(new_refresh, "refresh") + assert new_payload is not None + assert new_payload["jti"] != payload["jti"] + + +# ═══════════════════════════════════════════════════════════════════════════ +# WebAuthn tests (Phase 2) +# ═══════════════════════════════════════════════════════════════════════════ + +_FAKE_CRED_ID = base64.urlsafe_b64encode(b"fakecred012345").decode().rstrip("=") +_FAKE_CRED_ID_BYTES = b"fakecred012345" +_FAKE_PUBLIC_KEY = base64.urlsafe_b64encode(b"fakepubkey01234").decode().rstrip("=") +_FAKE_CHALLENGE = base64.urlsafe_b64encode(b"fakechallenge!!").decode().rstrip("=") + + +@patch.dict( + os.environ, + { + "VACUUM_WALL_DB_BACKEND": "sqlite", + "VACUUM_WALL_DB_PATH": ":memory:", + "PYTHONDONTWRITEBYTECODE": "1", + }, +) +class TestWebAuthnConfig: + """Test WebAuthn configuration helpers.""" + + def test_get_rp_defaults(self) -> None: + from lib.webauthn import get_origin, get_rp_id, get_rp_name + + assert get_rp_id() == "localhost" + assert get_rp_name() == "Vacuum Wall" + assert get_origin() == "http://localhost" + + +@patch.dict( + os.environ, + { + "VACUUM_WALL_DB_BACKEND": "sqlite", + "VACUUM_WALL_DB_PATH": ":memory:", + "PYTHONDONTWRITEBYTECODE": "1", + }, +) +class TestWebAuthnB64urlHelpers: + """Test base64url encoding/decoding used by WebAuthn.""" + + def test_b64url_roundtrip(self) -> None: + from lib.webauthn import b64u_decode, b64u_encode + + original = b"hello world 123 !@#" + encoded = b64u_encode(original) + decoded = b64u_decode(encoded) + assert decoded == original + + def test_b64url_binary(self) -> None: + from lib.webauthn import b64u_decode, b64u_encode + + original = bytes(range(256)) + encoded = b64u_encode(original) + decoded = b64u_decode(encoded) + assert decoded == original + + +@patch.dict( + os.environ, + { + "VACUUM_WALL_DB_BACKEND": "sqlite", + "VACUUM_WALL_DB_PATH": ":memory:", + "PYTHONDONTWRITEBYTECODE": "1", + }, +) +class TestWebAuthnRegistration: + """Test WebAuthn registration flow with mocked library.""" + + def setup_method(self) -> None: + lib.db.reset_db_for_test() + get_db() + + def test_create_registration_options_basic(self) -> None: + from lib.webauthn import create_registration_options + + options = create_registration_options("testuser") + + assert isinstance(options, dict) + assert "challenge" in options + assert "rp" in options + assert "user" in options + assert "pubKeyCredParams" in options + assert options["rp"]["id"] == "localhost" + assert options["user"]["name"] == "testuser" + assert len(options["pubKeyCredParams"]) >= 1 + + def test_verify_registration_stores_in_db(self) -> None: + from lib.webauthn import b64u_encode + + with patch("lib.webauthn.verify_registration_response") as mock_verify: + mock_credential = MagicMock() + mock_credential.id = _FAKE_CRED_ID_BYTES + mock_credential.public_key = b"fakepubkey01234" + mock_credential.sign_count = 0 + + col = MagicMock() + col.credential = mock_credential + mock_verify.return_value = col + + from lib.webauthn import verify_registration + + _insert_cred("testuser") + + result = verify_registration( + "testuser", + { + "id": _FAKE_CRED_ID, + "rawId": _FAKE_CRED_ID, + "type": "public-key", + "response": { + "clientDataJSON": b64u_encode(b"{}"), + "attestationObject": b64u_encode(b"dummy"), + "transports": [], + }, + }, + {"challenge": b64u_encode(b"testchallenge123")}, + "My Key", + ) + + assert "id" in result + assert result["name"] == "My Key" + assert isinstance(result["transports"], list) + assert isinstance(result["sign_count"], int) + + cursor = get_db().conn.execute( + "SELECT credential_id, name FROM webauthn_creds WHERE name = ?", + ("My Key",), + ) + assert len(cursor.fetchall()) == 1 + + def test_create_registration_excludes_existing(self) -> None: + _insert_cred("testuser", cred_id="existingcred") + + from lib.webauthn import create_registration_options + + options = create_registration_options("testuser") + + # Should contain the existing credential in exclude list + exclude_ids = [c["id"] for c in options.get("excludeCredentials", [])] + assert "existingcred" in exclude_ids + + +def _insert_cred( + username, + cred_id="Y3JlZDE", + public_key="cGsx", + sign_count=0, + name="", + transports='["internal"]', +): + """Convenience: insert a webauthn_creds row directly. + + Also creates the user in the users table if they don't exist (FK constraint). + """ + conn = get_db().conn + conn.execute( + "INSERT OR IGNORE INTO users (username, password_hash) VALUES (?, ?)", + (username, "testhash"), + ) + conn.execute( + "INSERT INTO webauthn_creds (username, credential_id, public_key, sign_count, name, transports) VALUES (?, ?, ?, ?, ?, ?)", + (username, cred_id, public_key, sign_count, name, transports), + ) + + +@patch.dict( + os.environ, + { + "VACUUM_WALL_DB_BACKEND": "sqlite", + "VACUUM_WALL_DB_PATH": ":memory:", + "PYTHONDONTWRITEBYTECODE": "1", + }, +) +class TestWebAuthnAuthentication: + """Test WebAuthn authentication flow with mocked library.""" + + def setup_method(self) -> None: + lib.db.reset_db_for_test() + get_db() + + def test_create_auth_options_no_credentials(self) -> None: + from lib.webauthn import create_authentication_options + + result = create_authentication_options("nonexistentuser") + assert result is None + + def test_create_auth_options_with_credentials(self) -> None: + _insert_cred("testuser") + + from lib.webauthn import create_authentication_options + + result = create_authentication_options("testuser") + + assert result is not None + assert len(result["allowCredentials"]) == 1 + + def test_verify_authentication_not_found(self) -> None: + from lib.webauthn import verify_authentication + + with pytest.raises(ValueError, match="Credential not found"): + verify_authentication( + "testuser", + { + "id": "nonexistent", + "response": { + "clientDataJSON": "", + "authenticatorData": "", + "signature": "", + }, + }, + {"challenge": _FAKE_CHALLENGE}, + ) + + def test_verify_authentication_success(self) -> None: + from lib.webauthn import b64u_encode + + _insert_cred("testuser", _FAKE_CRED_ID, _FAKE_PUBLIC_KEY, 0) + + with patch("lib.webauthn.verify_authentication_response") as mock_verify: + mock_col = MagicMock() + mock_col.credential_sign_count = 1 + mock_verify.return_value = mock_col + + from lib.webauthn import verify_authentication + + result = verify_authentication( + "testuser", + { + "id": _FAKE_CRED_ID, + "response": { + "clientDataJSON": b64u_encode(b"{}"), + "authenticatorData": b64u_encode(b"aa"), + "signature": b64u_encode(b"sig"), + }, + }, + {"challenge": _FAKE_CHALLENGE, "sign_count": 0}, + ) + + assert result is True + + +@patch.dict( + os.environ, + { + "VACUUM_WALL_DB_BACKEND": "sqlite", + "VACUUM_WALL_DB_PATH": ":memory:", + "PYTHONDONTWRITEBYTECODE": "1", + }, +) +class TestWebAuthnCredentials: + """Test credential listing and removal.""" + + def setup_method(self) -> None: + lib.db.reset_db_for_test() + get_db() + + def test_list_credentials_empty(self) -> None: + from lib.webauthn import list_credentials + + assert list_credentials("testuser") == [] + + def test_list_credentials_with_data(self) -> None: + _insert_cred("testuser", name="My Key", transports='["internal", "hybrid"]') + + from lib.webauthn import list_credentials + + result = list_credentials("testuser") + assert len(result) == 1 + assert result[0]["name"] == "My Key" + assert result[0]["sign_count"] == 0 + assert result[0]["transports"] == ["internal", "hybrid"] + + def test_remove_credential_success(self) -> None: + _insert_cred("testuser") + + from lib.webauthn import list_credentials, remove_credential + + result = remove_credential("testuser", "Y3JlZDE") + assert result is True + + assert list_credentials("testuser") == [] + + def test_remove_credential_not_found(self) -> None: + from lib.webauthn import remove_credential + + with pytest.raises(ValueError, match="not found"): + remove_credential("testuser", "nonexistent") + + def test_remove_credential_wrong_user(self) -> None: + _insert_cred("otheruser", cred_id=_FAKE_CRED_ID) + + from lib.webauthn import remove_credential + + with pytest.raises(ValueError, match="does not belong"): + remove_credential("testuser", _FAKE_CRED_ID) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Multi-user tests (Phase 3) +# ═══════════════════════════════════════════════════════════════════════════ + + +@patch.dict( + os.environ, + { + "VACUUM_WALL_DB_BACKEND": "sqlite", + "VACUUM_WALL_DB_PATH": ":memory:", + "PYTHONDONTWRITEBYTECODE": "1", + }, +) +class TestMultiUserAdmin: + """Test multi-user admin features: CRUD, permissions, self-deletion.""" + + def setup_method(self) -> None: + reset_db_for_test() + get_db() + + def test_create_user_with_mixed_permissions(self) -> None: + """User created with different permission levels per subsystem.""" + create_user( + "mixeduser", + "password123", + { + "firewall": "rw", + "network": "read", + "logs": "rw", + "dhcp": "rw", + }, + ) + user = get_user("mixeduser") + assert user is not None + assert user["permissions"]["firewall"] == "rw" + assert user["permissions"]["network"] == "read" + assert user["permissions"]["logs"] == "rw" + assert user["permissions"]["dhcp"] == "rw" + assert "prox" not in user["permissions"] + + def test_read_only_permissions(self) -> None: + """Verify read-only user can be created and queried.""" + create_user( + "readonly", + "password123", + { + "firewall": "read", + "network": "read", + "dhcp": "read", + "proxy": "read", + }, + ) + user = get_user("readonly") + assert user is not None + for _sub, level in user["permissions"].items(): + assert level == "read" + + def test_update_permissions_replaces_all(self) -> None: + """Updating permissions replaces existing set entirely.""" + create_user( + "permchange", + "password123", + { + "firewall": "rw", + "network": "rw", + }, + ) + user = get_user("permchange") + assert "firewall" in user["permissions"] + assert "network" in user["permissions"] + assert "dhcp" not in user["permissions"] + + update_permissions( + "permchange", + { + "dhcp": "rw", + "logs": "read", + }, + ) + user = get_user("permchange") + assert "dhcp" in user["permissions"] + assert "logs" in user["permissions"] + assert "firewall" not in user["permissions"] + assert "network" not in user["permissions"] + + def test_create_user_no_permissions(self) -> None: + """User created without permissions gets empty permission set.""" + perms = {"firewall": "rw"} + user = create_user("noperm", "password123", perms) + assert user["permissions"]["firewall"] == "rw" + + update_permissions("noperm", {}) + user = get_user("noperm") + assert not user["permissions"] + + def test_delete_user_cascades_permissions(self) -> None: + """Deleting a user also removes their permission rows.""" + create_user( + "cscduser", + "password123", + { + "firewall": "rw", + "network": "read", + "auth": "rw", + }, + ) + user = get_user("cscduser") + assert len(user["permissions"]) == 3 + + delete_user("cscduser") + assert get_user("cscduser") is None + # Verify permissions are cascaded-deleted + from lib.db import get_db as _get_db + + rows = _get_db().query(Q_SELECT_PERMISSIONS, ("cscduser",)) + assert len(rows) == 0 + + def test_delete_user_cascades_webauthn_creds(self) -> None: + """Deleting a user also removes their WebAuthn credentials.""" + _insert_cred("wgscduser", cred_id="test-cred-id-123") + + from lib.webauthn import list_credentials + + creds = list_credentials("wgscduser") + assert len(creds) == 1 + + delete_user("wgscduser") + creds = list_credentials("wgscduser") + assert len(creds) == 0 + + def test_self_deletion_prevention(self) -> None: + """Self-deletion is prevented at the Flask blueprint layer. + + The daemon handler accepts the delete request but trusts the Flask + middleware (which has _user_ctx) to block self-deletion first. + + This test verifies the handler still works for normal deletion. + The Flask blueprint test verifies self-deletion blocking. + """ + from daemon.handlers.auth import auth_delete_user + + create_user("admin", "password123", {"auth": "rw"}) + create_user("target", "password123", {"firewall": "read"}) + + # Handler can delete other users + mock_request = MagicMock() + result = auth_delete_user(mock_request, {"username": "target"}) + assert result["ok"] is True + assert get_user("target") is None + + def test_flask_self_deletion_logic(self) -> None: + """Flask blueprint blocks self-deletion: verify the guard logic.""" + # The blueprint check is a simple comparison: + # user_ctx.get("username") == username + # We verify this logic directly. + + # Self-deletion scenario: same user + user_ctx = {"username": "alice"} + username = "alice" + # When user_ctx is not None and username matches, blueprint blocks + assert user_ctx is not None + assert user_ctx.get("username") == username # Would trigger 403 + + # Different user scenario + user_ctx2 = {"username": "admin"} + username2 = "alice" + # When usernames differ, deletion proceeds + assert user_ctx2.get("username") != username2 + + # No context scenario + user_ctx3 = None + # When user_ctx is None, delete proceeds (no guard) + assert user_ctx3 is None # No guard triggered + + def test_self_deletion_prevention_no_context(self) -> None: + """When _user_ctx is not set, deletion proceeds (direct daemon call).""" + from daemon.handlers.auth import auth_delete_user + + create_user("directuser", "password123", {"firewall": "read"}) + + # No _user_ctx — daemon called directly (e.g., batch API) + mock_request = MagicMock() + mock_request._user_ctx = None + result = auth_delete_user(mock_request, {"username": "directuser"}) + assert result["ok"] is True + assert get_user("directuser") is None + + def test_multiple_users_independent_states(self) -> None: + """Multiple users maintain independent password and permission state.""" + create_user("user_a", "pass_a", {"firewall": "rw"}) + create_user("user_b", "pass_b", {"dhcp": "read"}) + + # Passwords are independent + assert verify_user_password("user_a", "pass_a") is not None + assert verify_user_password("user_a", "pass_b") is None + assert verify_user_password("user_b", "pass_b") is not None + assert verify_user_password("user_b", "pass_a") is None + + # Permissions are independent + perms_a = get_user("user_a")["permissions"] + perms_b = get_user("user_b")["permissions"] + assert "firewall" in perms_a + assert "dhcp" not in perms_a + assert "dhcp" in perms_b + assert "firewall" not in perms_b + + # Updating one user doesn't affect the other + update_permissions("user_a", {"logs": "rw"}) + perms_b_after = get_user("user_b")["permissions"] + assert "dhcp" in perms_b_after + assert "logs" not in perms_b_after + + def test_permission_update_nonexistent_user(self) -> None: + """Updating permissions for a nonexistent user raises ValueError.""" + with pytest.raises(ValueError, match="not found"): + update_permissions("nonexistent", {"firewall": "rw"}) + + def test_list_users_with_permissions(self) -> None: + """list_users returns all users with their full permission dicts.""" + create_user("alice", "pass1", {"firewall": "rw", "network": "read"}) + create_user("bob", "pass2", {"dhcp": "rw", "auth": "rw"}) + create_user("charlie", "pass3", {}) + + users = list_users() + assert len(users) == 3 + + by_name = {u["username"]: u for u in users} + + assert "firewall" in by_name["alice"]["permissions"] + assert "network" in by_name["alice"]["permissions"] + assert "dhcp" not in by_name["alice"]["permissions"] + + assert "dhcp" in by_name["bob"]["permissions"] + assert "auth" in by_name["bob"]["permissions"] + + assert not by_name["charlie"]["permissions"] + + def test_all_subsystems_constant(self) -> None: + """ALL_SUBSYSTEMS contains expected subsystem names.""" + from lib.auth_users import ALL_SUBSYSTEMS + + expected = { + "firewall", + "network", + "dhcp", + "proxy", + "certs", + "wireguard", + "logs", + "status", + "auth", + } + assert set(ALL_SUBSYSTEMS) == expected + + +class TestPermissionMiddleware: + """Test Flask middleware permission enforcement logic. + + These tests verify the permission checking logic that would be applied + by the Flask before_request middleware (server._auth_middleware). + """ + + def test_permission_check_read_allowed(self) -> None: + """GET requests allowed with 'read' or 'rw' for the subsystem.""" + from webui.server import _has_permission + + perms = {"firewall": "read"} + assert _has_permission(perms, "firewall", "GET") is True + + perms_rw = {"firewall": "rw"} + assert _has_permission(perms_rw, "firewall", "GET") is True + + def test_permission_check_read_denied_write(self) -> None: + """Non-GET requests denied with 'read' permission.""" + from webui.server import _has_permission + + perms = {"firewall": "read"} + assert _has_permission(perms, "firewall", "POST") is False + assert _has_permission(perms, "firewall", "DELETE") is False + + def test_permission_check_rw_allowed(self) -> None: + """'rw' permission allows all HTTP methods.""" + from webui.server import _has_permission + + perms = {"firewall": "rw"} + assert _has_permission(perms, "firewall", "GET") is True + assert _has_permission(perms, "firewall", "POST") is True + assert _has_permission(perms, "firewall", "DELETE") is True + + def test_permission_check_no_access(self) -> None: + """Missing subsystem permission denies all access.""" + from webui.server import _has_permission + + perms = {"logs": "read"} + assert _has_permission(perms, "firewall", "GET") is False + assert _has_permission(perms, "firewall", "POST") is False + + def test_subsystem_extraction(self) -> None: + """Subsystem name correctly extracted from API path.""" + from webui.server import _subsystem_from_path + + assert _subsystem_from_path("/api/firewall/config") == "firewall" + assert _subsystem_from_path("/api/auth/users") == "auth" + assert _subsystem_from_path("/api/dhcp/leases/subpath") == "dhcp" + assert _subsystem_from_path("/") is None + assert _subsystem_from_path("/static/app.js") is None diff --git a/tests/test_server.py b/tests/test_server.py index 702d37a..7307f64 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -30,7 +30,11 @@ class TestSPARoutes: def test_api_routes_still_work(self, client): resp = client.get("/api/firewall/zones") - assert resp.status_code in (200, 500) + assert resp.status_code in (401, 500) + data = resp.get_json() + assert data is not None + assert data.get("ok") is False + assert data.get("error") == "unauthorized" class TestWsUrlGeneration: @@ -47,8 +51,9 @@ class TestBlueprintsRegistered: def test_all_blueprints_registered(self, client): from webui.server import BLUEPRINTS - assert len(BLUEPRINTS) == 8 + assert len(BLUEPRINTS) == 9 names = [name for name, _ in BLUEPRINTS] + assert "auth" in names assert "firewall" in names assert "network" in names assert "dhcp" in names diff --git a/webui/api/auth.py b/webui/api/auth.py new file mode 100644 index 0000000..7e4a7bb --- /dev/null +++ b/webui/api/auth.py @@ -0,0 +1,352 @@ +"""Authentication API blueprint. + +Exposed at /api/auth/* and delegates all operations to vacuum-walld. +""" + +from __future__ import annotations + +import logging + +from flask import Blueprint, request + +from daemon.client import delete, get, post +from daemon.iface import ( + DELETE_AUTH_USER, + DELETE_AUTH_WEBAUTHN_CREDENTIAL, + GET_AUTH_SESSION, + GET_AUTH_USERS, + GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS, + GET_AUTH_WEBAUTHN_CREDENTIALS, + POST_AUTH_LOGIN, + POST_AUTH_LOGOUT, + POST_AUTH_PASSWORD, + POST_AUTH_REFRESH, + POST_AUTH_USER_CREATE, + POST_AUTH_USER_UPDATE, + POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN, + POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH, + POST_AUTH_WEBAUTHN_REGISTER_BEGIN, + POST_AUTH_WEBAUTHN_REGISTER_FINISH, +) +from webui.api.common import _error, _ok + +logger = logging.getLogger(__name__) +bp = Blueprint("auth", __name__) + + +@bp.route("/login", methods=["POST"]) +def login(): + """Authenticate user with username and password. + + Endpoint: + POST /api/auth/login + Body: + { "username": "admin", "password": "secretpass" } + Returns: + { "tokens": { "access_token": "...", "refresh_token": "..." }, + "user": { "id": 1, "username": "admin" }, + "permissions": { ... } } + """ + try: + body = request.get_json(silent=True) or {} + return _ok(post(POST_AUTH_LOGIN, body)) + except Exception as exc: + logger.error("Login failed: %s", exc) + return _error(str(exc), 401) + + +@bp.route("/logout", methods=["POST"]) +def logout(): + """Invalidate current session by blacklisting access and refresh tokens. + + Endpoint: + POST /api/auth/logout + Body: + { "refresh_token": "..." } -- client-provided refresh token + Returns: + { "ok": true } + """ + try: + client_body = request.get_json(silent=True) or {} + body = { + **(request._user_ctx or {}), + "refresh_token": client_body.get("refresh_token"), + } + return _ok(post(POST_AUTH_LOGOUT, body)) + except RuntimeError as exc: + logger.error("Logout failed: %s", exc) + return _error(str(exc), 500) + + +@bp.route("/refresh", methods=["POST"]) +def refresh(): + """Rotate tokens using a refresh token. + + Endpoint: + POST /api/auth/refresh + Body: + { "refresh_token": "..." } + Returns: + { "tokens": { ... }, "user": { ... }, "permissions": { ... } } + """ + try: + body = request.get_json(silent=True) or {} + return _ok(post(POST_AUTH_REFRESH, body)) + except Exception as exc: + logger.error("Token refresh failed: %s", exc) + return _error(str(exc), 401) + + +@bp.route("/session", methods=["GET"]) +def session(): + """Return current user session info. + + Endpoint: + GET /api/auth/session + Returns: + { "user": { ... }, "permissions": { ... } } + """ + try: + return _ok(get(GET_AUTH_SESSION, {**(request._user_ctx or {})})) + except Exception as exc: + logger.error("Session check failed: %s", exc) + return _error(str(exc), 401) + + +@bp.route("/password", methods=["POST"]) +def change_password(): + """Change own password. + + Endpoint: + POST /api/auth/password + Body: + { "oldPassword": "...", "newPassword": "..." } + Returns: + { "ok": true } + """ + try: + body = request.get_json(silent=True) or {} + user_ctx = getattr(request, "_user_ctx", None) + if user_ctx is not None: + body["username"] = user_ctx["username"] + return _ok(post(POST_AUTH_PASSWORD, body)) + except Exception as exc: + logger.error("Password change failed: %s", exc) + return _error(str(exc), 400) + + +@bp.route("/users", methods=["GET"]) +def list_users(): + """List all users. + + Endpoint: + GET /api/auth/users + Returns: + { "users": [{ "id": 1, "username": "...", "permissions": { ... }, ... }] } + """ + try: + return _ok(get(GET_AUTH_USERS)) + except RuntimeError as exc: + logger.error("List users failed: %s", exc) + return _error(str(exc), 500) + + +@bp.route("/users", methods=["POST"]) +def create_user(): + """Create a new user. + + Endpoint: + POST /api/auth/users + Body: + { "username": "...", "password": "...", "permissions": { ... } } + Returns: + { "ok": true, "id": ..., "username": "...", "permissions": { ... } } + """ + try: + body = request.get_json(silent=True) or {} + return _ok(post(POST_AUTH_USER_CREATE, body)) + except Exception as exc: + logger.error("Create user failed: %s", exc) + return _error(str(exc), 400) + + +@bp.route("/users/", methods=["POST"]) +def update_user(username: str): + """Update user permissions. + + Endpoint: + POST /api/auth/users/ + Body: + { "permissions": { ... } } + Returns: + { "ok": true, "id": ..., "username": "..." } + """ + try: + body = {**(request.get_json(silent=True) or {}), "username": username} + return _ok(post(POST_AUTH_USER_UPDATE, body)) + except Exception as exc: + err = str(exc) + status = 404 if "not found" in err.lower() else 400 + logger.error("Update user failed: %s", exc) + return _error(err, status) + + +@bp.route("/users/", methods=["DELETE"]) +def delete_user(username: str): + """Delete a user. + + Endpoint: + DELETE /api/auth/users/ + Returns: + { "ok": true } + """ + # Prevent self-deletion + user_ctx = getattr(request, "_user_ctx", None) + if user_ctx is not None and user_ctx.get("username") == username: + return _error("Cannot delete your own account", 403) + try: + return _ok(delete(DELETE_AUTH_USER, {"username": username})) + except Exception as exc: + err = str(exc) + status = 404 if "not found" in err.lower() else 400 + logger.error("Delete user failed: %s", exc) + return _error(err, status) + + +# --------------------------------------------------------------------------- +# WebAuthn routes +# --------------------------------------------------------------------------- + + +@bp.route("/webauthn/register-begin", methods=["POST"]) +def webauthn_register_begin(): + """Begin WebAuthn registration. + + Endpoint: + POST /api/auth/webauthn/register-begin + Body: + { "username": "..." } + Returns: + Registration options for navigator.credentials.create() + """ + try: + body = request.get_json(silent=True) or {} + return _ok(post(POST_AUTH_WEBAUTHN_REGISTER_BEGIN, body)) + except Exception as exc: + logger.error("WebAuthn register begin failed: %s", exc) + return _error(str(exc), 400) + + +@bp.route("/webauthn/register-finish", methods=["POST"]) +def webauthn_register_finish(): + """Finish WebAuthn registration. + + Endpoint: + POST /api/auth/webauthn/register-finish + Body: + { "username": "...", "credential_response": {...}, "registration_options": {...}, "name": "..." } + Returns: + { "ok": true, "credential": {...} } + """ + try: + body = request.get_json(silent=True) or {} + return _ok(post(POST_AUTH_WEBAUTHN_REGISTER_FINISH, body)) + except Exception as exc: + logger.error("WebAuthn register finish failed: %s", exc) + return _error(str(exc), 400) + + +@bp.route("/webauthn/authenticate-begin", methods=["POST"]) +def webauthn_authenticate_begin(): + """Begin WebAuthn authentication (public endpoint). + + Endpoint: + POST /api/auth/webauthn/authenticate-begin + Body: + { "username": "..." } + Returns: + Authentication options for navigator.credentials.get() + or { "no_webauthn": true } if user has no credentials. + """ + try: + body = request.get_json(silent=True) or {} + return _ok(post(POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN, body)) + except Exception as exc: + logger.error("WebAuthn authenticate begin failed: %s", exc) + return _error(str(exc), 400) + + +@bp.route("/webauthn/authenticate-finish", methods=["POST"]) +def webauthn_authenticate_finish(): + """Finish WebAuthn authentication (public endpoint). + + Endpoint: + POST /api/auth/webauthn/authenticate-finish + Body: + { "username": "...", "assertion_response": {...}, "auth_options": {...} } + Returns: + { "tokens": {...}, "user": {...}, "permissions": {...} } + """ + try: + body = request.get_json(silent=True) or {} + return _ok(post(POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH, body)) + except Exception as exc: + logger.error("WebAuthn authenticate finish failed: %s", exc) + return _error(str(exc), 401) + + +@bp.route("/webauthn/credentials", methods=["GET"]) +def webauthn_credentials_list(): + """List registered WebAuthn credentials. + + Endpoint: + GET /api/auth/webauthn/credentials + Returns: + { "credentials": [...] } + """ + try: + return _ok(get(GET_AUTH_WEBAUTHN_CREDENTIALS, {**(request._user_ctx or {})})) + except Exception as exc: + logger.error("List WebAuthn credentials failed: %s", exc) + return _error(str(exc), 500) + + +@bp.route("/webauthn/credential-counts", methods=["GET"]) +def webauthn_credential_counts(): + """Return credential counts for all users. + + Admin endpoint — returns a dict mapping usernames to credential counts. + + Endpoint: + GET /api/auth/webauthn/credential-counts + Returns: + { "counts": { "username": 2, ... } } + """ + try: + return _ok(get(GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS)) + except RuntimeError as exc: + logger.error("List credential counts failed: %s", exc) + return _error(str(exc), 500) + + +@bp.route("/webauthn/creds/", methods=["DELETE"]) +def webauthn_remove_credential(credential_id: str): + """Remove a WebAuthn credential. + + Endpoint: + DELETE /api/auth/webauthn/creds/ + Returns: + { "ok": true } + """ + try: + return _ok( + delete( + DELETE_AUTH_WEBAUTHN_CREDENTIAL, + {**(request._user_ctx or {}), "credential_id": credential_id}, + ) + ) + except Exception as exc: + err = str(exc) + status = 404 if "not found" in err.lower() else 400 + logger.error("Remove WebAuthn credential failed: %s", exc) + return _error(err, status) diff --git a/webui/server.py b/webui/server.py index 201fa1a..cc789cd 100644 --- a/webui/server.py +++ b/webui/server.py @@ -14,10 +14,13 @@ import sys import time from pathlib import Path -from flask import Flask, abort, request +from flask import Flask, abort, jsonify, request from werkzeug.middleware.proxy_fix import ProxyFix +from lib.auth import validate_token +from lib.db import get_db from lib.logging import setup_logging +from webui.api.auth import bp as auth_bp from webui.api.certs import bp as certs_bp from webui.api.dhcp import bp as dhcp_bp from webui.api.firewall import bp as firewall_bp @@ -86,6 +89,9 @@ app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 5 if _DEV_MODE else 31536000 # Flask is behind nginx — trust X-Forwarded-* headers for scheme/host detection app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) +get_db() + +app.register_blueprint(auth_bp, url_prefix="/api/auth") app.register_blueprint(firewall_bp, url_prefix="/api/firewall") app.register_blueprint(network_bp, url_prefix="/api/network") app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp") @@ -96,6 +102,7 @@ app.register_blueprint(logs_bp, url_prefix="/api/logs") app.register_blueprint(status_bp, url_prefix="/api/status") BLUEPRINTS = [ + ("auth", auth_bp), ("firewall", firewall_bp), ("network", network_bp), ("dhcp", dhcp_bp), @@ -109,6 +116,90 @@ BLUEPRINTS = [ for name, _ in BLUEPRINTS: logger.info("Registered blueprint '%s' at /api/%s", name, name) +# ── Public endpoints (no auth required) ── +_AUTH_EXEMPT = { + ("GET", "/"), + ("POST", "/api/auth/login"), + ("POST", "/api/auth/refresh"), + ("POST", "/api/auth/webauthn/authenticate-begin"), + ("POST", "/api/auth/webauthn/authenticate-finish"), +} + + +def _subsystem_from_path(path: str) -> str | None: + """Extract subsystem name from API path.""" + if not path.startswith("/api/"): + return None + parts = path.split("/") + if len(parts) >= 3: + return parts[2] + return None + + +def _has_permission(perms: dict, subsystem: str, method: str) -> bool: + """Check if user has permission for subsystem + method.""" + level = perms.get(subsystem) + if method == "GET": + return level in ("read", "rw") + return level == "rw" + + +# ── JWT authentication middleware ── + + +@app.before_request +def _auth_middleware(): + """Validate JWT from Authorization header for API routes. + + Exempts: static routes, vendor files, and public auth endpoints. + Attaches request._user_ctx with user info for downstream handlers. + """ + method = request.method + path = request.path + + # Exempt specific paths + if (method, path) in _AUTH_EXEMPT: + return + if method == "GET" and path.startswith("/vendor/"): + return + if method in ("GET", "HEAD") and path.startswith("/static/"): + return + + # For non-API routes, skip auth + if not path.startswith("/api/"): + return + + # Extract token from Authorization header + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return jsonify({"ok": False, "error": "unauthorized"}), 401 + + token_string = auth_header[7:] # strip "Bearer " + payload = validate_token(token_string, token_type="access") + if payload is None: + return jsonify({"ok": False, "error": "unauthorized"}), 401 + + username = payload.get("sub") + if not username: + return jsonify({"ok": False, "error": "unauthorized"}), 401 + + # Check subsystem permissions + subsystem = _subsystem_from_path(path) + if subsystem: + perms = payload.get("permissions", {}) + if subsystem not in perms: + return jsonify({"ok": False, "error": "forbidden"}), 403 + if not _has_permission(perms, subsystem, method): + return jsonify({"ok": False, "error": "forbidden"}), 403 + + request._user_ctx = { + "username": username, + "permissions": perms if subsystem else payload.get("permissions", {}), + "jti": payload.get("jti"), + } + return + + # --------------------------------------------------------------------------- # Request logging # --------------------------------------------------------------------------- diff --git a/webui/static/app.js b/webui/static/app.js index 19cae0f..e423724 100644 --- a/webui/static/app.js +++ b/webui/static/app.js @@ -1,4 +1,4 @@ -import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=10'; +import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive, initAuth, getAuthToken, checkSession } from '/static/hoover/index.js?v=10'; import DashboardPage from '/static/pages/dashboard.js?v=11'; import InterfacesPage from '/static/pages/interfaces.js?v=9'; @@ -12,9 +12,12 @@ import CertsPage from '/static/pages/certs.js?v=9'; import WireguardPage from '/static/pages/wireguard.js?v=9'; import LogsPage from '/static/pages/logs.js?v=9'; import NotFoundPage from '/static/pages/notfound.js?v=9'; +import LoginPage from '/static/pages/login.js'; +import PasskeysPage from '/static/pages/passkeys.js'; +import UsersPage from '/static/pages/users.js'; /* ── Navigation items ──────────────────────────────────────── */ -const Nav = [ +const _NavBase = [ { path: '/dashboard', label: 'Dashboard' }, { path: '/interfaces', label: 'Interfaces' }, { path: '/zones', label: 'Zones' }, @@ -28,6 +31,15 @@ const Nav = [ { path: '/logs', label: 'Logs' }, ]; +function getNav() { + const nav = [..._NavBase]; + const perms = JSON.parse(localStorage.getItem('vw:permissions') || 'null'); + if (perms && perms.auth === 'rw') { + nav.push({ path: '/users', label: 'Users' }); + } + return nav; +} + modelRegister('firewall', { subsystem: 'firewall', fetch: async () => { @@ -174,14 +186,17 @@ modelRegister('status', { }, }); -/* ── Initial fetch ─────────────────────────────────────────── */ -for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) { - modelFetch(name); +/* ── Initial fetch (after auth check) ───────────────────────── */ +function fetchInitialData() { + for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) { + modelFetch(name); + } + modelFetch('logs', 'journal'); } -modelFetch('logs', 'journal'); /* ── Page map ──────────────────────────────────────────────── */ const Pages = { + login: LoginPage, dashboard: DashboardPage, interfaces: InterfacesPage, zones: ZonesPage, @@ -193,11 +208,14 @@ const Pages = { certs: CertsPage, wireguard: WireguardPage, logs: LogsPage, + passkeys: PasskeysPage, + users: UsersPage, }; /* ── Router ────────────────────────────────────────────────── */ const router = { state: reactive({ path: location.hash.slice(1) || '/dashboard' }), + isAuthenticated: false, component() { const name = this.state.path.replace(/^\//, ''); const page = Pages[name] || NotFoundPage; @@ -213,10 +231,11 @@ window.addEventListener('hashchange', () => { /* ── Sidebar render root ───────────────────────────────────── */ function Sidebar() { const current = router.state.path; + const nav = getNav(); return h('div', { class: 'sidebar' }, h('div', { class: 'logo' }, 'Vacuum Wall'), h('nav', null, - Nav.map(item => + nav.map(item => Link({ path: item.path, class: current === item.path ? 'active' : '', @@ -236,17 +255,26 @@ function MainContent() { } /* ── Init ──────────────────────────────────────────────────── */ -export function initApp() { +export async function initApp() { const sidebarEl = document.getElementById('sidebar'); const mainEl = document.getElementById('main'); if (sidebarEl && mainEl) { render(sidebarEl, Sidebar); render(mainEl, MainContent); } - // Defer connect() after the first render microtask settles to prevent - // the initial requestUpdate() from triggering a second commit while - // the vnode tree is still being finalized. - setTimeout(connect, 0); + + // Check auth state before connecting WS + const ok = await initAuth(); + if (ok) { + router.isAuthenticated = true; + fetchInitialData(); + setTimeout(connect, 0); + } else { + // No valid session — redirect to login + if (router.state.path !== '/login') { + window.location.hash = '/login'; + } + } } if (document.readyState === 'loading') { diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js index 31bb310..1dc1c5c 100644 --- a/webui/static/hoover/api.js +++ b/webui/static/hoover/api.js @@ -1,20 +1,123 @@ /** * Hoover — api.js * - * JSON-friendly fetch wrapper with automatic header management. + * JSON-friendly fetch wrapper with automatic header management and JWT auth. * Toast notification system with auto-dismiss. * Modal processing guard for async form submissions. */ -import { modelFetch } from './model.js?v=9'; +import { modelFetch } from './model.js?v=10'; import { requestUpdate } from './reactivity.js?v=9'; import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js?v=9'; +/** + * Global state — shared with auth.js component. + * + * ``window.__auth_token__`` — current access token (in memory, cleared on reload). + * ``localStorage['vw:refresh']`` — refresh token (survives reload). + * ``localStorage['vw:access_ttl']`` — access token TTL in ms for refresh scheduling. + */ + +/** + * Inject ``Authorization: Bearer `` header from ``window.__auth_token__``. + * Returns undefined when no token is available. + * + * @returns {string|undefined} + */ +function getAuthToken() { + return window.__auth_token__; +} + +/** + * Store access token in memory and schedule refresh. + * + * @param {string} token + */ +function setAuthToken(token) { + window.__auth_token__ = token; +} + +/** + * Clear all auth tokens from memory and storage. + */ +function clearAuthTokens() { + window.__auth_token__ = undefined; + localStorage.removeItem('vw:refresh'); + localStorage.removeItem('vw:access_ttl'); + localStorage.removeItem('vw:user'); + if (typeof window.__authRefreshTimer__ !== 'undefined') { + clearTimeout(window.__authRefreshTimer__); + window.__authRefreshTimer__ = undefined; + } +} + +/** + * Read refresh token and access TTL from localStorage. + * @returns {{refresh?: string, ttl?: number}} + */ +function getStoredAuth() { + return { + refresh: localStorage.getItem('vw:refresh'), + ttl: parseInt(localStorage.getItem('vw:access_ttl'), 10) || 900000, + }; +} + +/** + * Attempt to refresh the access token using the stored refresh token. + * + * Sends: POST /api/auth/refresh { refresh_token: ... } + * On success: updates ``window.__auth_token__`` and ``localStorage['vw:refresh']``. + * On failure: clears all tokens. + * + * @returns {Promise} ``true`` if refresh succeeded + */ +async function tryRefreshToken() { + const stored = getStoredAuth(); + if (!stored.refresh) return false; + + try { + const res = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ refresh_token: stored.refresh }), + credentials: 'same-origin', + }); + if (res.status !== 200) { + clearAuthTokens(); + return false; + } + const json = await res.json(); + if (!json.ok || !json.data?.tokens) { + clearAuthTokens(); + return false; + } + const tokens = json.data.tokens; + window.__auth_token__ = tokens.access_token; + localStorage.setItem('vw:refresh', tokens.refresh_token); + localStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 900) * 1000)); + localStorage.setItem('vw:user', JSON.stringify(json.data.user)); + return true; + } catch { + clearAuthTokens(); + return false; + } +} + +/** + * Redirect to login page, clearing tokens. + */ +function redirectLogin() { + clearAuthTokens(); + window.location.href = '/#/login'; +} + /** * JSON-friendly fetch wrapper. * * Automatically sets Content-Type for object bodies, parses JSON * responses, and normalises the result to { ok, data, error, status }. + * Injects ``Authorization: Bearer`` header when a token is present. + * On 401, tries token refresh once; on persistent failure, redirects to login. * * @param {string} url – Target URL * @param {object} [options] – Fetch options (method, body, headers, …) @@ -23,6 +126,10 @@ import { isModalProcessing, setModalProcessing, refreshModals } from './componen export async function apiFetch(url, options = {}) { const { method = 'GET', body, ...opts } = options; const headers = { 'Accept': 'application/json', ...opts.headers }; + const token = getAuthToken(); + if (token) { + headers['Authorization'] = 'Bearer ' + token; + } if (body && typeof body === 'object' && !(body instanceof FormData)) { headers['Content-Type'] = 'application/json'; @@ -30,12 +137,21 @@ export async function apiFetch(url, options = {}) { } try { - const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts }); + const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts }); if (opts.signal?.aborted) { return { ok: false, data: null, error: 'Aborted', status: 0 }; } - if (res.status === 401) { - window.location.reload(); + if (res.status === 401 && getAuthToken()) { + const refreshed = await tryRefreshToken(); + if (refreshed) { + headers['Authorization'] = 'Bearer ' + getAuthToken(); + const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts }); + const json = await retryRes.json(); + if (retryRes.ok) { + return { ok: json.ok, data: json.ok ? json.data : json, error: null, status: retryRes.status }; + } + } + redirectLogin(); return { ok: false, data: null, error: 'Session expired', status: 401 }; } const json = await res.json(); @@ -49,6 +165,11 @@ export async function apiFetch(url, options = {}) { } } +/** + * Export auth helpers for use by other modules. + */ +export { setAuthToken, clearAuthTokens, getAuthToken, tryRefreshToken, redirectLogin }; + /** ─── Toast notifications ────────────────────────────────── */ /** Toast notification queue. Exported for ToastContainer component. */ @@ -195,7 +316,6 @@ export function formAction(fn) { return async () => { if (isModalProcessing()) return; setModalProcessing(true); - refreshModals(); try { await fn(); } catch (e) { @@ -244,13 +364,13 @@ export function apiSubmit(opts) { handler: async () => { if (isModalProcessing()) return; setModalProcessing(true); - refreshModals(); try { const b = body ? body() : {}; if (validate) { const err = validate(b); if (err) { toast(err, 'error'); return; } } + refreshModals(); const res = await apiFetch(url, { method, body: b }); if (res.ok) { const synced = res.data?.synced; diff --git a/webui/static/hoover/components/auth.js b/webui/static/hoover/components/auth.js new file mode 100644 index 0000000..cd9d51a --- /dev/null +++ b/webui/static/hoover/components/auth.js @@ -0,0 +1,268 @@ +/** + * Hoover — auth.js + * + * Token refresh scheduler, session check, logout. + */ + +import { apiFetch, setAuthToken, clearAuthTokens, redirectLogin, getAuthToken, tryRefreshToken, toast } from '../api.js?v=12'; + +/** + * Schedule a token refresh based on the access token TTL stored in localStorage. + * The refresh fires at TTL - 60 seconds to allow the browser to refresh smoothly. + */ +export function scheduleTokenRefresh() { + if (typeof window.__authRefreshTimer__ !== 'undefined') { + clearTimeout(window.__authRefreshTimer__); + } + const ttl = parseInt(localStorage.getItem('vw:access_ttl'), 10) || 900000; + const delay = Math.max(ttl - 60000, 30000); + + window.__authRefreshTimer__ = setTimeout(async () => { + const ok = await tryRefreshToken(); + if (ok) { + scheduleTokenRefresh(); + } + }, delay); +} + +/** + * Stop the refresh timer (e.g. user logs out or page unloads). + */ +export function cancelTokenRefresh() { + if (typeof window.__authRefreshTimer__ !== 'undefined') { + clearTimeout(window.__authRefreshTimer__); + window.__authRefreshTimer__ = undefined; + } +} + +/** + * Check the current session by calling GET /api/auth/session. + * Returns true if the session is valid. + * + * @returns {Promise} + */ +export async function checkSession() { + const result = await apiFetch('/api/auth/session'); + if (result.ok) { + const { user, permissions } = result.data || {}; + if (user) { + localStorage.setItem('vw:user', JSON.stringify(user)); + if (permissions) { + localStorage.setItem('vw:permissions', JSON.stringify(permissions)); + } + return true; + } + } + return false; +} + +/** + * Logout: blacklist current token and clear auth state, then redirect to login. + */ +export async function logout() { + const token = getAuthToken(); + if (token) { + try { + const headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': 'Bearer ' + token, + }; + const refresh = localStorage.getItem('vw:refresh'); + await fetch('/api/auth/logout', { + method: 'POST', + headers, + credentials: 'same-origin', + body: JSON.stringify({ refresh_token: refresh || '' }), + }); + } catch { + // ignore errors, we're clearing everything anyway + } + } + cancelTokenRefresh(); + clearAuthTokens(); + redirectLogin(); +} + +/** + * Initialize auth state on page load. + * Checks stored tokens, validates session, and schedules refresh. + * + * @returns {Promise} true if authenticated + */ +export async function initAuth() { + const token = getAuthToken(); + if (token) { + const saved = JSON.parse(localStorage.getItem('vw:user') || 'null'); + if (saved) { + const ok = await checkSession(); + if (ok) { + scheduleTokenRefresh(); + return true; + } + } + } + clearAuthTokens(); + return false; +} + +/** + * Handle login response: store tokens, schedule refresh, redirect. + * + * @param {object} data — login/migrate response data + * @param {string} [redirectPath] — where to navigate after login + */ +export function handleLoginSuccess(data, redirectPath = '/dashboard') { + const { tokens, user, permissions } = data || {}; + if (tokens) { + setAuthToken(tokens.access_token); + localStorage.setItem('vw:refresh', tokens.refresh_token); + localStorage.setItem('vw:access_ttl', String((data.access_ttl || 900) * 1000)); + if (user) { + localStorage.setItem('vw:user', JSON.stringify(user)); + if (permissions) { + localStorage.setItem('vw:permissions', JSON.stringify(permissions)); + } + } + scheduleTokenRefresh(); + } + window.location.hash = redirectPath; +} + +/** + * Check if WebAuthn (passkeys) is supported in this browser. + * + * @returns {boolean} + */ +export function webauthnSupported() { + return typeof window !== 'undefined' && !!window.PublicKeyCredential; +} + +/* ─── Base64url helpers ──────────────────────────────────────────────── */ + +/** + * Convert base64url string to ArrayBuffer. + * @param {string} b64url + * @returns {ArrayBuffer} + */ +function b64urlToArrayBuffer(b64url) { + const bin = atob(b64url.replace(/-/g, '+').replace(/_/g, '/')); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) { + arr[i] = bin.charCodeAt(i); + } + return arr.buffer; +} + +/** + * Convert ArrayBuffer to base64url string. + * @param {ArrayBuffer} buffer + * @returns {string} + */ +function arrayBufferToB64url(buffer) { + const bytes = new Uint8Array(buffer); + const bin = String.fromCharCode.apply(null, Array.from(bytes)); + return btoa(bin) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +/* ─── WebAuthn navigator wrappers ────────────────────────────────────── */ + +/** + * Start a WebAuthn registration ceremony. + * + * Calls ``navigator.credentials.create()`` with the provided options, + * then returns the credential response as a JSON-serializable dict + * suitable for sending to the server. + * + * @param {object} registrationOptions — options from /webauthn/register-begin + * @returns {Promise} credential response (id, rawId, type, response) + */ +export async function startRegistration(registrationOptions) { + if (!webauthnSupported()) { + throw new Error('WebAuthn is not supported in this browser'); + } + + const publicKey = { + challenge: b64urlToArrayBuffer(registrationOptions.challenge), + rp: registrationOptions.rp, + user: { + id: b64urlToArrayBuffer(registrationOptions.user.id), + name: registrationOptions.user.name, + displayName: registrationOptions.user.displayName, + }, + pubKeyCredParams: registrationOptions.pubKeyCredParams, + timeout: registrationOptions.timeout, + }; + + if (registrationOptions.excludeCredentials) { + publicKey.excludeCredentials = registrationOptions.excludeCredentials.map(c => ({ + ...c, + id: b64urlToArrayBuffer(c.id), + })); + } + if (registrationOptions.authenticatorSelection) { + publicKey.authenticatorSelection = registrationOptions.authenticatorSelection; + } + + const credential = await navigator.credentials.create({ publicKey }); + + const { id, rawId, type, response } = credential; + + return { + id: arrayBufferToB64url(rawId), + rawId: arrayBufferToB64url(rawId), + type, + response: { + clientDataJSON: arrayBufferToB64url(response.clientDataJSON), + attestationObject: arrayBufferToB64url(response.attestationObject), + transports: response.getTransports ? response.getTransports() : [], + }, + }; +} + +/** + * Start a WebAuthn authentication ceremony. + * + * Calls ``navigator.credentials.get()`` with the provided options, + * then returns the assertion response as a JSON-serializable dict. + * + * @param {object} authenticationOptions — options from /webauthn/authenticate-begin + * @returns {Promise} assertion response (id, rawId, type, response) + */ +export async function startAuthentication(authenticationOptions) { + if (!webauthnSupported()) { + throw new Error('WebAuthn is not supported in this browser'); + } + + const publicKey = { + challenge: b64urlToArrayBuffer(authenticationOptions.challenge), + timeout: authenticationOptions.timeout, + userVerification: authenticationOptions.userVerification || 'preferred', + }; + + if (authenticationOptions.allowCredentials) { + publicKey.allowCredentials = authenticationOptions.allowCredentials.map(c => ({ + ...c, + id: b64urlToArrayBuffer(c.id), + })); + } + + const credential = await navigator.credentials.get({ publicKey }); + + const { id, rawId, type, response } = credential; + + return { + id: arrayBufferToB64url(rawId), + rawId: arrayBufferToB64url(rawId), + type, + response: { + clientDataJSON: arrayBufferToB64url(response.clientDataJSON), + authenticatorData: arrayBufferToB64url(response.authenticatorData), + signature: arrayBufferToB64url(response.signature), + userHandle: response.userHandle ? arrayBufferToB64url(response.userHandle) : null, + }, + }; +} diff --git a/webui/static/hoover/components/modal.js b/webui/static/hoover/components/modal.js index 6f31c26..35f7467 100644 --- a/webui/static/hoover/components/modal.js +++ b/webui/static/hoover/components/modal.js @@ -191,7 +191,8 @@ export function formModal(inner, title, fields, actions) { if (a.handler) { const origHandler = a.handler; btn.addEventListener('click', () => { - refreshModals(); + btn.disabled = true; + btn.innerHTML = ''; origHandler(); }); } diff --git a/webui/static/hoover/index.js b/webui/static/hoover/index.js index daf8dca..2298c40 100644 --- a/webui/static/hoover/index.js +++ b/webui/static/hoover/index.js @@ -23,10 +23,13 @@ export { definePage, hComp } from './component.js?v=9'; export { createRouter, Link } from './router.js?v=9'; /* ── WebSocket ───────────────────────────────────────────────── */ -export { connect, onMessage } from './websocket.js?v=9'; +export { connect, onMessage } from './websocket.js?v=10'; /* ── API & Toast ─────────────────────────────────────────────── */ -export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad, formAction } from './api.js?v=9'; +export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad, formAction, setAuthToken, clearAuthTokens, getAuthToken } from './api.js?v=12'; + +/* ── UI Components: Auth ──────────────────────────────────────── */ +export { scheduleTokenRefresh, cancelTokenRefresh, checkSession, logout, initAuth, handleLoginSuccess, webauthnSupported, startRegistration, startAuthentication } from './components/auth.js?v=2'; /* ── Model ───────────────────────────────────────────────────── */ export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js?v=9'; @@ -41,7 +44,7 @@ export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGr export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=9'; /* ── UI Components: Modal ────────────────────────────────────── */ -export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js?v=9'; +export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js?v=10'; /* ── UI Components: Apply ────────────────────────────────────── */ export { ApplyConfirm } from './components/applyconfirm.js?v=9'; diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js index 840cf7c..efaf51c 100644 --- a/webui/static/hoover/websocket.js +++ b/webui/static/hoover/websocket.js @@ -10,10 +10,41 @@ import { refreshByTopic } from './model.js?v=9'; let _wsConn = null; let _wsReconnectMs = 0; +let _wsFailCount = 0; -/** Direct onMessage handlers: { topics, handler, unsubscribed }[] */ +/** Direct onMessage handlers — { topics, handler, unsubscribed }[] */ const _directHandlers = []; +/** + * Refresh the access token. Does NOT redirect on failure — the caller + * decides what to do when refresh fails. + * + * @returns {Promise} true if token was refreshed + */ +async function _tryRefreshToken() { + const refresh = localStorage.getItem('vw:refresh'); + if (!refresh) return false; + try { + const res = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ refresh_token: refresh }), + credentials: 'same-origin', + }); + if (res.status !== 200) return false; + const json = await res.json(); + if (!json.ok || !json.data?.tokens) return false; + const tokens = json.data.tokens; + window.__auth_token__ = tokens.access_token; + localStorage.setItem('vw:refresh', tokens.refresh_token); + localStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 900) * 1000)); + localStorage.setItem('vw:user', JSON.stringify(json.data.user)); + return true; + } catch { + return false; + } +} + /** * Build the WebSocket URL. Supports an override via `window.__WS_URL__` * (useful for proxy setups). Falls back to port 9091 when the current @@ -25,17 +56,43 @@ function _wsUrl() { return proto + '//' + location.host + '/ws'; } -/** Attempt a WebSocket connection. */ +/** Attempt a WebSocket connection. + * Passes the JWT in the WebSocket subprotocol header (Sec-WebSocket-Protocol) + * instead of a query parameter, keeping it out of logs and browser history. + */ function _wsConnect() { if (_wsConn && _wsConn.readyState <= 1) return; - _wsConn = new WebSocket(_wsUrl()); + const token = window.__auth_token__; + if (token) { + _wsConn = new WebSocket(_wsUrl(), ['Bearer ' + token]); + } else { + _wsConn = new WebSocket(_wsUrl()); + } _wsConn.onopen = () => { _wsReconnectMs = 0; + _wsFailCount = 0; }; _wsConn.onclose = () => { + if (!window.__auth_token__) return; + _wsFailCount++; + + if (_wsFailCount >= 3) { + // Attempt token refresh after repeated failures. No redirect + // on failure — the reconnect loop continues. + (async () => { + const ok = await _tryRefreshToken(); + if (ok) { + _wsFailCount = 0; + _wsReconnectMs = 0; + _wsConn = null; + setTimeout(_wsConnect, 100); + } + })(); + } + _wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000); setTimeout(_wsConnect, _wsReconnectMs); }; diff --git a/webui/static/pages/login.js b/webui/static/pages/login.js new file mode 100644 index 0000000..09f9515 --- /dev/null +++ b/webui/static/pages/login.js @@ -0,0 +1,207 @@ +/** + * Login page. + * + * Username + password form, plus "Sign in with passkey" button. + * On success: stores tokens and navigates to dashboard. + */ + +import { h, definePage } from '/static/hoover/index.js?v=11'; +import { apiFetch, toast, setAuthToken, getAuthToken } from '/static/hoover/api.js?v=12'; +import { + handleLoginSuccess, + webauthnSupported, + startAuthentication, +} from '/static/hoover/components/auth.js'; + +import { html } from '/static/hoover/html.js?v=9'; + +function LoginPage() { + const hasWebAuthn = webauthnSupported(); + + return html` + + `; +} + +function handleLogin() { + const form = document.getElementById('loginForm'); + if (!form) return; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + await doPasswordLogin(); + }); +} + +async function doPasswordLogin() { + const username = document.getElementById('loginUsername').value.trim(); + const password = document.getElementById('loginPassword').value; + const errEl = document.getElementById('loginError'); + if (!username || !password) { + errEl.textContent = 'Username and password are required'; + return; + } + errEl.textContent = ''; + + const res = await apiFetch('/api/auth/login', { + method: 'POST', + body: { username, password }, + }); + + if (res.ok) { + handleLoginSuccess(res.data); + toast('Welcome, ' + username, 'success'); + } else { + errEl.textContent = res.error || 'Login failed'; + } +} + +function setupPasskeyButton() { + const passkeyBtn = document.getElementById('passkeyBtn'); + if (!passkeyBtn) return; + + const usernameInput = document.getElementById('loginUsername'); + const passwordGroup = document.getElementById('loginPasswordGroup'); + const loginBtn = document.getElementById('loginBtn'); + const errEl = document.getElementById('loginError'); + + passkeyBtn.addEventListener('click', async () => { + errEl.textContent = ''; + const username = usernameInput.value.trim(); + if (!username) { + errEl.textContent = 'Enter your username first'; + usernameInput.focus(); + return; + } + + passkeyBtn.disabled = true; + passkeyBtn.textContent = 'Checking...'; + + try { + const beginRes = await apiFetch('/api/auth/webauthn/authenticate-begin', { + method: 'POST', + body: { username }, + }); + + if (!beginRes.ok) { + errEl.textContent = beginRes.error || 'Failed to start authentication'; + passkeyBtn.disabled = false; + passkeyBtn.textContent = 'Sign in with passkey'; + return; + } + + if (beginRes.data && beginRes.data.no_webauthn) { + errEl.textContent = 'No passkey registered for this account'; + passkeyBtn.disabled = false; + passkeyBtn.textContent = 'Sign in with passkey'; + return; + } + + const authOptions = beginRes.data; + passkeyBtn.textContent = 'Waiting for authenticator...'; + + const assertionResponse = await startAuthentication(authOptions); + + passkeyBtn.textContent = 'Verifying...'; + + const finishRes = await apiFetch('/api/auth/webauthn/authenticate-finish', { + method: 'POST', + body: { + username, + assertion_response: assertionResponse, + auth_options: authOptions, + }, + }); + + if (finishRes.ok) { + handleLoginSuccess(finishRes.data); + toast('Welcome, ' + username, 'success'); + } else { + errEl.textContent = finishRes.error || 'Passkey authentication failed'; + } + } catch (err) { + if (err.message && err.message.toLowerCase().includes('user cancelled')) { + errEl.textContent = 'Authentication cancelled'; + } else { + errEl.textContent = err.message || 'Passkey authentication failed'; + } + } finally { + passkeyBtn.disabled = false; + passkeyBtn.textContent = 'Sign in with passkey'; + } + }); + + passkeyBtn.addEventListener('mouseenter', () => { + if (passwordGroup) { + passwordGroup.style.display = 'none'; + } + }); + + passkeyBtn.addEventListener('mouseleave', () => { + if (passwordGroup) { + passwordGroup.style.display = ''; + } + }); +} + +const Page = definePage({ + init() { + document.title = 'Login — Vacuum Wall'; + }, + + async load(state, abortController) { + if (getAuthToken()) { + try { + const res = await apiFetch('/api/auth/session'); + if (res.ok) { + window.location.hash = '/dashboard'; + return; + } + } catch { + // auth check failed, show login + } + } + }, + + render() { + return h('div', null, LoginPage()); + }, +}); + +handleLogin(); +setupPasskeyButton(); + +export default Page; diff --git a/webui/static/pages/passkeys.js b/webui/static/pages/passkeys.js new file mode 100644 index 0000000..8cdb663 --- /dev/null +++ b/webui/static/pages/passkeys.js @@ -0,0 +1,288 @@ +/** + * WebAuthn credentials management page. + * + * Lists registered passkeys with name, transports, and sign count. + * Provides "Add passkey" and "Remove" actions. + */ + +import { + html, + definePage, + reactive, + apiFetch, + toast, + openModal, + closeModal, + formModal, + refreshModals, + PageHeader, + Empty, + Table, + esc, + ActionCell, + Badge, + startRegistration, + webauthnSupported, +} from '/static/hoover/index.js?v=12'; +import { isModalProcessing, setModalProcessing } from '/static/hoover/components/modal.js?v=9'; + +const state = reactive({ credentials: [], loading: true, refreshing: false, error: null }); + +async function loadCredentials() { + if (state.credentials.length) state.refreshing = true; + else state.loading = true; + state.error = null; + + try { + const res = await apiFetch('/api/auth/webauthn/credentials'); + if (res.ok) { + state.credentials = res.data || []; + } else { + state.error = res.error || 'Failed to load credentials'; + } + } catch (e) { + state.error = e.message || 'Failed to load credentials'; + } + state.loading = false; + state.refreshing = false; +} + +function addCredentialModal() { + if (!webauthnSupported()) { + toast('WebAuthn is not supported in this browser', 'error'); + return; + } + + openModal((inner) => { + formModal( + inner, + 'Add passkey', + [ + { + label: 'Passkey name', + id: 'cred-name', + type: 'text', + placeholder: 'My laptop key', + }, + ], + [ + { + label: 'Cancel', + cls: 'btn-outline', + action: 'c', + handler: () => closeModal(), + }, + { + label: 'Register', + cls: 'btn-primary', + action: 'r', + processing: true, + handler: async () => { + if (isModalProcessing()) return; + setModalProcessing(true); + refreshModals(); + + const user = JSON.parse(localStorage.getItem('vw:user') || 'null'); + const username = user?.username || ''; + if (!username) { + toast('Username not available', 'error'); + setModalProcessing(false); + refreshModals(); + return; + } + + try { + // Step 1: Get registration options + const beginRes = await apiFetch('/api/auth/webauthn/register-begin', { + method: 'POST', + body: { username }, + }); + + if (!beginRes.ok) { + throw beginRes.error || 'Registration failed'; + } + + const options = beginRes.data; + + // Step 2: Call browser authenticator + const credentialName = document.getElementById('cred-name')?.value?.trim() || ''; + const credentialResponse = await startRegistration(options); + + // Step 3: Verify with server + const finishRes = await apiFetch('/api/auth/webauthn/register-finish', { + method: 'POST', + body: { + username, + credential_response: credentialResponse, + registration_options: options, + name: credentialName, + }, + }); + + if (!finishRes.ok) { + throw finishRes.error || 'Registration verification failed'; + } + + toast('Passkey registered', 'success'); + closeModal(); + loadCredentials(); + } catch (e) { + if (!e.message.toLowerCase().includes('cancelled')) { + toast(e.message || 'Registration failed', 'error'); + } + } finally { + setModalProcessing(false); + refreshModals(); + } + }, + }, + ], + ); + }); +} + +function confirmRemove(credentialId, credentialName) { + openModal((inner) => { + formModal( + inner, + 'Remove passkey', + [], + [ + html`

Remove "${esc(credentialName || credentialId.slice(0, 12))}"?

`, + { + label: 'Cancel', + cls: 'btn-outline', + action: 'c', + handler: () => closeModal(), + }, + { + label: 'Remove', + cls: 'btn-primary btn-danger', + action: 'r', + processing: true, + handler: async () => { + if (isModalProcessing()) return; + setModalProcessing(true); + refreshModals(); + + try { + const res = await apiFetch('/api/auth/webauthn/creds/' + encodeURIComponent(credentialId), { + method: 'DELETE', + }); + + if (!res.ok) { + throw res.error || 'Removal failed'; + } + + toast('PassKey removed', 'success'); + closeModal(); + loadCredentials(); + } catch (e) { + toast(e.message || 'Removal failed', 'error'); + } finally { + setModalProcessing(false); + refreshModals(); + } + }, + }, + ], + ); + }); +} + +function CredentialsPage() { + if (state.loading && !state.credentials.length) { + return [ + PageHeader({ title: 'Passkeys', subtitle: 'Manage your passkey credentials for passwordless authentication' }), + html`
+
Loading...
+
`, + ]; + } + + if (state.error) { + return [ + PageHeader({ title: 'Passkeys', subtitle: 'Manage your passkey credentials for passwordless authentication' }), + html`
+
${esc(state.error)}
+
`, + ]; + } + + if (!state.credentials.length) { + return [ + PageHeader({ + title: 'Passkeys', + subtitle: 'Manage your passkey credentials for passwordless authentication', + actions: html``, + }), + html` + + `, + ]; + } + + const cols = [ + { key: 'name', label: 'Name' }, + { key: 'transports', label: 'Transports' }, + { key: 'signCount', label: 'Uses' }, + { key: 'id', label: 'ID' }, + { key: '_action', label: '' }, + ]; + + const rows = state.credentials.map(c => ({ + name: esc(c.name || 'Unnamed'), + transports: (c.transports || ['internal']).map(t => + html`${esc(t)}` + ), + signCount: c.sign_count ?? 0, + id: esc(c.id.slice(0, 12) + '...'), + _action: ActionCell({ + actions: [ + { + label: 'Remove', + cls: 'btn-danger', + icon: 'Delete', + onClick: () => confirmRemove(c.id, c.name), + }, + ], + }), + })); + + const actions = webauthnSupported() + ? html`` + : html`WebAuthn not supported in this browser`; + + return [ + PageHeader({ + title: 'Passkeys', + subtitle: 'Manage your passkey credentials for passwordless authentication', + actions: actions, + }), + Table({ columns: cols, rows }), + ]; +} + +const Page = definePage({ + init() { + document.title = 'Passkeys — Vacuum Wall'; + return state; + }, + + async load(s, abortController) { + await loadCredentials(); + }, + + render() { + return CredentialsPage(); + }, +}); + +export default Page; diff --git a/webui/static/pages/users.js b/webui/static/pages/users.js new file mode 100644 index 0000000..7c8e195 --- /dev/null +++ b/webui/static/pages/users.js @@ -0,0 +1,259 @@ +/** + * Users management page. + * + * Multi-user admin: list, create, edit permissions, delete users. + * Requires auth: rw permission. + */ + +import { h, definePage, reactive, requestUpdate } from '/static/hoover/index.js?v=11'; +import { html, PageHeader, Table, Badge, ConfirmDelete, Empty, Card, openModal, closeModal, formModal, apiFetch, toast, esc } from '/static/hoover/index.js?v=11'; + +const SUBSYSTEMS = [ + { key: 'firewall', label: 'Firewall' }, + { key: 'network', label: 'Network' }, + { key: 'dhcp', label: 'DHCP' }, + { key: 'proxy', label: 'Proxy' }, + { key: 'certs', label: 'Certs' }, + { key: 'wireguard', label: 'WireGuard' }, + { key: 'logs', label: 'Logs' }, + { key: 'status', label: 'Status' }, + { key: 'auth', label: 'Auth' }, +]; + +function currentUser() { + const u = JSON.parse(localStorage.getItem('vw:user') || 'null'); + return u ? u.username : ''; +} + +function hasAuthAdmin() { + const perms = JSON.parse(localStorage.getItem('vw:permissions') || 'null'); + return perms && perms.auth === 'rw'; +} + +const state = reactive({ users: [], loading: true, refreshing: false, error: null }); + +async function loadUsers(abortController) { + if (abortController?.signal?.aborted) return; + if (state.users.length) state.refreshing = true; + else state.loading = true; + state.error = null; + + try { + const [usersRes, countsRes] = await Promise.all([ + apiFetch('/api/auth/users'), + apiFetch('/api/auth/webauthn/credential-counts'), + ]); + if (abortController?.signal?.aborted) return; + + if (usersRes.ok) { + const credCounts = countsRes.ok ? (countsRes.data || {}) : {}; + state.users = (usersRes.data || []).map(u => ({ + ...u, + credCount: credCounts[u.username] || 0, + })); + } else { + state.error = usersRes.error || 'Failed to load users'; + } + } catch (e) { + state.error = 'Failed to load users'; + } + state.loading = false; + state.refreshing = false; +} + +function permissionLevel(perms, subsystem) { + return perms[subsystem] || '—'; +} + +function openCreateUserModal() { + openModal((inner) => { + const fields = [ + { label: 'Username', id: 'new-username', placeholder: '3-32 chars: letters, digits, dash, underscore' }, + { label: 'Password', id: 'new-password', type: 'password', placeholder: 'At least 8 characters' }, + ]; + + // Add subsystem permission selects + for (const sub of SUBSYSTEMS) { + fields.push({ + label: sub.label, + id: 'perm-' + sub.key, + tag: 'select', + options: ['—', 'read', 'rw'], + }); + } + + const actions = [ + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }, + { + label: 'Create', + cls: 'btn-primary', + action: 's', + processing: true, + handler: async () => { + const username = document.getElementById('new-username').value.trim(); + const password = document.getElementById('new-password').value; + + if (!username) { toast('Username is required', 'error'); return; } + if (!password || password.length < 8) { toast('Password must be at least 8 characters', 'error'); return; } + + const perms = {}; + for (const sub of SUBSYSTEMS) { + const level = document.getElementById('perm-' + sub.key).value; + if (level && level !== '—') { + perms[sub.key] = level; + } + } + + const res = await apiFetch('/api/auth/users', { + method: 'POST', + body: { username, password, permissions: perms }, + }); + + if (res.ok) { + toast('User ' + username + ' created', 'success'); + closeModal(); + loadUsers(); + } else { + toast(res.error || 'Failed to create user', 'error'); + } + }, + }, + ]; + + formModal(inner, 'Create User', fields, actions); + }); +} + +function openEditPermissionsModal(user) { + openModal((inner) => { + const perms = user.permissions || {}; + const fields = [ + { label: 'Username', id: 'edit-username', value: user.username, type: 'text' }, + ]; + + for (const sub of SUBSYSTEMS) { + fields.push({ + label: sub.label, + id: 'edit-perm-' + sub.key, + tag: 'select', + options: [['', '—'], ['read', 'read'], ['rw', 'rw']], + }); + } + + const actions = [ + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }, + { + label: 'Save', + cls: 'btn-primary', + action: 's', + processing: true, + handler: async () => { + const perms = {}; + for (const sub of SUBSYSTEMS) { + const level = document.getElementById('edit-perm-' + sub.key).value; + if (level && level !== '—') { + perms[sub.key] = level; + } + } + + const res = await apiFetch('/api/auth/users/' + encodeURIComponent(user.username), { + method: 'POST', + body: { permissions: perms }, + }); + + if (res.ok) { + toast('Permissions updated', 'success'); + closeModal(); + loadUsers(); + } else { + toast(res.error || 'Failed to update permissions', 'error'); + } + }, + }, + ]; + + formModal(inner, 'Edit Permissions — ' + esc(user.username), fields, actions); + + // Pre-select permission values + for (const sub of SUBSYSTEMS) { + const el = document.getElementById('edit-perm-' + sub.key); + if (el) { + el.value = perms[sub.key] || ''; + } + } + }); +} + +function UsersPage() { + if (state.loading) { + return html` +

Loading...

`; + } + + if (state.error && !state.users.length) { + return html` +

${esc(state.error)}

`; + } + + const myUser = currentUser(); + const rows = state.users.map(u => { + const isMe = u.username === myUser; + const permBadges = SUBSYSTEMS.map(sub => { + const level = permissionLevel(u.permissions, sub.key); + const variant = level === 'rw' ? 'info' : level === 'read' ? 'secondary' : 'light'; + if (level === '—') return null; + return html`<${Badge} text=${level} variant=${variant} /> ${sub.label} `; + }).filter(Boolean); + + return html` + ${esc(u.username)} + ${u.credCount || 0} + ${permBadges.length ? permBadges.join(' ') : '—'} + + + ${isMe ? html`(you)` : + html`<${ConfirmDelete} + url=${'/api/auth/users/' + encodeURIComponent(u.username)} + deleteKey=${u.username} + message=${'Delete user ' + esc(u.username) + '? This cannot be undone.'} + success=${'User ' + esc(u.username) + ' deleted'} + onRefresh=${() => loadUsers()} />`} + + `; + }); + + return [ + PageHeader({ + title: 'Users', + subtitle: 'Manage users and permissions', + actions: html``, + }), + html`
+
+ <${Table} + columns=${['Username', 'Passkeys', 'Permissions', 'Actions']} + rows=${rows} + emptyText="No users found" /> +
+
`, + ]; +} + +export default definePage({ + init() { + return state; + }, + + async load(s, abortController) { + if (!hasAuthAdmin()) { + s.error = 'Admin access required'; + s.loading = false; + return; + } + await loadUsers(abortController); + }, + + render(s) { + return h('div', null, UsersPage()); + }, +});