diff --git a/docs/superpowers/plans/2026-05-06-l4d2-web-auth-pages.md b/docs/superpowers/plans/2026-05-06-l4d2-web-auth-pages.md new file mode 100644 index 0000000..c37a5ec --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-l4d2-web-auth-pages.md @@ -0,0 +1,403 @@ +# 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: + +```python +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 '
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") +``` + +- [ ] **Step 2: Add protected-page and root redirect tests** + +Append these tests to `l4d2web/tests/test_pages.py`: + +```python +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: + +```python +from urllib.parse import quote + +from flask import abort, g, redirect, request, session +``` + +```python +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") +``` + +Change `require_login` and the anonymous branch of `require_admin` to call `login_redirect_for_current_request()`: + +```python +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: + +```python +from flask import Blueprint, Response, redirect, render_template, request +``` + +Update the auth import: + +```python +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: + +```python +@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: + +```python + 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: + +```python +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: + +```python +CSRF_EXEMPT_PATHS={"/login", "/health"}, +``` + +Replace the root route with: + +```python + @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: + +```html + +``` + +Keep the existing account nav wrapped in `{% if g.user %}`. + +- [ ] **Step 2: Create the login template** + +Create `l4d2web/templates/login.html`: + +```html +{% extends "base.html" %} + +{% block title %}Log In | left4me{% endblock %} + +{% block content %} +
+

Log in to left4me

+

Use your local account to manage Left 4 Dead 2 servers.

+ + + {% if next_target %}{% endif %} + + + + +
+{% endblock %} +``` + +- [ ] **Step 3: Add minimal form styling if needed** + +Append to `l4d2web/static/css/components.css`: + +```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** + +```bash +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: + +```bash +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.