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:
2026-07-24 01:21:39 +00:00
parent 04417cf05c
commit 56b200d233
28 changed files with 4900 additions and 82 deletions
+528
View File
@@ -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()
+8 -2
View File
@@ -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}
+33
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+258
View File
@@ -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()
+19
View File
@@ -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",
+280
View File
@@ -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
+168
View File
@@ -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
View File
@@ -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",
+83
View File
@@ -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
View File
@@ -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}
+3
View File
@@ -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]
+78
View File
@@ -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
View File
@@ -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"
+964
View File
@@ -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
+7 -2
View File
@@ -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
+352
View File
@@ -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
View File
@@ -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
# ---------------------------------------------------------------------------
+38 -10
View File
@@ -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']) {
/* ── 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.
// 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') {
+126 -6
View File
@@ -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';
@@ -34,8 +141,17 @@ export async function apiFetch(url, options = {}) {
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;
+268
View File
@@ -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,
},
};
}
+2 -1
View File
@@ -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();
});
}
+6 -3
View File
@@ -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';
+59 -2
View File
@@ -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;
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);
};
+207
View File
@@ -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;
+288
View File
@@ -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;
+259
View File
@@ -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());
},
});