Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et finances personnelles avec import de relevés bancaires. Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS + Vite + Tailwind + ECharts, déploiement Docker Compose. Modules auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework de connecteurs à deux voies (importeurs de fichiers + ingestion JSON) pour brancher de nouvelles sources sans toucher au noyau. Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16 (28 tables, SPA servie par nginx, wizard de premier démarrage). Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les documents de conception et de recherche dans docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
82 lines
2.8 KiB
Python
82 lines
2.8 KiB
Python
from collections.abc import Callable
|
|
from typing import TYPE_CHECKING, Annotated
|
|
|
|
from fastapi import Depends, Header
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.database import get_db
|
|
from app.core.errors import ForbiddenError, UnauthorizedError
|
|
from app.core.security import decode_access_token, verify_device_key
|
|
|
|
if TYPE_CHECKING:
|
|
from app.modules.auth.models import User
|
|
|
|
bearer = HTTPBearer(auto_error=False)
|
|
|
|
INGEST_WILDCARD_SCOPE = "ingest:*"
|
|
|
|
|
|
def get_current_user(
|
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
|
|
db: Annotated[Session, Depends(get_db)],
|
|
) -> "User":
|
|
"""JWT Bearer -> User. Raises UnauthorizedError (401) otherwise."""
|
|
from app.modules.auth.models import User
|
|
|
|
if credentials is None:
|
|
raise UnauthorizedError("Authentification requise.")
|
|
user_id = decode_access_token(credentials.credentials)
|
|
user = db.get(User, user_id)
|
|
if user is None or not user.is_active:
|
|
raise UnauthorizedError("Session invalide ou expirée.")
|
|
return user
|
|
|
|
|
|
def authenticate_ingest(
|
|
db: Session,
|
|
credentials: HTTPAuthorizationCredentials | None,
|
|
api_key: str | None,
|
|
required_scope: str,
|
|
) -> "User":
|
|
"""Shared ingest authentication: JWT Bearer OR X-API-Key device key.
|
|
|
|
A device key must hold `required_scope` (ex: "ingest:health") or the
|
|
wildcard "ingest:*". Raises 401/403 accordingly.
|
|
"""
|
|
from app.modules.auth.models import User
|
|
|
|
if api_key:
|
|
key = verify_device_key(db, api_key)
|
|
scopes = key.scopes or []
|
|
if required_scope not in scopes and INGEST_WILDCARD_SCOPE not in scopes:
|
|
raise ForbiddenError(
|
|
"Cette clé d'appareil ne dispose pas du droit requis.",
|
|
details={"required_scope": required_scope},
|
|
)
|
|
user = db.get(User, key.user_id)
|
|
if user is None or not user.is_active:
|
|
raise UnauthorizedError("Clé d'appareil invalide.")
|
|
return user
|
|
if credentials is not None:
|
|
return get_current_user(credentials, db)
|
|
raise UnauthorizedError("Authentification requise.")
|
|
|
|
|
|
def get_ingest_identity(required_scope: str) -> Callable[..., "User"]:
|
|
"""Factory dependency for ingest endpoints.
|
|
|
|
Accepts EITHER:
|
|
- Authorization: Bearer <jwt> (interactive user), OR
|
|
- X-API-Key: ltk_... (device key)
|
|
"""
|
|
|
|
def dependency(
|
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
|
|
db: Annotated[Session, Depends(get_db)],
|
|
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
|
|
) -> "User":
|
|
return authenticate_ingest(db, credentials, x_api_key, required_scope)
|
|
|
|
return dependency
|