diff --git a/api/src/api/dependencies/auth.py b/api/src/api/dependencies/auth.py index e988fc8..dedbadd 100644 --- a/api/src/api/dependencies/auth.py +++ b/api/src/api/dependencies/auth.py @@ -18,7 +18,8 @@ import uuid from collections.abc import Awaitable, Callable from typing import Annotated -from fastapi import Depends, Header, HTTPException, Request, status +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from src.api.dependencies.db import SessionDep from src.common.exceptions import AuthenticationFailedError @@ -61,6 +62,27 @@ def _login_forbidden(detail: str, email: str, user_id: uuid.UUID | None = None) ) +# Схема безопасности OpenAPI (issue #29): без неё Swagger UI не показывает Authorize, а заголовок выглядит как обычный +# параметр. `auto_error=False`: нет заголовка или чужая схема → `None`, прежний 401 формирует `parse_bearer`. +BEARER_SCHEME_NAME = "bearerAuth" +bearer_scheme = HTTPBearer( + scheme_name=BEARER_SCHEME_NAME, + bearerFormat="JWT", + auto_error=False, + description=( + "JWT РАИСА (прод) или dev-токен `POST /dev/token` (DEBUG). В Swagger: Authorize → токен без слова Bearer." + ), +) +BearerDep = Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)] + + +def header_value(credentials: HTTPAuthorizationCredentials | None) -> str | None: + """Значение заголовка `Authorization` как строка — для `parse_bearer` с прежними правилами (три части → 401).""" + if credentials is None: + return None + return f"{credentials.scheme} {credentials.credentials}" + + def parse_bearer(authorization: str | None) -> str: """Токен из значения заголовка `Authorization: Bearer `; иначе 401 (схема без учёта регистра).""" if authorization is not None: @@ -84,10 +106,10 @@ def get_token_verifier(request: Request) -> TokenVerifier | None: async def verified_claims( verifier: Annotated[TokenVerifier | None, Depends(get_token_verifier)], - authorization: Annotated[str | None, Header()] = None, + credentials: BearerDep = None, ) -> Claims: """Claims проверенного токена. Не обращается к БД: все 401 решаются до неё.""" - token = parse_bearer(authorization) + token = parse_bearer(header_value(credentials)) if verifier is None: raise _token_rejected("аутентификация не настроена") try: diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index c1f114b..b55c4f4 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -199,3 +199,30 @@ def test_counterparty_mapping_routes_declare_documented_responses(schema: dict[s assert mapping["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/MapCounterpartyOut") row = schema["components"]["schemas"]["MapCounterpartyOut"]["properties"]["row"] assert {"type": "null"} in row["anyOf"] + + +# --- Схема безопасности (issue #29): Swagger UI показывает Authorize только при securitySchemes в OpenAPI --- + +BEARER_SCHEME = "bearerAuth" +# Маршруты без аутентификации: выдача dev-токена и healthcheck +PUBLIC_PATHS = {"/api/v1/dev/token", "/healthcheck"} + + +def test_openapi_declares_bearer_security_scheme(schema: dict[str, Any]) -> None: + schemes = schema.get("components", {}).get("securitySchemes", {}) + bearer = schemes.get(BEARER_SCHEME, {}) + assert (bearer.get("type"), bearer.get("scheme"), bearer.get("bearerFormat")) == ("http", "bearer", "JWT"), schemes + assert "Authorize" in bearer.get("description", "") # подсказка для Swagger UI + + +def test_protected_routes_require_bearer_and_public_do_not(schema: dict[str, Any]) -> None: + missing: list[str] = [] + for path, methods in schema["paths"].items(): + for method, operation in methods.items(): + requirements = operation.get("security", []) + has_bearer = any(BEARER_SCHEME in requirement for requirement in requirements) + if path in PUBLIC_PATHS: + assert not has_bearer, f"{method.upper()} {path} — публичный маршрут помечен security" + elif not has_bearer: + missing.append(f"{method.upper()} {path}") + assert missing == [], f"маршруты без схемы {BEARER_SCHEME} (Authorize в Swagger не подставит токен): {missing}"