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>
81 lines
3.5 KiB
Python
81 lines
3.5 KiB
Python
"""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)
|