diff --git a/src/auth/routes.py b/src/auth/routes.py index 9fec563..285346c 100644 --- a/src/auth/routes.py +++ b/src/auth/routes.py @@ -1,14 +1,20 @@ from email_validator import EmailNotValidError, validate_email from flask import Blueprint, redirect, request -from werkzeug.security import generate_password_hash +from flask_login import login_user, logout_user +from werkzeug.security import check_password_hash, generate_password_hash from src.auth import db, mailer, tokens +from src.auth.models import User +from src.auth.setup import safe_next from src.utils import logger auth_bp = Blueprint("auth", __name__, url_prefix="/auth") MIN_PASSWORD_LENGTH = 8 +# Hash bidon pré-calculé pour uniformiser le timing login +_DUMMY_HASH = generate_password_hash("dummy-password-for-timing") + def _redirect_with_error(path: str, error: str, email: str | None = None): url = f"{path}?error={error}" @@ -59,3 +65,30 @@ def verify_email(): return redirect("/verification-email?error=invalid_token") db.set_email_verified(user_id) return redirect("/connexion?verified=1") + + +@auth_bp.route("/login", methods=["POST"]) +def login(): + email = (request.form.get("email") or "").strip().lower() + password = request.form.get("password") or "" + next_url = safe_next(request.form.get("next"), fallback="/compte") + + row = db.get_user_by_email(email) + if row is None: + check_password_hash(_DUMMY_HASH, password) # uniformiser le temps + return _redirect_with_error("/connexion", "invalid_credentials", email) + + if not check_password_hash(row["password_hash"], password): + return _redirect_with_error("/connexion", "invalid_credentials", email) + + if not row["email_verified"]: + return _redirect_with_error("/connexion", "email_not_verified", email) + + login_user(User(row), remember=True) + return redirect(next_url) + + +@auth_bp.route("/logout", methods=["POST"]) +def logout(): + logout_user() + return redirect("/") diff --git a/tests/auth/test_login.py b/tests/auth/test_login.py new file mode 100644 index 0000000..9df7221 --- /dev/null +++ b/tests/auth/test_login.py @@ -0,0 +1,74 @@ +from werkzeug.security import generate_password_hash + +from src.auth import db + + +def _make_verified_user(email="a@b.c", password="password12"): + db.init_schema() + uid = db.create_user(email, generate_password_hash(password)) + db.set_email_verified(uid) + return uid + + +def test_login_success(client, users_db_path): + _make_verified_user() + resp = client.post( + "/auth/login", + data={"email": "a@b.c", "password": "password12"}, + ) + assert resp.status_code == 302 + assert resp.headers["Location"].endswith("/compte") + + +def test_login_wrong_password(client, users_db_path): + _make_verified_user() + resp = client.post( + "/auth/login", data={"email": "a@b.c", "password": "wrong-password"} + ) + assert "error=invalid_credentials" in resp.headers["Location"] + + +def test_login_unknown_email_same_error(client, users_db_path): + db.init_schema() + resp = client.post( + "/auth/login", + data={"email": "inexistant@example.com", "password": "x" * 12}, + ) + assert "error=invalid_credentials" in resp.headers["Location"] + + +def test_login_unverified_user(client, users_db_path): + db.init_schema() + db.create_user("a@b.c", generate_password_hash("password12")) + resp = client.post("/auth/login", data={"email": "a@b.c", "password": "password12"}) + assert "error=email_not_verified" in resp.headers["Location"] + + +def test_login_respects_safe_next(client, users_db_path): + _make_verified_user() + resp = client.post( + "/auth/login", + data={"email": "a@b.c", "password": "password12", "next": "/tableau"}, + ) + assert resp.headers["Location"].endswith("/tableau") + + +def test_login_rejects_absolute_next(client, users_db_path): + _make_verified_user() + resp = client.post( + "/auth/login", + data={ + "email": "a@b.c", + "password": "password12", + "next": "https://evil.com", + }, + ) + assert resp.headers["Location"].endswith("/compte") + + +def test_logout_clears_session(client, users_db_path): + _make_verified_user() + client.post("/auth/login", data={"email": "a@b.c", "password": "password12"}) + resp = client.post("/auth/logout") + assert resp.status_code == 302 + assert resp.headers["Location"].endswith("/")