eptm_dashboard/src/password_tokens.py
Julien Balet 43a2196150 auth: flow email pour mdp + page profil + restriction d'accès par classe
- création de compte par admin envoie un email avec lien de définition (7j),
  bouton "Reset mdp" pour renvoyer un lien (24h). Plus aucun admin ne peut
  modifier directement le mdp d'un user (tout passe par les liens email).
- nouvelle page /password-set publique (validation token, formulaire, hash
  bcrypt) au style aligné sur /login, avec emails multipart texte+HTML.
- nouvelle page /profile (changement mdp avec ancien, reset 2FA, avatar,
  infos), accessible via dropdown sur le widget user en bas de sidebar.
- restriction d'accès par utilisateur : champ allowed_classes dans auth.yaml,
  multi-select dans la page Users, filtrage cross-page (KPIs, sanctions,
  classes, apprentis, navigations cross-page, génération PDF avis).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:52:10 +02:00

126 lines
3.4 KiB
Python

"""Tokens à usage unique pour la définition / réinitialisation de mot de passe.
Stockage : data/password_tokens.json (JSON)
Format :
{
"<token>": {
"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)