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>
This commit is contained in:
william
2026-08-28 20:50:49 +02:00
co-authored by Claude Sonnet 5
parent 9efe119936
commit d5a84413d1
15 changed files with 418 additions and 2 deletions
+9
View File
@@ -20,6 +20,12 @@ from .rate_limit import lockout_minutes_for, seconds_locked_remaining, lockout_m
from .record_failed_attempt import record_failed_attempt
from .reset_failed_attempts import reset_failed_attempts
from .recovery_codes import generate_recovery_codes, verify_recovery_code
from .set_password import set_password
from .password_reset import (
create_password_reset_token, get_user_id_for_valid_token, consume_password_reset_token,
TOKEN_TTL_MINUTES,
)
from .send_email import send_password_reset_email, EmailNotConfiguredError
__all__ = [
"users_db_path", "ensure_users_schema", "is_first_user",
@@ -31,4 +37,7 @@ __all__ = [
"lockout_minutes_for", "seconds_locked_remaining", "lockout_message",
"record_failed_attempt", "reset_failed_attempts",
"generate_recovery_codes", "verify_recovery_code",
"set_password", "create_password_reset_token", "get_user_id_for_valid_token",
"consume_password_reset_token", "TOKEN_TTL_MINUTES",
"send_password_reset_email", "EmailNotConfiguredError",
]
+17
View File
@@ -44,5 +44,22 @@ def ensure_users_schema():
);
"""
)
# Jetons de réinitialisation de mot de passe (voir password_reset.py) :
# seul leur hash (SHA-256, un jeton aléatoire à haute entropie n'a pas
# besoin d'un hash de mot de passe volontairement lent) est stocké, un
# jeton envoyé par email reste donc inutilisable même en cas de fuite
# de cette base.
conn.execute(
"""
CREATE TABLE IF NOT EXISTS _password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
conn.commit()
conn.close()
+55
View File
@@ -0,0 +1,55 @@
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()
+52
View File
@@ -0,0 +1,52 @@
"""Envoi d'email via LE SERVEUR SMTP DE L'UTILISATEUR (Mailcow ou autre) —
aucune dépendance supplémentaire, smtplib est dans la bibliothèque standard
Python. Configuré uniquement par variables d'environnement (SMTP_HOST,
SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM) : ce module ne connaît et
ne stocke jamais le mot de passe SMTP en dur, à définir sur le poste/
serveur qui fait tourner l'appli."""
import os
import smtplib
from email.mime.text import MIMEText
from .password_reset import TOKEN_TTL_MINUTES
class EmailNotConfiguredError(Exception):
"""Levée si les variables d'environnement SMTP_* ne sont pas toutes
définies — jamais renvoyée telle quelle à l'utilisateur (voir
routes/auth/forgot_password.py, qui affiche toujours le même message
générique), seulement journalisée côté serveur pour que l'admin sache
qu'il doit finir de configurer son SMTP."""
def send_email(to_email, subject, body):
host = os.environ.get("SMTP_HOST")
port = int(os.environ.get("SMTP_PORT", "587"))
user = os.environ.get("SMTP_USER")
password = os.environ.get("SMTP_PASSWORD")
sender = os.environ.get("SMTP_FROM") or user
if not host or not user or not password or not sender:
raise EmailNotConfiguredError(
"SMTP_HOST / SMTP_USER / SMTP_PASSWORD / SMTP_FROM doivent être "
"définis en variables d'environnement pour envoyer des emails."
)
msg = MIMEText(body)
msg["Subject"] = subject
msg["From"] = sender
msg["To"] = to_email
with smtplib.SMTP(host, port, timeout=10) as server:
server.starttls()
server.login(user, password)
server.sendmail(sender, [to_email], msg.as_string())
def send_password_reset_email(to_email, reset_url):
body = (
"Une réinitialisation de mot de passe a été demandée pour ce compte "
"Forge Engine.\n\n"
f"Clique sur ce lien pour choisir un nouveau mot de passe "
f"(valable {TOKEN_TTL_MINUTES} minutes) :\n{reset_url}\n\n"
"Si tu n'es pas à l'origine de cette demande, ignore cet email : "
"ton mot de passe actuel reste inchangé."
)
send_email(to_email, "Réinitialisation de ton mot de passe — Forge Engine", body)
+17
View File
@@ -0,0 +1,17 @@
from werkzeug.security import generate_password_hash
from .connection import connect
def set_password(user_id, new_password):
"""Remet aussi le compteur anti-bruteforce à zéro (auth/rate_limit.py) :
prouver son identité par email est une voie de récupération légitime,
un compte verrouillé après trop d'échecs ne doit pas rester bloqué une
fois le mot de passe changé pour de vrai."""
conn = connect()
conn.execute(
"UPDATE _users SET password_hash = ?, failed_attempts = 0, locked_until = NULL WHERE id = ?",
(generate_password_hash(new_password), user_id),
)
conn.commit()
conn.close()