From e67a6a9f5f0944e535a3571f1ddf7c80bd0153dc Mon Sep 17 00:00:00 2001 From: Egor Stolbov Date: Sun, 17 May 2026 18:16:10 +0300 Subject: [PATCH] feat: supertoken for microservices --- example.env | 3 ++ src/dependencies.py | 85 ++++++++++++++++++++++++++++++++++++++++---- src/routers/zones.py | 21 +++-------- 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/example.env b/example.env index 914ff6e..a45d73a 100644 --- a/example.env +++ b/example.env @@ -12,6 +12,9 @@ POSTGRES_TEST_DB=cars_test_db DATABASE_HOST_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432" DATABASE_URL="${DATABASE_HOST_URL}/${POSTGRES_DB}?sslmode=disable" +# Super token for trusted services. Send it in the api_token request header. +#API_TOKEN=change-me + # Password reset # If SMTP_HOST is set, reset tokens are sent by email. # Without SMTP, the API returns reset_token by default for local testing. diff --git a/src/dependencies.py b/src/dependencies.py index 530c845..bf2c6e3 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -1,6 +1,7 @@ """ Зависимости FastAPI: - - get_current_user — декодирует JWT, возвращает User из БД + - get_current_user — принимает API_TOKEN из заголовка api_token + или декодирует JWT, возвращает User из БД - require — фабрика зависимостей для проверки permissions - BASE_USER_PERMISSIONS — хардкод прав для роли 'user' """ @@ -8,11 +9,12 @@ from __future__ import annotations import os +import secrets from datetime import datetime, timedelta, timezone from typing import Annotated import jwt -from fastapi import Depends, HTTPException, status +from fastapi import Depends, Header, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from passlib.context import CryptContext from sqlalchemy.orm import Session @@ -27,6 +29,9 @@ JWT_SECRET: str = os.environ.get("JWT_SECRET", "CHANGE_ME_IN_PRODUCTION") JWT_ALGORITHM: str = "HS256" JWT_EXPIRE_SECONDS: int = int(os.environ.get("JWT_EXPIRE_SECONDS", 86400)) # 24 ч +API_TOKEN_HEADER_NAME = "api_token" +API_TOKEN_USER_EMAIL = "api-token@parktrack.local" +_API_TOKEN_AUTH_ATTR = "_authenticated_via_api_token" # --------------------------------------------------------------------------- # Хэширование паролей @@ -176,6 +181,55 @@ def decode_access_token(token: str) -> int: }), } +API_TOKEN_PERMISSIONS: frozenset[str] = frozenset({ + *BASE_ADMIN_PERMISSIONS, + *(permission for permissions in PARTNER_ROLE_PERMISSIONS.values() for permission in permissions), + "forecasts.write", + "forecasts.delete", + "occupancy.write", + "occupancy.delete", +}) + + +def _configured_api_token() -> str | None: + token = os.getenv("API_TOKEN") + return token if token else None + + +def _api_token_matches(candidate: str | None) -> bool: + token = _configured_api_token() + return bool(token and candidate and secrets.compare_digest(candidate, token)) + + +def _mark_api_token_authenticated(user: User) -> User: + setattr(user, _API_TOKEN_AUTH_ATTR, True) + return user + + +def is_api_token_authenticated(user: User) -> bool: + return getattr(user, _API_TOKEN_AUTH_ATTR, False) is True + + +def _get_api_token_user(db: Session) -> User: + user = db.query(User).filter(User.email == API_TOKEN_USER_EMAIL).one_or_none() + if user is None: + user = User( + email=API_TOKEN_USER_EMAIL, + hashed_password=hash_password(secrets.token_urlsafe(48)), + full_name="API Token", + global_role=GlobalRole.admin, + is_active=False, + ) + db.add(user) + db.commit() + db.refresh(user) + elif user.is_active or user.global_role != GlobalRole.admin: + user.is_active = False + user.global_role = GlobalRole.admin + db.commit() + db.refresh(user) + return _mark_api_token_authenticated(user) + def get_membership_permissions(membership: PartnerMembership) -> set[str]: return set(PARTNER_ROLE_PERMISSIONS.get(membership.user_role, frozenset())) @@ -186,6 +240,9 @@ def get_effective_permissions(user: User) -> set[str]: Возвращает эффективный набор прав пользователя: базовые (по роли) + дополнительные из user_permissions + права партнёрских ролей. """ + if is_api_token_authenticated(user): + return set(API_TOKEN_PERMISSIONS) + base = BASE_ADMIN_PERMISSIONS if user.global_role == GlobalRole.admin else BASE_USER_PERMISSIONS extra = {p.permission for p in user.permissions} partner_permissions: set[str] = set() @@ -203,11 +260,15 @@ def get_effective_permissions(user: User) -> set[str]: _bearer_scheme = HTTPBearer(auto_error=False) -def get_current_user( - credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer_scheme)], - db: Annotated[Session, Depends(get_db)], +def resolve_current_user( + credentials: HTTPAuthorizationCredentials | None, + db: Session, + api_token: str | None = None, ) -> User: - """Обязательная авторизация. Бросает 401, если токен отсутствует или невалиден.""" + """Обязательная авторизация по API_TOKEN или JWT.""" + if _api_token_matches(api_token): + return _get_api_token_user(db) + if credentials is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -223,6 +284,15 @@ def get_current_user( return user +def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer_scheme)], + db: Annotated[Session, Depends(get_db)], + api_token: Annotated[str | None, Header(alias=API_TOKEN_HEADER_NAME)] = None, +) -> User: + """Обязательная авторизация. Бросает 401, если токен отсутствует или невалиден.""" + return resolve_current_user(credentials=credentials, db=db, api_token=api_token) + + CurrentUser = Annotated[User, Depends(get_current_user)] @@ -239,6 +309,9 @@ def handler(user: Annotated[User, Depends(require("cameras.view"))]): def _dependency( current_user: CurrentUser, ) -> User: + if is_api_token_authenticated(current_user): + return current_user + effective = get_effective_permissions(current_user) missing = [p for p in permissions if p not in effective] if missing: diff --git a/src/routers/zones.py b/src/routers/zones.py index 30c126f..7e617f1 100644 --- a/src/routers/zones.py +++ b/src/routers/zones.py @@ -3,14 +3,14 @@ from datetime import datetime, timezone from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, Header, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import text from sqlalchemy.orm import Session from ..database import get_db from ..db_models import Camera, ParkingZone, Partner, User -from ..dependencies import CurrentUser, get_current_user, require +from ..dependencies import API_TOKEN_HEADER_NAME, get_effective_permissions, require, resolve_current_user from ..schemas.zones import ( CreateZoneRequest, UpdateZoneRequest, @@ -102,21 +102,10 @@ def list_zones( top: int = 100, offset: int = 0, credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(HTTPBearer(auto_error=False))] = None, + api_token: Annotated[str | None, Header(alias=API_TOKEN_HEADER_NAME)] = None, ): if view != "map": - if credentials is None: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - detail={"error_description": "Missing or invalid access token"}, - ) - from ..dependencies import decode_access_token, get_effective_permissions - user_id = decode_access_token(credentials.credentials) - current_user = db.query(User).filter(User.user_id == user_id).one_or_none() - if current_user is None or not current_user.is_active: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - detail={"error_description": "User not found or inactive"}, - ) + current_user = resolve_current_user(credentials=credentials, db=db, api_token=api_token) if "zones.view" not in get_effective_permissions(current_user): raise HTTPException( status.HTTP_403_FORBIDDEN, @@ -242,4 +231,4 @@ def delete_zone( zone = _get_zone_or_404(db, zone_id) db.delete(zone) db.commit() - return None \ No newline at end of file + return None