"""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, 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: return {} 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. 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 # --------------------------------------------------------------------------- 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.") verify_authentication( username, assertion_response, auth_options, origin=origin, rp_id=rp_id, ) 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()