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>
91 lines
2.8 KiB
Python
91 lines
2.8 KiB
Python
import hashlib
|
|
import hmac
|
|
import secrets
|
|
from datetime import timedelta
|
|
from typing import TYPE_CHECKING
|
|
|
|
import jwt
|
|
from pwdlib import PasswordHash
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.errors import UnauthorizedError
|
|
from app.core.timeutils import utcnow
|
|
|
|
if TYPE_CHECKING:
|
|
from app.modules.auth.models import DeviceApiKey
|
|
|
|
password_hasher = PasswordHash.recommended()
|
|
|
|
DEVICE_KEY_PREFIX = "ltk"
|
|
|
|
|
|
def hash_password(password: str) -> str:
|
|
return password_hasher.hash(password)
|
|
|
|
|
|
def verify_password(password: str, password_hash: str) -> bool:
|
|
return password_hasher.verify(password, password_hash)
|
|
|
|
|
|
def create_access_token(user_id: int) -> str:
|
|
settings = get_settings()
|
|
now = utcnow()
|
|
payload = {
|
|
"sub": str(user_id),
|
|
"iat": now,
|
|
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
|
|
}
|
|
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
|
|
|
|
|
def decode_access_token(token: str) -> int:
|
|
settings = get_settings()
|
|
try:
|
|
payload = jwt.decode(
|
|
token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
|
|
)
|
|
return int(payload["sub"])
|
|
except (jwt.PyJWTError, KeyError, ValueError) as exc:
|
|
raise UnauthorizedError("Session invalide ou expirée.") from exc
|
|
|
|
|
|
def generate_device_key() -> tuple[str, str, str]:
|
|
"""Return (full_key, prefix, key_hash) for a new device API key.
|
|
|
|
Format: ltk_<prefix>_<secret>. Only the sha256 of the full key is stored;
|
|
the plaintext key is shown to the user exactly once.
|
|
"""
|
|
prefix = secrets.token_hex(4) # 8 hex chars
|
|
secret = secrets.token_urlsafe(32)
|
|
full_key = f"{DEVICE_KEY_PREFIX}_{prefix}_{secret}"
|
|
key_hash = hashlib.sha256(full_key.encode("utf-8")).hexdigest()
|
|
return full_key, prefix, key_hash
|
|
|
|
|
|
def verify_device_key(db: Session, full_key: str) -> "DeviceApiKey":
|
|
"""Validate an X-API-Key value and return its DeviceApiKey row.
|
|
|
|
Lookup by prefix, constant-time hash comparison, rejects revoked keys,
|
|
updates last_used_at. Raises UnauthorizedError on any failure.
|
|
"""
|
|
from app.modules.auth.models import DeviceApiKey
|
|
|
|
invalid = UnauthorizedError("Clé d'appareil invalide.")
|
|
parts = full_key.split("_", 2)
|
|
if len(parts) != 3 or parts[0] != DEVICE_KEY_PREFIX:
|
|
raise invalid
|
|
prefix = parts[1]
|
|
key = db.scalar(select(DeviceApiKey).where(DeviceApiKey.key_prefix == prefix))
|
|
if key is None:
|
|
raise invalid
|
|
candidate_hash = hashlib.sha256(full_key.encode("utf-8")).hexdigest()
|
|
if not hmac.compare_digest(candidate_hash, key.key_hash):
|
|
raise invalid
|
|
if key.revoked_at is not None:
|
|
raise UnauthorizedError("Clé d'appareil révoquée.")
|
|
key.last_used_at = utcnow()
|
|
db.commit()
|
|
return key
|