From d5a84413d1753e91dca0dbf31bab893be3900f1a Mon Sep 17 00:00:00 2001 From: william Date: Fri, 28 Aug 2026 20:50:49 +0200 Subject: [PATCH] =?UTF-8?q?Ajoute=20la=20r=C3=A9initialisation=20de=20mot?= =?UTF-8?q?=20de=20passe=20par=20email=20(SMTP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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 --- .env.example | 11 ++++ auth/__init__.py | 9 ++++ auth/ensure_schema.py | 17 ++++++ auth/password_reset.py | 55 ++++++++++++++++++++ auth/send_email.py | 52 +++++++++++++++++++ auth/set_password.py | 17 ++++++ core/auth_guard.py | 1 + docker-compose.prod.yml | 10 ++++ routes/auth/__init__.py | 2 +- routes/auth/forgot_password.py | 29 +++++++++++ routes/auth/reset_password.py | 29 +++++++++++ templates/auth/forgot_password.html | 26 ++++++++++ templates/auth/login.html | 3 +- templates/auth/reset_password.html | 79 ++++++++++++++++++++++++++++ tests/test_password_reset.py | 80 +++++++++++++++++++++++++++++ 15 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 auth/password_reset.py create mode 100644 auth/send_email.py create mode 100644 auth/set_password.py create mode 100644 routes/auth/forgot_password.py create mode 100644 routes/auth/reset_password.py create mode 100644 templates/auth/forgot_password.html create mode 100644 templates/auth/reset_password.html create mode 100644 tests/test_password_reset.py diff --git a/.env.example b/.env.example index d2e76925..398b04c1 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,14 @@ IMAGE_TAG=latest # Port exposé sur l'hôte de production PORT=5050 + +# Réinitialisation de mot de passe par email (auth/send_email.py) — un +# serveur SMTP existant (ex. Mailcow) suffit, aucun service tiers requis. +# Laisser vide désactive l'envoi (le lien de réinitialisation n'est alors +# jamais transmis à l'utilisateur, seule une erreur est journalisée côté +# serveur — jamais montrée telle quelle à l'utilisateur). +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= diff --git a/auth/__init__.py b/auth/__init__.py index f4c33669..d8bf8e69 100644 --- a/auth/__init__.py +++ b/auth/__init__.py @@ -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", ] diff --git a/auth/ensure_schema.py b/auth/ensure_schema.py index 035dba0d..9d631891 100644 --- a/auth/ensure_schema.py +++ b/auth/ensure_schema.py @@ -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() diff --git a/auth/password_reset.py b/auth/password_reset.py new file mode 100644 index 00000000..7b7c7661 --- /dev/null +++ b/auth/password_reset.py @@ -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() diff --git a/auth/send_email.py b/auth/send_email.py new file mode 100644 index 00000000..3c9c97ae --- /dev/null +++ b/auth/send_email.py @@ -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) diff --git a/auth/set_password.py b/auth/set_password.py new file mode 100644 index 00000000..88b1cc72 --- /dev/null +++ b/auth/set_password.py @@ -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() diff --git a/core/auth_guard.py b/core/auth_guard.py index 644ce85e..949629c2 100644 --- a/core/auth_guard.py +++ b/core/auth_guard.py @@ -21,6 +21,7 @@ from .flask_app import app # de connexion elle-même en a besoin pour s'afficher. _PUBLIC_ENDPOINTS = { "static", "login", "login_2fa", "register", "register_2fa", "logout", + "forgot_password", "reset_password", } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9ee4600c..2dcc3a25 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -6,6 +6,16 @@ services: - "${PORT:-5050}:5050" volumes: - forge_projects:/app/projects + environment: + # Réinitialisation de mot de passe par email (voir auth/send_email.py) + # — laissés vides ici tant qu'ils ne sont pas définis dans le .env du + # serveur : l'appli continue de fonctionner sans, l'envoi d'email + # échoue juste silencieusement côté utilisateur (logué côté serveur). + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM: ${SMTP_FROM:-} volumes: forge_projects: diff --git a/routes/auth/__init__.py b/routes/auth/__init__.py index 8409d75e..971b94a5 100644 --- a/routes/auth/__init__.py +++ b/routes/auth/__init__.py @@ -1 +1 @@ -from . import register, register_2fa, login, login_2fa, logout +from . import register, register_2fa, login, login_2fa, logout, forgot_password, reset_password diff --git a/routes/auth/forgot_password.py b/routes/auth/forgot_password.py new file mode 100644 index 00000000..f9da5f36 --- /dev/null +++ b/routes/auth/forgot_password.py @@ -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) diff --git a/routes/auth/reset_password.py b/routes/auth/reset_password.py new file mode 100644 index 00000000..9d26c1df --- /dev/null +++ b/routes/auth/reset_password.py @@ -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/", 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, + ) diff --git a/templates/auth/forgot_password.html b/templates/auth/forgot_password.html new file mode 100644 index 00000000..0d14367f --- /dev/null +++ b/templates/auth/forgot_password.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Mot de passe oublié — Forge Engine{% endblock %} +{% block content %} +
+
+

Mot de passe oublié

+ + {% if sent %} +
Si un compte existe avec cette adresse, un email vient de t'être envoyé avec un lien pour choisir un nouveau mot de passe.
+

Retour à la connexion

+ {% else %} +

Indique ton adresse email : si un compte y est associé, un lien de réinitialisation te sera envoyé.

+
+
+ +
+
+
+ +
+
+

Retour à la connexion

+ {% endif %} +
+
+{% endblock %} diff --git a/templates/auth/login.html b/templates/auth/login.html index 270a97e1..517f1e43 100644 --- a/templates/auth/login.html +++ b/templates/auth/login.html @@ -23,7 +23,8 @@ -

Pas encore de compte ? S'inscrire

+

Mot de passe oublié ?

+

Pas encore de compte ? S'inscrire

{% endblock %} diff --git a/templates/auth/reset_password.html b/templates/auth/reset_password.html new file mode 100644 index 00000000..2f202b11 --- /dev/null +++ b/templates/auth/reset_password.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block title %}Nouveau mot de passe — Forge Engine{% endblock %} +{% block content %} +
+
+

Choisir un nouveau mot de passe

+ + {% if invalid %} +
Ce lien de réinitialisation est invalide ou a expiré.
+

Redemander un lien

+ {% else %} + {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ +
+
+ +
+
+
    +
  • Au moins 8 caractères
  • +
  • Une majuscule
  • +
  • Une minuscule
  • +
  • Un chiffre
  • +
  • Un caractère spécial (ex. ! ? # -)
  • +
+
+ +
+ +
+
+ +
+ +
+
+ {% endif %} +
+
+ +{% if not invalid %} + +{% endif %} +{% endblock %} diff --git a/tests/test_password_reset.py b/tests/test_password_reset.py new file mode 100644 index 00000000..e8c6624e --- /dev/null +++ b/tests/test_password_reset.py @@ -0,0 +1,80 @@ +"""Réinitialisation de mot de passe par email (auth/password_reset.py, +auth/send_email.py) — l'envoi SMTP réel est remplacé (monkeypatch) par une +capture de l'URL qui aurait été envoyée : ces tests ne doivent jamais +essayer de contacter un vrai serveur SMTP.""" +import auth + +from tests.test_auth import _register, _confirm_2fa, _cleanup_project, anon_client # noqa: F401 + + +def _capture_reset_url(monkeypatch): + sent = {} + + def fake_send(to_email, reset_url): + sent["to_email"] = to_email + sent["reset_url"] = reset_url + + monkeypatch.setattr(auth, "send_password_reset_email", fake_send) + return sent + + +def test_forgot_password_always_shows_the_same_generic_message(anon_client, monkeypatch): + _capture_reset_url(monkeypatch) + resp_known = anon_client.post("/forgot-password", data={"email": "unknown@example.com"}) + resp_unknown_body = resp_known.get_data(as_text=True) + assert "un email vient de t'être envoyé" in resp_unknown_body + + +def test_full_password_reset_flow(anon_client, monkeypatch): + sent = _capture_reset_url(monkeypatch) + _register(anon_client, "resetflow@example.com") + _confirm_2fa(anon_client) + try: + anon_client.post("/logout") + + resp = anon_client.post("/forgot-password", data={"email": "resetflow@example.com"}) + assert "un email vient de t'être envoyé" in resp.get_data(as_text=True) + assert sent["to_email"] == "resetflow@example.com" + token = sent["reset_url"].rsplit("/", 1)[-1] + + # Le lien affiche bien le formulaire de nouveau mot de passe. + resp = anon_client.get(f"/reset-password/{token}") + assert resp.status_code == 200 + assert "invalide" not in resp.get_data(as_text=True) + + # Mots de passe qui ne correspondent pas -> refusé. + resp = anon_client.post(f"/reset-password/{token}", data={ + "password": "Nouveau$ecret1", "password_confirm": "Autre$ecret2", + }) + assert "ne correspondent pas" in resp.get_data(as_text=True) + + # Mot de passe trop faible -> refusé. + resp = anon_client.post(f"/reset-password/{token}", data={ + "password": "faible", "password_confirm": "faible", + }) + assert "faible" in resp.get_data(as_text=True) + + # Nouveau mot de passe valide -> accepté, redirige vers /login. + resp = anon_client.post(f"/reset-password/{token}", data={ + "password": "Nouveau$ecret1", "password_confirm": "Nouveau$ecret1", + }, follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["Location"] == "/login" + + # L'ancien mot de passe ne fonctionne plus, le nouveau fonctionne. + resp = anon_client.post("/login", data={"email": "resetflow@example.com", "password": "Sup3r$ecret!"}) + assert "incorrect" in resp.get_data(as_text=True) + resp = anon_client.post("/login", data={"email": "resetflow@example.com", "password": "Nouveau$ecret1"}, follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["Location"] == "/login/2fa" + + # Le jeton est à usage unique : le réutiliser échoue désormais. + resp = anon_client.get(f"/reset-password/{token}") + assert "invalide" in resp.get_data(as_text=True) + finally: + _cleanup_project("resetflow@example.com") + + +def test_reset_password_with_invalid_or_unknown_token_shows_invalid(anon_client): + resp = anon_client.get("/reset-password/not-a-real-token") + assert "invalide" in resp.get_data(as_text=True)