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:
co-authored by
Claude Sonnet 5
parent
9efe119936
commit
d5a84413d1
@@ -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=
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1 +1 @@
|
||||
from . import register, register_2fa, login, login_2fa, logout
|
||||
from . import register, register_2fa, login, login_2fa, logout, forgot_password, reset_password
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 %}
|
||||
@@ -23,7 +23,8 @@
|
||||
<button type="submit" class="button primary is-fullwidth">Continuer</button>
|
||||
</div>
|
||||
</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>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user