"""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()