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__. 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