Files
lifetrack/apps/api/app/core/security.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
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>
2026-08-14 10:48:57 +02:00

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