- API versioning: all REST endpoints prefixed with /api/v1/ - Structured logging: replaced stdlib logging with structlog (console/JSON modes) - Alembic migrations: versioned DB schema with initial migration - Error standardization: global exception handlers for consistent envelope format - Interrupt cleanup: asyncio background task for expired interrupt removal - Integration tests: +30 tests (analytics, replay, openapi, error, session APIs) - Frontend tests: +57 tests (all components, pages, useWebSocket hook) - Backend: 557 tests, 89.75% coverage | Frontend: 80 tests, 16 test files
73 lines
2.1 KiB
Python
73 lines
2.1 KiB
Python
"""API key authentication for admin endpoints and WebSocket connections."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
from typing import Annotated
|
|
|
|
import structlog
|
|
from fastapi import Depends, HTTPException, Query, Request, WebSocket, status
|
|
from fastapi.security import APIKeyHeader
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
_API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
|
|
|
|
def _get_admin_api_key(request: Request) -> str:
|
|
"""Retrieve the configured admin API key from app settings.
|
|
|
|
Returns empty string if settings are not configured (test/dev mode).
|
|
"""
|
|
settings = getattr(request.app.state, "settings", None)
|
|
if settings is None:
|
|
return ""
|
|
key = getattr(settings, "admin_api_key", "")
|
|
return key if isinstance(key, str) else ""
|
|
|
|
|
|
async def require_admin_api_key(
|
|
request: Request,
|
|
api_key: Annotated[str | None, Depends(_API_KEY_HEADER)] = None,
|
|
) -> None:
|
|
"""Dependency that enforces API key authentication on admin endpoints.
|
|
|
|
Skips validation when no admin_api_key is configured (dev mode).
|
|
"""
|
|
expected = _get_admin_api_key(request)
|
|
if not expected:
|
|
return
|
|
|
|
if api_key is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Missing X-API-Key header",
|
|
)
|
|
if not secrets.compare_digest(api_key, expected):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Invalid API key",
|
|
)
|
|
|
|
|
|
async def verify_ws_token(
|
|
ws: WebSocket,
|
|
token: str | None = Query(default=None),
|
|
) -> None:
|
|
"""Verify WebSocket connection token from query parameter.
|
|
|
|
Skips validation when no admin_api_key is configured (dev mode).
|
|
Usage: ws://host/ws?token=<api_key>
|
|
"""
|
|
settings = ws.app.state.settings
|
|
expected = settings.admin_api_key
|
|
if not expected:
|
|
return
|
|
|
|
if token is None or not secrets.compare_digest(token, expected):
|
|
await ws.close(code=4001, reason="Unauthorized")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Invalid or missing WebSocket token",
|
|
)
|