docs(l4d2-web): plan auth pages
This commit is contained in:
parent
84f325bb03
commit
942dada807
1 changed files with 403 additions and 0 deletions
403
docs/superpowers/plans/2026-05-06-l4d2-web-auth-pages.md
Normal file
403
docs/superpowers/plans/2026-05-06-l4d2-web-auth-pages.md
Normal file
|
|
@ -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 '<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")
|
||||
```
|
||||
|
||||
- [ ] **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
|
||||
<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`:
|
||||
|
||||
```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`:
|
||||
|
||||
```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.
|
||||
Loading…
Reference in a new issue