Merge pull request 'fix(auth): схема bearerAuth в OpenAPI — кнопка Authorize в Swagger UI' (#30) from fix/29-swagger-bearer-scheme into main
Reviewed-on: #30
This commit is contained in:
commit
1d7f2c8176
@ -18,7 +18,8 @@ import uuid
|
|||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Annotated
|
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.api.dependencies.db import SessionDep
|
||||||
from src.common.exceptions import AuthenticationFailedError
|
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:
|
def parse_bearer(authorization: str | None) -> str:
|
||||||
"""Токен из значения заголовка `Authorization: Bearer <jwt>`; иначе 401 (схема без учёта регистра)."""
|
"""Токен из значения заголовка `Authorization: Bearer <jwt>`; иначе 401 (схема без учёта регистра)."""
|
||||||
if authorization is not None:
|
if authorization is not None:
|
||||||
@ -84,10 +106,10 @@ def get_token_verifier(request: Request) -> TokenVerifier | None:
|
|||||||
|
|
||||||
async def verified_claims(
|
async def verified_claims(
|
||||||
verifier: Annotated[TokenVerifier | None, Depends(get_token_verifier)],
|
verifier: Annotated[TokenVerifier | None, Depends(get_token_verifier)],
|
||||||
authorization: Annotated[str | None, Header()] = None,
|
credentials: BearerDep = None,
|
||||||
) -> Claims:
|
) -> Claims:
|
||||||
"""Claims проверенного токена. Не обращается к БД: все 401 решаются до неё."""
|
"""Claims проверенного токена. Не обращается к БД: все 401 решаются до неё."""
|
||||||
token = parse_bearer(authorization)
|
token = parse_bearer(header_value(credentials))
|
||||||
if verifier is None:
|
if verifier is None:
|
||||||
raise _token_rejected("аутентификация не настроена")
|
raise _token_rejected("аутентификация не настроена")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -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")
|
assert mapping["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/MapCounterpartyOut")
|
||||||
row = schema["components"]["schemas"]["MapCounterpartyOut"]["properties"]["row"]
|
row = schema["components"]["schemas"]["MapCounterpartyOut"]["properties"]["row"]
|
||||||
assert {"type": "null"} in row["anyOf"]
|
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}"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user