Files
vacuum-wall/daemon/handlers/auth.py
T

520 lines
15 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_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,
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)
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()