"""Tokens à usage unique pour la définition / réinitialisation de mot de passe. Stockage : data/password_tokens.json (JSON) Format : { "": { "username": "prof.demo", "type": "set" | "reset", "created_at": "2026-05-10T18:30:00", "expires_at": "2026-05-17T18:30:00" } } """ from __future__ import annotations import json import os import secrets from datetime import datetime, timedelta from pathlib import Path from typing import Optional _ROOT = Path(__file__).resolve().parent.parent _DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data"))) TOKENS_FILE = _DATA_DIR / "password_tokens.json" # Durées de validité TTL_SET = timedelta(days=7) # Création de compte TTL_RESET = timedelta(hours=24) # Réinitialisation def _load() -> dict: if not TOKENS_FILE.exists(): return {} try: return json.loads(TOKENS_FILE.read_text(encoding="utf-8")) except Exception: return {} def _save(data: dict) -> None: TOKENS_FILE.parent.mkdir(parents=True, exist_ok=True) TOKENS_FILE.write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" ) def _purge_expired(data: dict) -> dict: """Retourne une nouvelle dict sans les tokens expirés.""" now = datetime.now() out = {} for tok, info in data.items(): try: exp = datetime.fromisoformat(info["expires_at"]) except Exception: continue if exp > now: out[tok] = info return out def _purge_for_user(data: dict, username: str) -> dict: """Supprime tous les tokens existants pour un utilisateur.""" return {t: i for t, i in data.items() if i.get("username") != username} def create_token(username: str, kind: str = "set") -> tuple[str, datetime]: """Crée un nouveau token. Tout token précédent du même utilisateur est révoqué. Renvoie (token_str, expires_at). """ if kind not in ("set", "reset"): raise ValueError(f"Unknown token kind: {kind!r}") token = secrets.token_urlsafe(32) now = datetime.now() expires_at = now + (TTL_SET if kind == "set" else TTL_RESET) data = _load() data = _purge_expired(data) data = _purge_for_user(data, username) data[token] = { "username": username, "type": kind, "created_at": now.isoformat(timespec="seconds"), "expires_at": expires_at.isoformat(timespec="seconds"), } _save(data) return token, expires_at def validate_token(token: str) -> Optional[dict]: """Renvoie {username, type, expires_at} si le token est valide, sinon None.""" if not token: return None data = _load() info = data.get(token) if not info: return None try: exp = datetime.fromisoformat(info["expires_at"]) except Exception: return None if exp <= datetime.now(): # Token expiré : on en profite pour purger data = _purge_expired(data) _save(data) return None return info def consume_token(token: str) -> None: """Supprime le token (après usage).""" data = _load() if token in data: del data[token] _save(data) def revoke_all_for_user(username: str) -> None: """Révoque tous les tokens d'un utilisateur (par exemple lors de la suppression).""" data = _load() data = _purge_for_user(data, username) _save(data)