left4me/docs/superpowers/plans/2026-05-06-l4d2-web-auth-pages.md
2026-05-06 13:01:48 +02:00

14 KiB

l4d2 Web Auth Pages Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. Do not use git worktrees.

Goal: Add a real login page, remove signup, and redirect anonymous users back to their requested page after login when safe.

Architecture: Keep the current username/password data model and Flask session authentication. Add a small safe-redirect helper in l4d2web/auth.py, render login through Jinja, and keep routing changes scoped to auth decorators plus the app root route.

Tech Stack: Flask, Jinja templates, SQLAlchemy models, pytest, existing custom CSS tokens.


File Structure

  • l4d2web/auth.py: owns password hashing, session helpers, current user lookup, login-required/admin-required decorators, and safe local redirect target validation.
  • l4d2web/routes/auth_routes.py: owns login GET/POST and logout. Signup handlers will be removed from this file.
  • l4d2web/app.py: owns app setup, CSRF exemptions, and public root/health routes. Root will become auth-aware.
  • l4d2web/templates/base.html: owns shared shell navigation. It will hide app navigation for anonymous users.
  • l4d2web/templates/login.html: new server-rendered login page.
  • l4d2web/static/css/components.css: minor form-control styling only if needed for the login form.
  • l4d2web/tests/test_auth.py: auth route tests for login page, signup removal, safe next redirect, and unsafe next fallback.
  • l4d2web/tests/test_pages.py: protected-page redirect and root redirect tests.
  • l4d2web/tests/test_security.py: CSRF/rate-limit regression tests after signup is removed from exemptions.

Task 1: Remove Signup and Add Login Page Tests

Files:

  • Modify: l4d2web/tests/test_auth.py

  • Modify: l4d2web/tests/test_pages.py

  • Step 1: Update auth tests for login page, signup removal, and next redirects

Replace test_public_signup in l4d2web/tests/test_auth.py with these tests, and update existing login test data keys only if needed:

def test_login_page_renders_form(client) -> None:
    response = client.get("/login")
    text = response.get_data(as_text=True)

    assert response.status_code == 200
    assert '<form method="post" action="/login"' in text
    assert 'name="username"' in text
    assert 'name="password"' in text
    assert "signup" not in text.lower()


def test_signup_routes_are_gone(client) -> None:
    assert client.get("/signup").status_code == 404
    assert client.post("/signup", data={"username": "alice", "password": "secret"}).status_code == 404


def test_login_redirects_to_safe_next(client) -> None:
    with session_scope() as session:
        session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))

    response = client.post(
        "/login",
        data={"username": "alice", "password": "secret", "next": "/servers"},
    )

    assert response.status_code == 302
    assert response.headers["Location"].endswith("/servers")


def test_login_ignores_unsafe_next(client) -> None:
    with session_scope() as session:
        session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))

    response = client.post(
        "/login",
        data={"username": "alice", "password": "secret", "next": "https://example.com/steal"},
    )

    assert response.status_code == 302
    assert response.headers["Location"].endswith("/dashboard")


def test_login_ignores_backslash_next(client) -> None:
    with session_scope() as session:
        session.add(User(username="alice", password_digest=hash_password("secret"), admin=False))

    response = client.post(
        "/login",
        data={"username": "alice", "password": "secret", "next": "/\\evil.com"},
    )

    assert response.status_code == 302
    assert response.headers["Location"].endswith("/dashboard")
  • Step 2: Add protected-page and root redirect tests

Append these tests to l4d2web/tests/test_pages.py:

def test_anonymous_protected_page_redirects_to_login_with_next(tmp_path, monkeypatch) -> None:
    db_url = f"sqlite:///{tmp_path/'anonymous-pages.db'}"
    monkeypatch.setenv("DATABASE_URL", db_url)
    app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
    init_db()
    client = app.test_client()

    response = client.get("/servers")

    assert response.status_code == 302
    assert response.headers["Location"].endswith("/login?next=/servers")


def test_root_redirects_by_auth_state(tmp_path, monkeypatch) -> None:
    db_url = f"sqlite:///{tmp_path/'root-redirect.db'}"
    monkeypatch.setenv("DATABASE_URL", db_url)
    app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
    init_db()
    client = app.test_client()

    anonymous_response = client.get("/")
    assert anonymous_response.status_code == 302
    assert anonymous_response.headers["Location"].endswith("/login")

    with session_scope() as session:
        user = User(username="alice", password_digest=hash_password("secret"), admin=False)
        session.add(user)
        session.flush()
        user_id = user.id

    with client.session_transaction() as sess:
        sess["user_id"] = user_id

    logged_in_response = client.get("/")
    assert logged_in_response.status_code == 302
    assert logged_in_response.headers["Location"].endswith("/dashboard")
  • Step 3: Run tests and verify they fail

Run: pytest l4d2web/tests/test_auth.py::test_login_page_renders_form l4d2web/tests/test_auth.py::test_signup_routes_are_gone l4d2web/tests/test_auth.py::test_login_redirects_to_safe_next l4d2web/tests/test_auth.py::test_login_ignores_unsafe_next l4d2web/tests/test_pages.py::test_anonymous_protected_page_redirects_to_login_with_next l4d2web/tests/test_pages.py::test_root_redirects_by_auth_state -q

Expected: FAIL because /login is still plain text, signup routes still exist, next is not implemented, and / still returns JSON.

Task 2: Implement Safe Redirects and Remove Signup

Files:

  • Modify: l4d2web/auth.py

  • Modify: l4d2web/routes/auth_routes.py

  • Modify: l4d2web/app.py

  • Step 1: Add safe redirect helpers and next-aware decorators

Update l4d2web/auth.py imports and add helpers near the session helpers:

from urllib.parse import quote

from flask import abort, g, redirect, request, session
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
    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")

Change require_login and the anonymous branch of require_admin to call login_redirect_for_current_request():

def require_login(func: F) -> F:
    @wraps(func)
    def wrapper(*args, **kwargs):
        if current_user() is None:
            return login_redirect_for_current_request()
        return func(*args, **kwargs)

    return wrapper  # type: ignore[return-value]


def require_admin(func: F) -> F:
    @wraps(func)
    def wrapper(*args, **kwargs):
        user = current_user()
        if user is None:
            return login_redirect_for_current_request()
        if not user.admin:
            abort(403)
        return func(*args, **kwargs)

    return wrapper  # type: ignore[return-value]
  • Step 2: Remove signup and render login template

Update l4d2web/routes/auth_routes.py imports:

from flask import Blueprint, Response, redirect, render_template, request

Update the auth import:

from l4d2web.auth import hash_password, is_safe_next, login_user, logout_user, verify_password

Delete the @bp.get("/signup") and @bp.post("/signup") route functions.

Replace login_form with:

@bp.get("/login")
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 "")

Replace the final line of login with a safe redirect target:

    next_target = request.form.get("next", "")
    return redirect(next_target if is_safe_next(next_target) else "/dashboard")

Keep the existing rate limiting and invalid credential behavior unchanged.

  • Step 3: Update CSRF exemptions and root route

In l4d2web/app.py, update imports:

from flask import Flask, Response, jsonify, redirect, request, session

from l4d2web.auth import current_user, load_current_user

Change the CSRF exemptions to remove signup:

CSRF_EXEMPT_PATHS={"/login", "/health"},

Replace the root route with:

    @app.get("/")
    def root():
        if current_user() is None:
            return redirect("/login")
        return redirect("/dashboard")
  • Step 4: Run focused tests and verify they still fail only for missing template/nav

Run: pytest l4d2web/tests/test_auth.py::test_login_page_renders_form l4d2web/tests/test_auth.py::test_signup_routes_are_gone l4d2web/tests/test_auth.py::test_login_redirects_to_safe_next l4d2web/tests/test_auth.py::test_login_ignores_unsafe_next l4d2web/tests/test_pages.py::test_anonymous_protected_page_redirects_to_login_with_next l4d2web/tests/test_pages.py::test_root_redirects_by_auth_state -q

Expected: FAIL only because login.html does not exist or the rendered page does not yet contain the expected form markup.

Task 3: Add Login Template and Anonymous Shell Behavior

Files:

  • Create: l4d2web/templates/login.html

  • Modify: l4d2web/templates/base.html

  • Modify: l4d2web/static/css/components.css

  • Step 1: Hide main app navigation for anonymous users

Update the header section in l4d2web/templates/base.html so the main section links render only for logged-in users:

        <nav class="primary-nav" aria-label="Main navigation">
          <a class="brand" href="{{ '/dashboard' if g.user else '/login' }}">left4me</a>
          {% if g.user %}
          <a href="/servers">servers</a>
          <a href="/blueprints">blueprints</a>
          <a href="/overlays">overlays</a>
          {% endif %}
        </nav>

Keep the existing account nav wrapped in {% if g.user %}.

  • Step 2: Create the login template

Create l4d2web/templates/login.html:

{% extends "base.html" %}

{% block title %}Log In | left4me{% endblock %}

{% block content %}
<section class="panel auth-panel">
  <h1>Log in to left4me</h1>
  <p class="muted">Use your local account to manage Left 4 Dead 2 servers.</p>

  <form method="post" action="/login" class="stack">
    {% if next_target %}<input type="hidden" name="next" value="{{ next_target }}">{% endif %}
    <label>
      Username
      <input name="username" autocomplete="username" required autofocus>
    </label>
    <label>
      Password
      <input name="password" type="password" autocomplete="current-password" required>
    </label>
    <button type="submit">log in</button>
  </form>
</section>
{% endblock %}
  • Step 3: Add minimal form styling if needed

Append to l4d2web/static/css/components.css:

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;
}

.auth-panel {
  max-width: 28rem;
}
  • Step 4: Run focused tests and verify they pass

Run: pytest l4d2web/tests/test_auth.py::test_login_page_renders_form l4d2web/tests/test_auth.py::test_signup_routes_are_gone l4d2web/tests/test_auth.py::test_login_redirects_to_safe_next l4d2web/tests/test_auth.py::test_login_ignores_unsafe_next l4d2web/tests/test_pages.py::test_anonymous_protected_page_redirects_to_login_with_next l4d2web/tests/test_pages.py::test_root_redirects_by_auth_state -q

Expected: PASS.

  • Step 5: Commit auth page implementation
git add l4d2web/auth.py l4d2web/routes/auth_routes.py l4d2web/app.py l4d2web/templates/base.html l4d2web/templates/login.html l4d2web/static/css/components.css l4d2web/tests/test_auth.py l4d2web/tests/test_pages.py
git commit -m "feat(l4d2-web): add login page and safe redirects"

Task 4: Security Regression and Full Web Verification

Files:

  • Modify: l4d2web/tests/test_security.py only if existing tests need path-specific updates.

  • Modify: docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md only if stale public-signup requirements should be corrected after implementation.

  • Modify: AGENTS.md only if the auth contract must be changed from public signup to admin-created local users.

  • Step 1: Run security tests

Run: pytest l4d2web/tests/test_security.py -q

Expected: PASS. test_csrf_required should still prove protected POSTs require CSRF. test_login_rate_limit should still pass with /login exempt from CSRF.

  • Step 2: Run page tests

Run: pytest l4d2web/tests/test_pages.py -q

Expected: PASS. Existing logged-in shell tests should still see app navigation; new anonymous tests should see login redirects.

  • Step 3: Run auth tests

Run: pytest l4d2web/tests/test_auth.py -q

Expected: PASS.

  • Step 4: Run full web test suite

Run: pytest l4d2web/tests -q

Expected: PASS.

  • Step 5: Commit test/doc cleanup if needed

If Task 4 required changes, commit them:

git add l4d2web/tests/test_security.py docs/superpowers/plans/2026-04-23-l4d2-web-app-v1.md AGENTS.md
git commit -m "test(l4d2-web): cover auth redirect regressions"

If no files changed in Task 4, do not create an empty commit.

Final Verification

  • Run pytest l4d2web/tests -q and confirm all tests pass.
  • Run git status --short and confirm only intentional changes remain.
  • Report exact command results and commits created.