0ed275835d
Refresh/logout and token robustness - drop the post-rotation refresh_tokens row delete in auth_refresh so logout blacklists the current (rotated) refresh token; remove the dead _clear_refresh_token_after_rotation helper and clear_active_refresh_token - reject non-object JWT payloads in _extract_unverified_sub so crafted Authorization headers return 401 instead of crashing with 500 SQLite user store - make builtin-admin seeding idempotent: on a concurrent first start the losing seeder re-checks, finds the winner, and returns instead of raising IntegrityError - per-thread sqlite connections + busy_timeout so Flask worker threads don't hit cross-thread ProgrammingError / SQLITE_BUSY - add LogsDirectory + /var/log/vacuum-wall to ReadWritePaths in both systemd units so the fallback admin password actually lands on disk Frontend - skip apiFetch 401-recovery for public auth endpoints so a failed login no longer logs out a valid session - add /passkeys to the nav (passkey registration was unreachable); remove the dead checkWebAuthnCapable export - drop the CSP-blocked inline WS-URL script and the __WS_URL_PLACEHOLDER__ plumbing; the WS URL is derived from location Daemon / WS - parse Sec-WebSocket-Protocol manually (web.Request.get_subprotocols does not exist in aiohttp 3.13); X-Auth-Token is a custom-nginx fallback only — docstring and security docs corrected Install / system - bootstrap_auth.py is now idempotent: preserves existing auth config and syncs the admin password on re-runs (new reset_password helper) - WebUI server block renders auth_basic off (the UI is JWT-protected) - install.sh chown/chmod skips .git to avoid git dubious-ownership breakage - tolerate unreadable /etc/wireguard during system import Contracts / docs - create_user returns 409 on duplicate username per docs/api.md - correct docs/api.md response shapes, docs/security.md blacklist cleanup wording + one-refresh-per-user caveat, stale WS-URL references, and the .htpasswd description Tests: +7 regression tests (rotation/logout revocation, crafted-token 401, concurrent seeding); placeholder-substitution tests replaced with serve-as-is SPA root tests.
406 lines
12 KiB
Python
406 lines
12 KiB
Python
"""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 Conflict, delete, get, post
|
|
from daemon.iface import (
|
|
DELETE_AUTH_USER,
|
|
DELETE_AUTH_WEBAUTHN_CREDENTIAL,
|
|
GET_AUTH_SESSION,
|
|
GET_AUTH_USERS,
|
|
GET_AUTH_WEBAUTHN_CAPABLE,
|
|
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 {}
|
|
body["client_ip"] = request.headers.get("X-Real-IP") or request.remote_addr
|
|
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 Conflict as exc:
|
|
return _error(str(exc), 409)
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _resolve_webauthn_origin() -> tuple[str, str]:
|
|
"""Extract WebAuthn origin and rp_id from the current request.
|
|
|
|
Returns (origin, rp_id) derived from the actual request, falling back
|
|
to config values when the request metadata is unavailable.
|
|
"""
|
|
scheme = request.headers.get("X-Forwarded-Proto", request.scheme)
|
|
host = request.headers.get("X-Forwarded-Host", request.host.split(":")[0])
|
|
origin = f"{scheme}://{host}"
|
|
# rp_id is the registered domain (strip port numbers)
|
|
rp_id = host.split(":")[0]
|
|
return origin, rp_id
|
|
|
|
|
|
@bp.route("/webauthn/capable", methods=["GET"])
|
|
def webauthn_capable():
|
|
"""Check if WebAuthn is available on the current request domain.
|
|
|
|
Endpoint:
|
|
GET /api/auth/webauthn/capable
|
|
Returns:
|
|
{ "enabled": true/false, "rp_id": "...", "rp_name": "...", "origin": "..." }
|
|
or { "enabled": false, "reason": "..." }
|
|
"""
|
|
try:
|
|
origin, rp_id = _resolve_webauthn_origin()
|
|
body = {"webauthn_origin": origin, "webauthn_rp_id": rp_id}
|
|
return _ok(get(GET_AUTH_WEBAUTHN_CAPABLE, body))
|
|
except Exception as exc:
|
|
logger.error("WebAuthn capable check failed: %s", exc)
|
|
return _error(str(exc), 500)
|
|
|
|
|
|
@bp.route("/webauthn/register-begin", methods=["POST"])
|
|
def webauthn_register_begin():
|
|
"""Begin WebAuthn registration.
|
|
|
|
Endpoint:
|
|
POST /api/auth/webauthn/register-begin
|
|
Returns:
|
|
Registration options for navigator.credentials.create()
|
|
"""
|
|
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"]
|
|
origin, rp_id = _resolve_webauthn_origin()
|
|
body["webauthn_origin"] = origin
|
|
body["webauthn_rp_id"] = rp_id
|
|
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:
|
|
{ "credential_response": {...}, "registration_options": {...}, "name": "..." }
|
|
Returns:
|
|
{ "ok": true, "credential": {...} }
|
|
"""
|
|
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"]
|
|
origin, rp_id = _resolve_webauthn_origin()
|
|
body["webauthn_origin"] = origin
|
|
body["webauthn_rp_id"] = rp_id
|
|
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 {}
|
|
_, rp_id = _resolve_webauthn_origin()
|
|
body["webauthn_rp_id"] = rp_id
|
|
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 {}
|
|
body["client_ip"] = request.headers.get("X-Real-IP") or request.remote_addr
|
|
origin, rp_id = _resolve_webauthn_origin()
|
|
body["webauthn_origin"] = origin
|
|
body["webauthn_rp_id"] = rp_id
|
|
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)
|