diff --git a/app/webapi/app.py b/app/webapi/app.py index 16411197..dd3249f2 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -28,6 +28,7 @@ def create_web_api_app() -> FastAPI: docs_url=docs_config.get("docs_url"), redoc_url=docs_config.get("redoc_url"), openapi_url=docs_config.get("openapi_url"), + swagger_ui_parameters={"persistAuthorization": True}, ) allowed_origins = settings.get_web_api_allowed_origins() diff --git a/app/webapi/dependencies.py b/app/webapi/dependencies.py index 67f876bf..2d2ea1a0 100644 --- a/app/webapi/dependencies.py +++ b/app/webapi/dependencies.py @@ -2,8 +2,8 @@ from __future__ import annotations from typing import AsyncGenerator -from fastapi import Depends, HTTPException, Request, status -from fastapi.security.utils import get_authorization_scheme_param +from fastapi import Depends, HTTPException, Request, Security, status +from fastapi.security import APIKeyHeader from sqlalchemy.ext.asyncio import AsyncSession from app.database.database import AsyncSessionLocal @@ -11,6 +11,9 @@ from app.database.models import WebApiToken from app.services.web_api_token_service import web_api_token_service +api_key_header_scheme = APIKeyHeader(name="X-API-Key", auto_error=False) + + async def get_db_session() -> AsyncGenerator[AsyncSession, None]: async with AsyncSessionLocal() as session: try: @@ -21,15 +24,17 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]: async def require_api_token( request: Request, + api_key_header: str | None = Security(api_key_header_scheme), db: AsyncSession = Depends(get_db_session), ) -> WebApiToken: - api_key = request.headers.get("X-API-Key") + api_key = api_key_header if not api_key: authorization = request.headers.get("Authorization") - scheme, param = get_authorization_scheme_param(authorization) - if scheme.lower() == "bearer" and param: - api_key = param + if authorization: + scheme, _, credentials = authorization.partition(" ") + if scheme.lower() == "bearer" and credentials: + api_key = credentials if not api_key: raise HTTPException( diff --git a/app/webapi/routes/config.py b/app/webapi/routes/config.py index 6d4ec8fa..92869266 100644 --- a/app/webapi/routes/config.py +++ b/app/webapi/routes/config.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any, Optional -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy.ext.asyncio import AsyncSession from app.services.system_settings_service import bot_configuration_service @@ -100,7 +100,7 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe @router.get("/categories", response_model=list[SettingCategorySummary]) async def list_categories( - _: object = Depends(require_api_token), + _: object = Security(require_api_token), ) -> list[SettingCategorySummary]: categories = bot_configuration_service.get_categories() return [ @@ -111,7 +111,7 @@ async def list_categories( @router.get("", response_model=list[SettingDefinition]) async def list_settings( - _: object = Depends(require_api_token), + _: object = Security(require_api_token), category: Optional[str] = Query(default=None, alias="category_key"), ) -> list[SettingDefinition]: items: list[SettingDefinition] = [] @@ -130,7 +130,7 @@ async def list_settings( @router.get("/{key}", response_model=SettingDefinition) async def get_setting( key: str, - _: object = Depends(require_api_token), + _: object = Security(require_api_token), ) -> SettingDefinition: try: definition = bot_configuration_service.get_definition(key) @@ -144,7 +144,7 @@ async def get_setting( async def update_setting( key: str, payload: SettingUpdateRequest, - _: object = Depends(require_api_token), + _: object = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> SettingDefinition: try: @@ -162,7 +162,7 @@ async def update_setting( @router.delete("/{key}", response_model=SettingDefinition) async def reset_setting( key: str, - _: object = Depends(require_api_token), + _: object = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> SettingDefinition: try: diff --git a/app/webapi/routes/health.py b/app/webapi/routes/health.py index 2d459f8d..5415996a 100644 --- a/app/webapi/routes/health.py +++ b/app/webapi/routes/health.py @@ -1,6 +1,6 @@ from __future__ import annotations -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Security from app.config import settings from app.services.version_service import version_service @@ -12,7 +12,7 @@ router = APIRouter() @router.get("/health", tags=["health"], response_model=HealthCheckResponse) -async def health_check(_: object = Depends(require_api_token)) -> HealthCheckResponse: +async def health_check(_: object = Security(require_api_token)) -> HealthCheckResponse: return HealthCheckResponse( status="ok", api_version=settings.WEB_API_VERSION, diff --git a/app/webapi/routes/promo_groups.py b/app/webapi/routes/promo_groups.py index 4b4eb53d..98ac34bf 100644 --- a/app/webapi/routes/promo_groups.py +++ b/app/webapi/routes/promo_groups.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi import APIRouter, Depends, HTTPException, Response, Security, status from sqlalchemy.ext.asyncio import AsyncSession from app.database.crud.promo_group import ( @@ -56,7 +56,7 @@ def _serialize(group: PromoGroup, members_count: int = 0) -> PromoGroupResponse: @router.get("", response_model=list[PromoGroupResponse]) async def list_promo_groups( - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> list[PromoGroupResponse]: groups_with_counts = await get_promo_groups_with_counts(db) @@ -66,7 +66,7 @@ async def list_promo_groups( @router.get("/{group_id}", response_model=PromoGroupResponse) async def get_promo_group( group_id: int, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> PromoGroupResponse: group = await get_promo_group_by_id(db, group_id) @@ -80,7 +80,7 @@ async def get_promo_group( @router.post("", response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED) async def create_promo_group_endpoint( payload: PromoGroupCreateRequest, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> PromoGroupResponse: group = await create_promo_group( @@ -100,7 +100,7 @@ async def create_promo_group_endpoint( async def update_promo_group_endpoint( group_id: int, payload: PromoGroupUpdateRequest, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> PromoGroupResponse: group = await get_promo_group_by_id(db, group_id) @@ -125,7 +125,7 @@ async def update_promo_group_endpoint( @router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_promo_group_endpoint( group_id: int, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> Response: group = await get_promo_group_by_id(db, group_id) diff --git a/app/webapi/routes/stats.py b/app/webapi/routes/stats.py index 6902283b..150e01b6 100644 --- a/app/webapi/routes/stats.py +++ b/app/webapi/routes/stats.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Security from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -24,7 +24,7 @@ router = APIRouter() @router.get("/overview") async def stats_overview( - _: object = Depends(require_api_token), + _: object = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> dict[str, object]: total_users = await db.scalar(select(func.count()).select_from(User)) or 0 diff --git a/app/webapi/routes/subscriptions.py b/app/webapi/routes/subscriptions.py index f3117590..2ec77fe2 100644 --- a/app/webapi/routes/subscriptions.py +++ b/app/webapi/routes/subscriptions.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any, Optional -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -69,7 +69,7 @@ async def _get_subscription(db: AsyncSession, subscription_id: int) -> Subscript @router.get("", response_model=list[SubscriptionResponse]) async def list_subscriptions( - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), @@ -95,7 +95,7 @@ async def list_subscriptions( @router.get("/{subscription_id}", response_model=SubscriptionResponse) async def get_subscription( subscription_id: int, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> SubscriptionResponse: subscription = await _get_subscription(db, subscription_id) @@ -105,7 +105,7 @@ async def get_subscription( @router.post("", response_model=SubscriptionResponse, status_code=status.HTTP_201_CREATED) async def create_subscription( payload: SubscriptionCreateRequest, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> SubscriptionResponse: existing = await get_subscription_by_user_id(db, payload.user_id) @@ -141,7 +141,7 @@ async def create_subscription( async def extend_subscription_endpoint( subscription_id: int, payload: SubscriptionExtendRequest, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> SubscriptionResponse: subscription = await _get_subscription(db, subscription_id) @@ -154,7 +154,7 @@ async def extend_subscription_endpoint( async def add_subscription_traffic_endpoint( subscription_id: int, payload: SubscriptionTrafficRequest, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> SubscriptionResponse: subscription = await _get_subscription(db, subscription_id) @@ -167,7 +167,7 @@ async def add_subscription_traffic_endpoint( async def add_subscription_devices_endpoint( subscription_id: int, payload: SubscriptionDevicesRequest, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> SubscriptionResponse: subscription = await _get_subscription(db, subscription_id) @@ -180,7 +180,7 @@ async def add_subscription_devices_endpoint( async def add_subscription_squad_endpoint( subscription_id: int, payload: SubscriptionSquadRequest, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> SubscriptionResponse: if not payload.squad_uuid: @@ -196,7 +196,7 @@ async def add_subscription_squad_endpoint( async def remove_subscription_squad_endpoint( subscription_id: int, squad_uuid: str, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> SubscriptionResponse: subscription = await _get_subscription(db, subscription_id) diff --git a/app/webapi/routes/tickets.py b/app/webapi/routes/tickets.py index f6a6f24d..ec274bd3 100644 --- a/app/webapi/routes/tickets.py +++ b/app/webapi/routes/tickets.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime from typing import Any, Optional -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy.ext.asyncio import AsyncSession from app.database.crud.ticket import TicketCRUD @@ -56,7 +56,7 @@ def _serialize_ticket(ticket: Ticket, include_messages: bool = False) -> TicketR @router.get("", response_model=list[TicketResponse]) async def list_tickets( - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), @@ -89,7 +89,7 @@ async def list_tickets( @router.get("/{ticket_id}", response_model=TicketResponse) async def get_ticket( ticket_id: int, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> TicketResponse: ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False) @@ -102,7 +102,7 @@ async def get_ticket( async def update_ticket_status( ticket_id: int, payload: TicketStatusUpdateRequest, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> TicketResponse: try: @@ -123,7 +123,7 @@ async def update_ticket_status( async def update_ticket_priority( ticket_id: int, payload: TicketPriorityUpdateRequest, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> TicketResponse: allowed_priorities = {"low", "normal", "high", "urgent"} @@ -146,7 +146,7 @@ async def update_ticket_priority( async def update_reply_block( ticket_id: int, payload: TicketReplyBlockRequest, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> TicketResponse: until = payload.until @@ -169,7 +169,7 @@ async def update_reply_block( @router.delete("/{ticket_id}/reply-block", response_model=TicketResponse) async def clear_reply_block( ticket_id: int, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> TicketResponse: success = await TicketCRUD.set_user_reply_block( diff --git a/app/webapi/routes/tokens.py b/app/webapi/routes/tokens.py index 93429299..cef1fd91 100644 --- a/app/webapi/routes/tokens.py +++ b/app/webapi/routes/tokens.py @@ -1,6 +1,6 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi import APIRouter, Depends, HTTPException, Response, Security, status from sqlalchemy.ext.asyncio import AsyncSession from app.database.crud.web_api_token import ( @@ -35,7 +35,7 @@ def _serialize(token: WebApiToken) -> TokenResponse: @router.get("", response_model=list[TokenResponse]) async def get_tokens( - _: WebApiToken = Depends(require_api_token), + _: WebApiToken = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> list[TokenResponse]: tokens = await list_tokens(db, include_inactive=True) @@ -45,7 +45,7 @@ async def get_tokens( @router.post("", response_model=TokenCreateResponse, status_code=status.HTTP_201_CREATED) async def create_token( payload: TokenCreateRequest, - actor: WebApiToken = Depends(require_api_token), + actor: WebApiToken = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> TokenCreateResponse: token_value, token = await web_api_token_service.create_token( @@ -65,7 +65,7 @@ async def create_token( @router.post("/{token_id}/revoke", response_model=TokenResponse) async def revoke_token( token_id: int, - _: WebApiToken = Depends(require_api_token), + _: WebApiToken = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> TokenResponse: token = await get_token_by_id(db, token_id) @@ -80,7 +80,7 @@ async def revoke_token( @router.post("/{token_id}/activate", response_model=TokenResponse) async def activate_token( token_id: int, - _: WebApiToken = Depends(require_api_token), + _: WebApiToken = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> TokenResponse: token = await get_token_by_id(db, token_id) @@ -95,7 +95,7 @@ async def activate_token( @router.delete("/{token_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_token_endpoint( token_id: int, - _: WebApiToken = Depends(require_api_token), + _: WebApiToken = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> Response: token = await get_token_by_id(db, token_id) diff --git a/app/webapi/routes/transactions.py b/app/webapi/routes/transactions.py index c790de22..c00ebca1 100644 --- a/app/webapi/routes/transactions.py +++ b/app/webapi/routes/transactions.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime from typing import Any, Optional -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Security from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -33,7 +33,7 @@ def _serialize(transaction: Transaction) -> TransactionResponse: @router.get("", response_model=TransactionListResponse) async def list_transactions( - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py index 0c0b5abb..c2031719 100644 --- a/app/webapi/routes/users.py +++ b/app/webapi/routes/users.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any, Optional -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -111,7 +111,7 @@ def _apply_search_filter(query, search: str): @router.get("", response_model=UserListResponse) async def list_users( - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), @@ -155,7 +155,7 @@ async def list_users( @router.get("/{user_id}", response_model=UserResponse) async def get_user( user_id: int, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> UserResponse: user = await get_user_by_id(db, user_id) @@ -168,7 +168,7 @@ async def get_user( @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) async def create_user_endpoint( payload: UserCreateRequest, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> UserResponse: existing = await get_user_by_telegram_id(db, payload.telegram_id) @@ -199,7 +199,7 @@ async def create_user_endpoint( async def update_user_endpoint( user_id: int, payload: UserUpdateRequest, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> UserResponse: user = await get_user_by_id(db, user_id) @@ -252,7 +252,7 @@ async def update_user_endpoint( async def update_balance( user_id: int, payload: BalanceUpdateRequest, - _: Any = Depends(require_api_token), + _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> UserResponse: if payload.amount_kopeks == 0: