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