From 0aca36506f392619c0d4e1f31646046bda9a30a7 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Wed, 6 May 2026 12:52:22 +0200 Subject: [PATCH] feat(l4d2-web): add login page and safe redirects --- l4d2web/app.py | 13 +++++++--- l4d2web/auth.py | 26 +++++++++++++++++--- l4d2web/routes/auth_routes.py | 36 ++++++--------------------- l4d2web/static/css/components.css | 28 +++++++++++++++++++++ l4d2web/templates/base.html | 4 ++- l4d2web/templates/login.html | 23 +++++++++++++++++ l4d2web/tests/test_auth.py | 41 +++++++++++++++++++++++++++++-- l4d2web/tests/test_pages.py | 38 ++++++++++++++++++++++++++++ 8 files changed, 170 insertions(+), 39 deletions(-) create mode 100644 l4d2web/templates/login.html diff --git a/l4d2web/app.py b/l4d2web/app.py index 60c78a3..89fd3be 100644 --- a/l4d2web/app.py +++ b/l4d2web/app.py @@ -1,9 +1,9 @@ import os import secrets -from flask import Flask, Response, jsonify, request, session +from flask import Flask, Response, jsonify, redirect, request, session -from l4d2web.auth import load_current_user +from l4d2web.auth import current_user, load_current_user from l4d2web.cli import register_cli from l4d2web.config import DEFAULT_CONFIG from l4d2web.db import init_db @@ -24,7 +24,7 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask: app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", - CSRF_EXEMPT_PATHS={"/login", "/signup", "/health"}, + CSRF_EXEMPT_PATHS={"/login", "/health"}, ) if test_config is not None: app.config.update(test_config) @@ -40,6 +40,9 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask: if request.method not in {"POST", "PUT", "PATCH", "DELETE"}: return None + if request.endpoint is None: + return None + if request.path in app.config["CSRF_EXEMPT_PATHS"]: return None @@ -67,6 +70,8 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask: @app.get("/") def root(): - return jsonify({"status": "ok"}) + if current_user() is None: + return redirect("/login") + return redirect("/dashboard") return app diff --git a/l4d2web/auth.py b/l4d2web/auth.py index 88ecc46..9abf773 100644 --- a/l4d2web/auth.py +++ b/l4d2web/auth.py @@ -1,7 +1,8 @@ from functools import wraps from typing import Callable, TypeVar +from urllib.parse import quote -from flask import abort, g, redirect, session +from flask import abort, g, redirect, request, session from sqlalchemy import select from werkzeug.security import check_password_hash, generate_password_hash @@ -41,11 +42,30 @@ def logout_user() -> None: session.pop("user_id", None) +def is_safe_next(target: str | None) -> bool: + if not target: + return False + if not target.startswith("/"): + return False + if target.startswith("//"): + return False + if "://" in target: + return False + return True + + +def login_redirect_for_current_request(): + target = request.full_path.rstrip("?") + if is_safe_next(target): + return redirect(f"/login?next={quote(target, safe='/')}") + return redirect("/login") + + def require_login(func: F) -> F: @wraps(func) def wrapper(*args, **kwargs): if current_user() is None: - return redirect("/login") + return login_redirect_for_current_request() return func(*args, **kwargs) return wrapper # type: ignore[return-value] @@ -56,7 +76,7 @@ def require_admin(func: F) -> F: def wrapper(*args, **kwargs): user = current_user() if user is None: - return redirect("/login") + return login_redirect_for_current_request() if not user.admin: abort(403) return func(*args, **kwargs) diff --git a/l4d2web/routes/auth_routes.py b/l4d2web/routes/auth_routes.py index e894bbb..4592442 100644 --- a/l4d2web/routes/auth_routes.py +++ b/l4d2web/routes/auth_routes.py @@ -1,9 +1,9 @@ import time -from flask import Blueprint, Response, request, redirect +from flask import Blueprint, Response, redirect, render_template, request from sqlalchemy import select -from l4d2web.auth import hash_password, login_user, logout_user, verify_password +from l4d2web.auth import is_safe_next, login_user, logout_user, verify_password from l4d2web.db import session_scope from l4d2web.models import User @@ -29,33 +29,10 @@ def is_login_rate_limited(remote_addr: str) -> bool: return False -@bp.get("/signup") -def signup_form() -> Response: - return Response("signup", mimetype="text/plain") - - -@bp.post("/signup") -def signup() -> Response: - username = request.form.get("username", "").strip() - password = request.form.get("password", "") - if not username or not password: - return Response("missing credentials", status=400) - - with session_scope() as db: - existing = db.scalar(select(User).where(User.username == username)) - if existing is not None: - return Response("username already exists", status=409) - user = User(username=username, password_digest=hash_password(password), admin=False) - db.add(user) - db.flush() - login_user(user.id) - - return redirect("/dashboard") - - @bp.get("/login") -def login_form() -> Response: - return Response("login", mimetype="text/plain") +def login_form() -> str: + next_target = request.args.get("next", "") + return render_template("login.html", next_target=next_target if is_safe_next(next_target) else "") @bp.post("/login") @@ -72,7 +49,8 @@ def login() -> Response: return Response("invalid credentials", status=401) login_user(user.id) LOGIN_ATTEMPTS_BY_IP.pop(remote_addr, None) - return redirect("/dashboard") + next_target = request.form.get("next", "") + return redirect(next_target if is_safe_next(next_target) else "/dashboard") @bp.post("/logout") diff --git a/l4d2web/static/css/components.css b/l4d2web/static/css/components.css index f001fc9..7a0eaf8 100644 --- a/l4d2web/static/css/components.css +++ b/l4d2web/static/css/components.css @@ -35,6 +35,30 @@ textarea { font: inherit; } +label { + display: grid; + gap: var(--space-xs); +} + +input, +select, +textarea { + background: var(--color-surface); + border: var(--line); + border-radius: var(--radius-s); + color: var(--color-text); + padding: var(--space-s) var(--space-m); +} + +input:focus, +select:focus, +textarea:focus, +button:focus-visible, +a:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} + button { background: var(--color-primary); border: none; @@ -60,3 +84,7 @@ button.danger { .inline-form { display: inline; } + +.auth-panel { + max-width: 28rem; +} diff --git a/l4d2web/templates/base.html b/l4d2web/templates/base.html index dd1a278..99f21ad 100644 --- a/l4d2web/templates/base.html +++ b/l4d2web/templates/base.html @@ -14,10 +14,12 @@