diff --git a/src/auth/routes.py b/src/auth/routes.py index 285346c..9af6e10 100644 --- a/src/auth/routes.py +++ b/src/auth/routes.py @@ -92,3 +92,56 @@ def login(): def logout(): logout_user() return redirect("/") + + +@auth_bp.route("/request-password-reset", methods=["POST"]) +def request_password_reset(): + email = (request.form.get("email") or "").strip().lower() + try: + valid = validate_email(email, check_deliverability=False) + email = valid.normalized.lower() + except EmailNotValidError: + return redirect("/mot-de-passe-oublie?pending=1") + + row = db.get_user_by_email(email) + if row is None: + return redirect("/mot-de-passe-oublie?pending=1") + + token = tokens.create_password_reset_token(row["id"]) + try: + mailer.send_reset_email(email, token) + except Exception: + logger.exception("Échec d'envoi de l'email de réinitialisation") + return _redirect_with_error("/mot-de-passe-oublie", "email_send_failed", email) + return redirect("/mot-de-passe-oublie?pending=1") + + +@auth_bp.route("/reset-password", methods=["POST"]) +def reset_password(): + token = request.form.get("token") or "" + password = request.form.get("password") or "" + password_confirm = request.form.get("password_confirm") or "" + + user_id = tokens.validate_password_reset_token(token) + if user_id is None: + return redirect( + f"/reinitialiser-mot-de-passe?token={token}&error=invalid_token" + ) + + if len(password) < MIN_PASSWORD_LENGTH: + return redirect( + f"/reinitialiser-mot-de-passe?token={token}&error=password_too_short" + ) + if password != password_confirm: + return redirect( + f"/reinitialiser-mot-de-passe?token={token}&error=password_mismatch" + ) + + consumed = tokens.consume_password_reset_token(token) + if consumed is None: + return redirect( + f"/reinitialiser-mot-de-passe?token={token}&error=invalid_token" + ) + + db.update_password_hash(consumed, generate_password_hash(password)) + return redirect("/connexion?password_changed=1") diff --git a/tests/auth/test_password_reset.py b/tests/auth/test_password_reset.py new file mode 100644 index 0000000..553debc --- /dev/null +++ b/tests/auth/test_password_reset.py @@ -0,0 +1,84 @@ +from werkzeug.security import check_password_hash, generate_password_hash + +from src.auth import db, tokens + + +def _make_user(): + db.init_schema() + uid = db.create_user("a@b.c", generate_password_hash("old-password12")) + db.set_email_verified(uid) + return uid + + +def test_request_reset_sends_email_for_existing_user( + client, mail_outbox, users_db_path +): + _make_user() + resp = client.post("/auth/request-password-reset", data={"email": "a@b.c"}) + assert resp.status_code == 302 + assert "pending=1" in resp.headers["Location"] + assert len(mail_outbox) == 1 + + +def test_request_reset_same_response_for_unknown_email( + client, mail_outbox, users_db_path +): + db.init_schema() + resp = client.post("/auth/request-password-reset", data={"email": "absent@b.c"}) + assert "pending=1" in resp.headers["Location"] + assert len(mail_outbox) == 0 + + +def test_perform_reset_with_valid_token(client, users_db_path): + uid = _make_user() + token = tokens.create_password_reset_token(uid) + resp = client.post( + "/auth/reset-password", + data={ + "token": token, + "password": "new-password12", + "password_confirm": "new-password12", + }, + ) + assert resp.status_code == 302 + assert "password_changed=1" in resp.headers["Location"] + row = db.get_user_by_id(uid) + assert check_password_hash(row["password_hash"], "new-password12") + + +def test_perform_reset_rejects_expired_token(client, users_db_path): + uid = _make_user() + token = tokens.create_password_reset_token(uid, expires_in_hours=-1) + resp = client.post( + "/auth/reset-password", + data={ + "token": token, + "password": "new-password12", + "password_confirm": "new-password12", + }, + ) + assert "error=invalid_token" in resp.headers["Location"] + + +def test_perform_reset_rejects_short_password(client, users_db_path): + uid = _make_user() + token = tokens.create_password_reset_token(uid) + resp = client.post( + "/auth/reset-password", + data={"token": token, "password": "short", "password_confirm": "short"}, + ) + assert "error=password_too_short" in resp.headers["Location"] + + +def test_perform_reset_rejects_mismatched(client, users_db_path): + uid = _make_user() + token = tokens.create_password_reset_token(uid) + resp = client.post( + "/auth/reset-password", + data={ + "token": token, + "password": "new-password12", + "password_confirm": "other-password99", + }, + ) + assert "error=password_mismatch" in resp.headers["Location"]