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>
56 lines
1.7 KiB
Python
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()
|