Files
Forge-Engine/auth/password_reset.py
T
williamandClaude Sonnet 5 d5a84413d1 Ajoute la réinitialisation de mot de passe par email (SMTP)
Un lien "Mot de passe oublié ?" (login.html) mène à /forgot-password :
si l'adresse saisie correspond à un compte, un jeton à haute entropie
(secrets.token_urlsafe, valable 1h) est généré et envoyé par email via
smtplib (auth/send_email.py, aucune dépendance ajoutée) — configuré
uniquement par variables d'environnement (SMTP_HOST/PORT/USER/PASSWORD/
FROM, voir .env.example et docker-compose.prod.yml), n'importe quel
serveur SMTP existant convient (Mailcow compris).

Seul le hash SHA-256 du jeton est stocké (auth/password_reset.py, table
_password_reset_tokens) : un jeton envoyé par email reste inutilisable
même en cas de fuite de la base. Le même message générique s'affiche que
l'adresse corresponde à un compte ou non, pour ne jamais permettre à ce
formulaire de servir à deviner quelles adresses sont déjà inscrites. Un
échec d'envoi (SMTP non configuré) est journalisé côté serveur seulement,
jamais révélé à l'utilisateur.

/reset-password/<token> vérifie le jeton (non expiré, non déjà utilisé),
applique les mêmes règles de mot de passe fort qu'à l'inscription (même
schéma visuel), puis consomme le jeton et remet à zéro le compteur
anti-bruteforce du compte (auth/set_password.py) — une identité prouvée
par email est une voie de récupération légitime même pour un compte
verrouillé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 20:50:49 +02:00

56 lines
1.7 KiB
Python

import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from .connection import connect
TOKEN_TTL_MINUTES = 60
def _hash_token(token):
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def create_password_reset_token(user_id):
"""Un seul jeton valide à la fois par utilisateur : en créer un
nouveau invalide silencieusement tout jeu envoyé plus tôt (par
exemple si l'utilisateur redemande un email parce que le premier
s'est perdu), pour ne jamais avoir deux liens valides en même temps."""
token = secrets.token_urlsafe(32)
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=TOKEN_TTL_MINUTES)).isoformat()
conn = connect()
conn.execute("DELETE FROM _password_reset_tokens WHERE user_id = ?", (user_id,))
conn.execute(
"INSERT INTO _password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)",
(user_id, _hash_token(token), expires_at),
)
conn.commit()
conn.close()
return token
def get_user_id_for_valid_token(token):
if not token:
return None
conn = connect()
row = conn.execute(
"SELECT user_id, expires_at, used_at FROM _password_reset_tokens WHERE token_hash = ?",
(_hash_token(token),),
).fetchone()
conn.close()
if not row or row["used_at"]:
return None
if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc):
return None
return row["user_id"]
def consume_password_reset_token(token):
conn = connect()
conn.execute(
"UPDATE _password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE token_hash = ?",
(_hash_token(token),),
)
conn.commit()
conn.close()