- 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>
126 lines
3.4 KiB
Python
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)
|