Files
vacuum-wall/webui/api/auth.py
T
mteehan 358573567d fix: dual-key rate limiting for auth + websocket reconnect guard
- Pass client IP (X-Real-IP header) through Flask to daemon for both
  password login and WebAuthn authenticate-finish endpoints
- Rate limiter now checks both IP and username buckets: IP layer catches
  enumeration/brute-force attacks across multiple usernames; username
  layer protects against single-account targeting from multiple IPs
- Add _wsRefreshing flag to prevent double-scheduling reconnect when
  onclose fires during token refresh; simplify async IIFE to .then()/.catch()
- Reset _wsRefreshing on websocket onopen for safety
2026-07-28 00:54:48 +00:00

355 lines
10 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 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 {}
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 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 {}
body["client_ip"] = request.headers.get("X-Real-IP") or request.remote_addr
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)