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
+1 -1
View File
@@ -1 +1 @@
from . import register, register_2fa, login, login_2fa, logout
from . import register, register_2fa, login, login_2fa, logout, forgot_password, reset_password
+29
View File
@@ -0,0 +1,29 @@
from flask import current_app, redirect, render_template, request, session, url_for
import auth
from core.flask_app import app
@app.route("/forgot-password", methods=["GET", "POST"])
def forgot_password():
if session.get("user_id"):
return redirect(url_for("index"))
sent = False
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
user = auth.get_user_by_email(email)
if user:
token = auth.create_password_reset_token(user["id"])
reset_url = url_for("reset_password", token=token, _external=True)
try:
auth.send_password_reset_email(user["email"], reset_url)
except auth.EmailNotConfiguredError:
# Jamais révélé à l'utilisateur (voir le message générique
# ci-dessous) : seul l'admin qui lit les logs serveur doit
# savoir que le SMTP n'est pas encore configuré.
current_app.logger.exception("Email de réinitialisation non envoyé : SMTP non configuré.")
# Même message que le compte existe ou non, pour ne jamais
# permettre de deviner quelles adresses sont déjà inscrites.
sent = True
return render_template("auth/forgot_password.html", sent=sent)
+29
View File
@@ -0,0 +1,29 @@
from flask import redirect, render_template, request, url_for
import auth
from core.flask_app import app
@app.route("/reset-password/<token>", methods=["GET", "POST"])
def reset_password(token):
user_id = auth.get_user_id_for_valid_token(token)
if not user_id:
return render_template("auth/reset_password.html", invalid=True)
error = None
if request.method == "POST":
password = request.form.get("password", "")
password_confirm = request.form.get("password_confirm", "")
if password != password_confirm:
error = "Les mots de passe ne correspondent pas."
elif not auth.password_strength(password)["valid"]:
error = "Mot de passe trop faible — respecte les critères affichés."
else:
auth.set_password(user_id, password)
auth.consume_password_reset_token(token)
return redirect(url_for("login"))
return render_template(
"auth/reset_password.html", invalid=False, error=error,
min_score_required=auth.MIN_SCORE_REQUIRED,
)