feat: add auth subsystem with WebAuthn passkeys support
New modules: lib/auth, lib/auth_users, lib/db, lib/db_sqlite, lib/password, lib/webauthn, daemon/handlers/auth, scripts/bootstrap_auth, tests/test_auth Frontend: webui/api/auth, hoover/components/auth, pages/login, passkeys, users Updates: daemon/iface and server, lib/common and nginx, pyproject.toml deps, install script, server.py, app.js, and websocket/api clients
This commit is contained in:
@@ -0,0 +1,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()
|
||||
@@ -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}
|
||||
|
||||
@@ -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/<username>")
|
||||
DELETE_AUTH_USER: Endpoint = _ep("DELETE", "/auth/users/<username>")
|
||||
|
||||
# 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/<credential_id>"
|
||||
)
|
||||
|
||||
# ---- Server infra (not going through client) ----
|
||||
GET_HEALTH: Endpoint = _ep("GET", "/health")
|
||||
POST_STATUS_REFRESH: Endpoint = _ep("POST", "/status/refresh")
|
||||
|
||||
+34
-1
@@ -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 <token>")
|
||||
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 <token>")
|
||||
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
|
||||
|
||||
+358
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
+1
-26
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
+355
@@ -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}
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
+22
-24
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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/<username>", methods=["POST"])
|
||||
def update_user(username: str):
|
||||
"""Update user permissions.
|
||||
|
||||
Endpoint:
|
||||
POST /api/auth/users/<username>
|
||||
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/<username>", methods=["DELETE"])
|
||||
def delete_user(username: str):
|
||||
"""Delete a user.
|
||||
|
||||
Endpoint:
|
||||
DELETE /api/auth/users/<username>
|
||||
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/<credential_id>", methods=["DELETE"])
|
||||
def webauthn_remove_credential(credential_id: str):
|
||||
"""Remove a WebAuthn credential.
|
||||
|
||||
Endpoint:
|
||||
DELETE /api/auth/webauthn/creds/<credential_id>
|
||||
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)
|
||||
+92
-1
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+40
-12
@@ -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') {
|
||||
|
||||
+127
-7
@@ -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 <token>`` 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<boolean>} ``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;
|
||||
|
||||
@@ -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<boolean>}
|
||||
*/
|
||||
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<boolean>} 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<object>} 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<object>} 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 = '<span class="btn-spinner"></span>';
|
||||
origHandler();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<boolean>} 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);
|
||||
};
|
||||
|
||||
@@ -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`
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<h2 class="login-title">Vacuum Wall</h2>
|
||||
<p class="login-subtitle">Sign in to continue</p>
|
||||
<form id="loginForm" class="login-form">
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="text"
|
||||
id="loginUsername"
|
||||
autocomplete="username"
|
||||
placeholder="Username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div id="loginPasswordGroup" class="form-group">
|
||||
<input
|
||||
type="password"
|
||||
id="loginPassword"
|
||||
autocomplete="current-password"
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div id="loginError" class="login-error"></div>
|
||||
<button type="submit" class="btn btn-primary btn-login" id="loginBtn">Sign in</button>
|
||||
</form>
|
||||
${hasWebAuthn ? html`
|
||||
<div class="login-divider">or</div>
|
||||
<button type="button" class="btn btn-outline btn-passkey" id="passkeyBtn">
|
||||
Sign in with passkey
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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`<p class="text-sm">Remove "<strong>${esc(credentialName || credentialId.slice(0, 12))}</strong>"?</p>`,
|
||||
{
|
||||
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`<div class="card" key="loading">
|
||||
<div class="card-body loading">Loading...</div>
|
||||
</div>`,
|
||||
];
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title: 'Passkeys', subtitle: 'Manage your passkey credentials for passwordless authentication' }),
|
||||
html`<div class="card" key="error">
|
||||
<div class="card-body error-msg">${esc(state.error)}</div>
|
||||
</div>`,
|
||||
];
|
||||
}
|
||||
|
||||
if (!state.credentials.length) {
|
||||
return [
|
||||
PageHeader({
|
||||
title: 'Passkeys',
|
||||
subtitle: 'Manage your passkey credentials for passwordless authentication',
|
||||
actions: html`<button class="btn btn-sm btn-primary" onClick=${() => webauthnSupported() && addCredentialModal()}>
|
||||
Add passkey
|
||||
</button>`,
|
||||
}),
|
||||
html`<Empty text="No passkeys registered">
|
||||
<button class="btn btn-sm btn-primary"
|
||||
onClick=${() => webauthnSupported() && addCredentialModal()}>
|
||||
Add passkey
|
||||
</button>
|
||||
</Empty>`,
|
||||
];
|
||||
}
|
||||
|
||||
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`<Badge>${esc(t)}</Badge>`
|
||||
),
|
||||
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`<button class="btn btn-sm btn-primary" onClick=${() => addCredentialModal()}>
|
||||
Add passkey
|
||||
</button>`
|
||||
: html`<span class="text-sm text-muted">WebAuthn not supported in this browser</span>`;
|
||||
|
||||
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;
|
||||
@@ -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`<div class="page-header"><h1>Users</h1><p>Manage users and permissions</p></div>
|
||||
<div class="card"><div class="card-body"><p class="text-muted">Loading...</p></div></div>`;
|
||||
}
|
||||
|
||||
if (state.error && !state.users.length) {
|
||||
return html`<div class="page-header"><h1>Users</h1><p>Manage users and permissions</p></div>
|
||||
<div class="card"><div class="card-body"><p class="text-danger">${esc(state.error)}</p></div></div>`;
|
||||
}
|
||||
|
||||
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`<span key=${sub.key}><${Badge} text=${level} variant=${variant} /> ${sub.label} </span>`;
|
||||
}).filter(Boolean);
|
||||
|
||||
return html`<tr key=${u.username}>
|
||||
<td><strong>${esc(u.username)}</strong></td>
|
||||
<td class="text-sm">${u.credCount || 0}</td>
|
||||
<td class="text-sm text-muted">${permBadges.length ? permBadges.join(' ') : '—'}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline" onClick=${() => openEditPermissionsModal(u)}>Edit</button>
|
||||
${isMe ? html`<span class="text-muted text-sm">(you)</span>` :
|
||||
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()} />`}
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
return [
|
||||
PageHeader({
|
||||
title: 'Users',
|
||||
subtitle: 'Manage users and permissions',
|
||||
actions: html`<button class="btn btn-primary" onClick=${() => openCreateUserModal()}>Add User</button>`,
|
||||
}),
|
||||
html`<div class="card">
|
||||
<div class="card-body">
|
||||
<${Table}
|
||||
columns=${['Username', 'Passkeys', 'Permissions', 'Actions']}
|
||||
rows=${rows}
|
||||
emptyText="No users found" />
|
||||
</div>
|
||||
</div>`,
|
||||
];
|
||||
}
|
||||
|
||||
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());
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user