Files
vacuum-wall/daemon/handlers/auth.py
T
mteehan 0889ef0d08 fix: prevent data loss in update_permissions and token_refresh
- update_permissions: swap to upsert-first-then-delete-stale so a
  failed upsert mid-loop rolls back cleanly, leaving the user's
  permissions intact. Adds Q_DELETE_PERMISSION_SUBSYSTEM for
  targeted removal.

- auth_refresh: generate and persist the new refresh token before
  blacklisting/clearing the old one, so a failure in generate_tokens
  doesn't leave the user locked out with no valid refresh token.
2026-08-12 16:37:06 +00:00

602 lines
17 KiB
Python

"""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_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 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,
generate_tokens,
get_access_ttl,
record_login_failure,
record_login_success,
record_webauthn_failure,
record_webauthn_success,
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_management_domains,
get_rp_name,
list_credentials,
remove_credential,
verify_authentication,
verify_registration,
)
from lib.webauthn import (
is_enabled as webauthn_is_enabled,
)
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")
client_ip = body.get("client_ip")
if not check_login_rate(username, client_ip):
raise ValueError("Too many login attempts. Please try again later.")
user = verify_user_password(username, password)
if user is None:
record_login_failure(username, client_ip)
raise ValueError("Invalid credentials")
record_login_success(username, client_ip)
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:
raise ValueError("Request body is required")
jti = body.get("jti")
if jti:
blacklist_token(jti)
username = body.get("username")
if username:
blacklist_active_refresh_token(username)
return {}
@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.
Requires refresh_token and session_id in the request body for session binding.
Args:
_request: Unused.
body: Dict with ``refresh_token`` and ``session_id``.
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")
session_id = body.get("session_id")
payload = validate_token(refresh_token, token_type="refresh", session_id=session_id)
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")
# Persist new tokens first, then invalidate the old ones.
# This prevents data loss if generate_tokens fails mid-way:
# the old refresh token remains valid and the user is not locked out.
permissions = user["permissions"]
tokens = generate_tokens(username, permissions)
jti = payload.get("jti")
if jti:
blacklist_token(jti, token_type="refresh")
if username:
_clear_refresh_token_after_rotation(username)
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
# ---------------------------------------------------------------------------
def _check_webauthn_domain(body: Any) -> tuple[str, str]:
"""Validate that the request domain is eligible for WebAuthn.
Returns (origin, rp_id) if valid. Raises ValueError otherwise.
"""
if not body or not isinstance(body, dict):
raise ValueError("Request body is required")
if not webauthn_is_enabled():
raise ValueError("WebAuthn is disabled")
rp_id = body.get("webauthn_rp_id")
origin = body.get("webauthn_origin")
if not rp_id or not origin:
raise ValueError("Missing WebAuthn domain configuration")
if rp_id not in get_management_domains():
raise ValueError("WebAuthn is not available on this domain")
return origin, rp_id
@registry.register(GET_AUTH_WEBAUTHN_CAPABLE)
def webauthn_capable(_request: Any, body: Any) -> dict[str, Any]:
"""Check if WebAuthn is available for the current request domain."""
rp_id = body.get("webauthn_rp_id") if body else None
origin = body.get("webauthn_origin") if body else None
if not webauthn_is_enabled():
return {"enabled": False, "reason": "WebAuthn is disabled in config"}
if not rp_id or not origin:
return {"enabled": False, "reason": "Domain information unavailable"}
mgmt_domains = get_management_domains()
if rp_id not in mgmt_domains:
return {"enabled": False, "reason": "Not a management domain"}
return {
"enabled": True,
"rp_id": rp_id,
"rp_name": get_rp_name(),
"origin": origin,
}
@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()``."""
origin, rp_id = _check_webauthn_domain(body)
username = body.get("username")
if not username:
raise ValueError("username is required")
options = create_registration_options(
username,
origin=origin,
rp_id=rp_id,
)
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."""
origin, rp_id = _check_webauthn_domain(body)
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"
)
cred = verify_registration(
username,
credential_response,
registration_options,
credential_name,
origin=origin,
rp_id=rp_id,
)
return {"ok": True, "credential": cred}
@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).
"""
_, rp_id = _check_webauthn_domain(body)
username = body.get("username")
if not username:
raise ValueError("username is required")
options = create_authentication_options(
username,
rp_id=rp_id,
)
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.
"""
origin, rp_id = _check_webauthn_domain(body)
username = body.get("username")
assertion_response = body.get("assertion_response")
auth_options = body.get("auth_options")
client_ip = body.get("client_ip")
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, client_ip):
raise ValueError("Too many WebAuthn attempts. Please try again later.")
try:
verify_authentication(
username,
assertion_response,
auth_options,
origin=origin,
rp_id=rp_id,
)
except ValueError:
record_webauthn_failure(username, client_ip)
raise
record_webauthn_success(username, client_ip)
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)."""
from lib.webauthn import get_all_credential_counts
return get_all_credential_counts()