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
+11
View File
@@ -12,3 +12,14 @@ IMAGE_TAG=latest
# Port exposé sur l'hôte de production # Port exposé sur l'hôte de production
PORT=5050 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=
+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 .record_failed_attempt import record_failed_attempt
from .reset_failed_attempts import reset_failed_attempts from .reset_failed_attempts import reset_failed_attempts
from .recovery_codes import generate_recovery_codes, verify_recovery_code 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__ = [ __all__ = [
"users_db_path", "ensure_users_schema", "is_first_user", "users_db_path", "ensure_users_schema", "is_first_user",
@@ -31,4 +37,7 @@ __all__ = [
"lockout_minutes_for", "seconds_locked_remaining", "lockout_message", "lockout_minutes_for", "seconds_locked_remaining", "lockout_message",
"record_failed_attempt", "reset_failed_attempts", "record_failed_attempt", "reset_failed_attempts",
"generate_recovery_codes", "verify_recovery_code", "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.commit()
conn.close() 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()
+1
View File
@@ -21,6 +21,7 @@ from .flask_app import app
# de connexion elle-même en a besoin pour s'afficher. # de connexion elle-même en a besoin pour s'afficher.
_PUBLIC_ENDPOINTS = { _PUBLIC_ENDPOINTS = {
"static", "login", "login_2fa", "register", "register_2fa", "logout", "static", "login", "login_2fa", "register", "register_2fa", "logout",
"forgot_password", "reset_password",
} }
+10
View File
@@ -6,6 +6,16 @@ services:
- "${PORT:-5050}:5050" - "${PORT:-5050}:5050"
volumes: volumes:
- forge_projects:/app/projects - 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: volumes:
forge_projects: forge_projects:
+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,
)
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Mot de passe oublié — Forge Engine{% endblock %}
{% block content %}
<section class="section">
<div class="container" style="max-width:420px;">
<h1 class="title">Mot de passe oublié</h1>
{% if sent %}
<div class="notification is-success">Si un compte existe avec cette adresse, un email vient de t'être envoyé avec un lien pour choisir un nouveau mot de passe.</div>
<p class="hint mt-3"><a href="{{ url_for('login') }}">Retour à la connexion</a></p>
{% else %}
<p class="hint mb-4">Indique ton adresse email : si un compte y est associé, un lien de réinitialisation te sera envoyé.</p>
<form method="post" action="{{ url_for('forgot_password') }}" autocomplete="off">
<div class="field">
<label class="label">Adresse email</label>
<div class="control"><input class="input" type="email" name="email" required autofocus></div>
</div>
<div class="field mt-4">
<button type="submit" class="button primary is-fullwidth">Envoyer le lien</button>
</div>
</form>
<p class="hint mt-3"><a href="{{ url_for('login') }}">Retour à la connexion</a></p>
{% endif %}
</div>
</section>
{% endblock %}
+2 -1
View File
@@ -23,7 +23,8 @@
<button type="submit" class="button primary is-fullwidth">Continuer</button> <button type="submit" class="button primary is-fullwidth">Continuer</button>
</div> </div>
</form> </form>
<p class="hint mt-3">Pas encore de compte ? <a href="{{ url_for('register') }}">S'inscrire</a></p> <p class="hint mt-3"><a href="{{ url_for('forgot_password') }}">Mot de passe oublié ?</a></p>
<p class="hint mt-1">Pas encore de compte ? <a href="{{ url_for('register') }}">S'inscrire</a></p>
</div> </div>
</section> </section>
{% endblock %} {% endblock %}
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}Nouveau mot de passe — Forge Engine{% endblock %}
{% block content %}
<section class="section">
<div class="container" style="max-width:480px;">
<h1 class="title">Choisir un nouveau mot de passe</h1>
{% if invalid %}
<div class="notification is-danger">Ce lien de réinitialisation est invalide ou a expiré.</div>
<p class="hint mt-3"><a href="{{ url_for('forgot_password') }}">Redemander un lien</a></p>
{% else %}
{% if error %}
<div class="notification is-danger">{{ error }}</div>
{% endif %}
<form method="post" autocomplete="off">
<div class="field">
<label class="label">Nouveau mot de passe</label>
<div class="control"><input class="input" type="password" id="password" name="password" required autocomplete="new-password"></div>
</div>
<div class="field">
<div class="passwordStrengthBar"><div class="passwordStrengthFill" id="pwFill"></div></div>
<ul class="passwordChecklist" id="pwChecklist">
<li data-check="longueur">Au moins 8 caractères</li>
<li data-check="majuscule">Une majuscule</li>
<li data-check="minuscule">Une minuscule</li>
<li data-check="chiffre">Un chiffre</li>
<li data-check="special">Un caractère spécial (ex. ! ? # -)</li>
</ul>
</div>
<div class="field">
<label class="label">Confirmer le mot de passe</label>
<div class="control"><input class="input" type="password" name="password_confirm" required autocomplete="new-password"></div>
</div>
<div class="field mt-4">
<button type="submit" class="button primary is-fullwidth">Réinitialiser mon mot de passe</button>
</div>
</form>
{% endif %}
</div>
</section>
{% if not invalid %}
<script>
var MIN_SCORE_REQUIRED = {{ min_score_required }};
var PW_CHECKS = {
longueur: function (p) { return p.length >= 8; },
majuscule: function (p) { return /[A-Z]/.test(p); },
minuscule: function (p) { return /[a-z]/.test(p); },
chiffre: function (p) { return /\d/.test(p); },
special: function (p) { return /[^A-Za-z0-9]/.test(p); },
};
(function () {
var input = document.getElementById('password');
var fill = document.getElementById('pwFill');
var items = document.querySelectorAll('#pwChecklist li');
if (!input) return;
function update() {
var value = input.value || '';
var score = 0;
items.forEach(function (li) {
var ok = PW_CHECKS[li.dataset.check](value);
li.classList.toggle('ok', ok);
if (ok) score++;
});
var pct = (score / items.length) * 100;
fill.style.width = pct + '%';
fill.className = 'passwordStrengthFill' +
(score >= items.length ? ' strong' : score >= MIN_SCORE_REQUIRED ? ' good' : score >= 2 ? ' medium' : ' weak');
}
input.addEventListener('input', update);
update();
})();
</script>
{% endif %}
{% endblock %}
+80
View File
@@ -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)