feat: Implement Phase 4 Web Interface with bugfixes (v0.5.2)
## Phase 4: Web Interface Implementation Implemented complete web interface with public and admin routes, templates, CSS, and development authentication. ### Core Features **Public Routes**: - Homepage with recent published notes - Note permalinks with microformats2 - Server-side rendering (Jinja2) **Admin Routes**: - Login via IndieLogin - Dashboard with note management - Create, edit, delete notes - Protected with @require_auth decorator **Development Authentication**: - Dev login bypass for local testing (DEV_MODE only) - Security safeguards per ADR-011 - Returns 404 when disabled **Templates & Frontend**: - Base layouts (public + admin) - 8 HTML templates with microformats2 - Custom responsive CSS (114 lines) - Error pages (404, 500) ### Bugfixes (v0.5.1 → v0.5.2) 1. **Cookie collision fix (v0.5.1)**: - Renamed auth cookie from "session" to "starpunk_session" - Fixed redirect loop between dev login and admin dashboard - Flask's session cookie no longer conflicts with auth 2. **HTTP 404 error handling (v0.5.1)**: - Update route now returns 404 for nonexistent notes - Delete route now returns 404 for nonexistent notes - Follows ADR-012 HTTP Error Handling Policy - Pattern consistency across all admin routes 3. **Note model enhancement (v0.5.2)**: - Exposed deleted_at field from database schema - Enables soft deletion verification in tests - Follows ADR-013 transparency principle ### Architecture **New ADRs**: - ADR-011: Development Authentication Mechanism - ADR-012: HTTP Error Handling Policy - ADR-013: Expose deleted_at Field in Note Model **Standards Compliance**: - Uses uv for Python environment - Black formatted, Flake8 clean - Follows git branching strategy - Version incremented per versioning strategy ### Test Results - 405/406 tests passing (99.75%) - 87% code coverage - All security tests passing - Manual testing confirmed working ### Documentation - Complete implementation reports in docs/reports/ - Architecture reviews in docs/reviews/ - Design documents in docs/design/ - CHANGELOG updated for v0.5.2 ### Files Changed **New Modules**: - starpunk/dev_auth.py - starpunk/routes/ (public, admin, auth, dev_auth) **Templates**: 10 files (base, pages, admin, errors) **Static**: CSS and optional JavaScript **Tests**: 4 test files for routes and templates **Docs**: 20+ architectural and implementation documents 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
313
docs/design/auth-redirect-loop-diagram.md
Normal file
313
docs/design/auth-redirect-loop-diagram.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# Auth Redirect Loop - Visual Diagram
|
||||
|
||||
## Current Behavior (BROKEN)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ User clicks "Dev Login" │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ GET /dev/login │
|
||||
│ │
|
||||
│ 1. create_dev_session(me) → returns "abc123xyz" │
|
||||
│ 2. response.set_cookie("session", "abc123xyz") │
|
||||
│ 3. flash("DEV MODE: Logged in") ← This triggers Flask session! │
|
||||
│ Flask writes: session = {_flashes: ["message"]} │
|
||||
│ 4. return redirect("/admin/") │
|
||||
│ │
|
||||
│ ⚠️ Cookie "session" is now Flask session data, NOT auth token! │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Browser → GET /admin/ │
|
||||
│ Cookie: session={_flashes: ["message"]} ← WRONG DATA! │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ @require_auth decorator │
|
||||
│ │
|
||||
│ 1. session_token = request.cookies.get("session") │
|
||||
│ → Gets: {_flashes: ["message"]} ← Not a token! │
|
||||
│ 2. verify_session("{_flashes: ...}") │
|
||||
│ → hash("{_flashes: ...}") doesn't match any DB session │
|
||||
│ → Returns None │
|
||||
│ 3. if not session_info: │
|
||||
│ session["next"] = request.url ← More Flask session! │
|
||||
│ return redirect("/admin/login") │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Browser → GET /admin/login │
|
||||
│ User sees: Login page (NOT dashboard) │
|
||||
│ Result: REDIRECT LOOP ❌ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Fixed Behavior (CORRECT)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ User clicks "Dev Login" │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ GET /dev/login │
|
||||
│ │
|
||||
│ 1. create_dev_session(me) → returns "abc123xyz" │
|
||||
│ 2. response.set_cookie("starpunk_session", "abc123xyz") │
|
||||
│ 3. flash("DEV MODE: Logged in") │
|
||||
│ Flask writes: session = {_flashes: ["message"]} │
|
||||
│ 4. return redirect("/admin/") │
|
||||
│ │
|
||||
│ ✅ Two separate cookies: │
|
||||
│ - starpunk_session = "abc123xyz" (auth token) │
|
||||
│ - session = {_flashes: ["message"]} (Flask session) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Browser → GET /admin/ │
|
||||
│ Cookie: starpunk_session=abc123xyz │
|
||||
│ Cookie: session={_flashes: ["message"]} │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ @require_auth decorator │
|
||||
│ │
|
||||
│ 1. session_token = request.cookies.get("starpunk_session") │
|
||||
│ → Gets: "abc123xyz" ✅ Correct auth token! │
|
||||
│ 2. verify_session("abc123xyz") │
|
||||
│ → hash("abc123xyz") matches DB session │
|
||||
│ → Returns: {me: "https://dev.example.com", ...} │
|
||||
│ 3. if session_info: ✅ Valid session! │
|
||||
│ g.user = session_info │
|
||||
│ g.me = session_info["me"] │
|
||||
│ return dashboard() ← Continues to dashboard! │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Browser → Renders /admin/ dashboard │
|
||||
│ User sees: Dashboard with notes ✅ │
|
||||
│ Flash message: "DEV MODE: Logged in" ✅ │
|
||||
│ Result: SUCCESS! No redirect loop! ✅ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Cookie Collision Visualization
|
||||
|
||||
### BEFORE (Broken)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ BROWSER │
|
||||
│ │
|
||||
│ Cookies for localhost:5000: │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ Name: session │ │
|
||||
│ │ Value: {_flashes: ["message"]} │ │
|
||||
│ │ │ │
|
||||
│ │ ❌ CONFLICT: This should be auth token!│ │
|
||||
│ │ Flask overwrote our auth token! │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### AFTER (Fixed)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ BROWSER │
|
||||
│ │
|
||||
│ Cookies for localhost:5000: │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ Name: starpunk_session │ │
|
||||
│ │ Value: abc123xyz... │ │
|
||||
│ │ Purpose: Auth token ✅ │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ Name: session │ │
|
||||
│ │ Value: {_flashes: ["message"]} │ │
|
||||
│ │ Purpose: Flask session ✅ │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ✅ Two separate cookies, no conflict! │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Timeline of Events
|
||||
|
||||
### Broken Flow
|
||||
|
||||
```
|
||||
Time Request Cookie State Auth State
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
T0 GET /dev/login (none) Not authed
|
||||
T1 ↓ set_cookie session = "abc123xyz" Token set ✅
|
||||
T2 ↓ flash() session = {_flashes: [...]} OVERWRITTEN ❌
|
||||
T3 302 → /admin/ session = {_flashes: [...]} Token LOST ❌
|
||||
T4 GET /admin/ session = {_flashes: [...]} Not authed ❌
|
||||
T5 ↓ @require_auth verify("{_flashes...}") = None FAIL ❌
|
||||
T6 302 → /admin/login session = {_flashes: [...]} Not authed ❌
|
||||
T7 GET /admin/login session = {_flashes: [...]} Not authed ❌
|
||||
→ User sees login page (LOOP!) ❌
|
||||
```
|
||||
|
||||
### Fixed Flow
|
||||
|
||||
```
|
||||
Time Request Cookie State Auth State
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
T0 GET /dev/login (none) Not authed
|
||||
T1 ↓ set_cookie starpunk_session = "abc123xyz" Token set ✅
|
||||
T2 ↓ flash() session = {_flashes: [...]} Flask data ✅
|
||||
starpunk_session = "abc123xyz" Token preserved ✅
|
||||
T3 302 → /admin/ starpunk_session = "abc123xyz" Authed ✅
|
||||
session = {_flashes: [...]}
|
||||
T4 GET /admin/ starpunk_session = "abc123xyz" Authed ✅
|
||||
T5 ↓ @require_auth verify("abc123xyz") = {me: ...} SUCCESS ✅
|
||||
T6 Render dashboard starpunk_session = "abc123xyz" Authed ✅
|
||||
→ User sees dashboard ✅
|
||||
```
|
||||
|
||||
## Request/Response Detail
|
||||
|
||||
### Broken Request/Response Cycle
|
||||
|
||||
```
|
||||
REQUEST 1: GET /dev/login
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
RESPONSE 1:
|
||||
HTTP/1.1 302 Found
|
||||
Location: /admin/
|
||||
Set-Cookie: session={_flashes: [...]}; HttpOnly; SameSite=Lax
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
❌ Flask overwrote our auth token!
|
||||
|
||||
───────────────────────────────────────────────────────────────────
|
||||
|
||||
REQUEST 2: GET /admin/
|
||||
Cookie: session={_flashes: [...]}
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
❌ Sending Flask session data, not auth token!
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
RESPONSE 2:
|
||||
HTTP/1.1 302 Found
|
||||
Location: /admin/login
|
||||
❌ @require_auth rejected (no valid token)
|
||||
```
|
||||
|
||||
### Fixed Request/Response Cycle
|
||||
|
||||
```
|
||||
REQUEST 1: GET /dev/login
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
RESPONSE 1:
|
||||
HTTP/1.1 302 Found
|
||||
Location: /admin/
|
||||
Set-Cookie: starpunk_session=abc123xyz; HttpOnly; SameSite=Lax
|
||||
✅ Auth token in separate cookie
|
||||
Set-Cookie: session={_flashes: [...]}; HttpOnly; SameSite=Lax
|
||||
✅ Flask session in separate cookie
|
||||
|
||||
───────────────────────────────────────────────────────────────────
|
||||
|
||||
REQUEST 2: GET /admin/
|
||||
Cookie: starpunk_session=abc123xyz
|
||||
✅ Sending correct auth token!
|
||||
Cookie: session={_flashes: [...]}
|
||||
✅ Flask session data also sent (for flash messages)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
RESPONSE 2:
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/html
|
||||
|
||||
<html>
|
||||
<!-- Dashboard renders successfully! ✅ -->
|
||||
</html>
|
||||
```
|
||||
|
||||
## Code Comparison
|
||||
|
||||
### Setting the Cookie
|
||||
|
||||
```python
|
||||
# BEFORE (Broken)
|
||||
response.set_cookie(
|
||||
"session", # ❌ Conflicts with Flask
|
||||
session_token,
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="Lax",
|
||||
)
|
||||
|
||||
# AFTER (Fixed)
|
||||
response.set_cookie(
|
||||
"starpunk_session", # ✅ No conflict!
|
||||
session_token,
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="Lax",
|
||||
)
|
||||
```
|
||||
|
||||
### Reading the Cookie
|
||||
|
||||
```python
|
||||
# BEFORE (Broken)
|
||||
session_token = request.cookies.get("session")
|
||||
# Gets Flask session data, not our token! ❌
|
||||
|
||||
# AFTER (Fixed)
|
||||
session_token = request.cookies.get("starpunk_session")
|
||||
# Gets our auth token correctly! ✅
|
||||
```
|
||||
|
||||
## Why Flash Triggers the Problem
|
||||
|
||||
Flask's `flash()` function writes to the session:
|
||||
|
||||
```python
|
||||
# When you call:
|
||||
flash("DEV MODE: Logged in", "warning")
|
||||
|
||||
# Flask internally does:
|
||||
session['_flashes'] = [("warning", "DEV MODE: Logged in")]
|
||||
|
||||
# Which triggers:
|
||||
response.set_cookie("session", serialize(session), ...)
|
||||
|
||||
# This OVERWRITES any cookie named "session"!
|
||||
```
|
||||
|
||||
## The Key Insight
|
||||
|
||||
**Flask owns the `session` cookie name. We cannot use it.**
|
||||
|
||||
Flask reserves this cookie for its own session management (flash messages, session["key"] storage, etc.). When we try to use the same cookie name for our auth token, Flask overwrites it whenever session data is modified.
|
||||
|
||||
**Solution**: Use our own namespace → `starpunk_session`
|
||||
|
||||
## Architectural Principle Established
|
||||
|
||||
**Cookie Naming Convention**: All application cookies MUST use an application-specific prefix to avoid conflicts with framework-reserved names.
|
||||
|
||||
- Framework cookies: `session`, `csrf_token`, etc. (owned by Flask)
|
||||
- Application cookies: `starpunk_session`, `starpunk_*` (owned by StarPunk)
|
||||
|
||||
This separation ensures:
|
||||
1. No name collisions
|
||||
2. Clear ownership
|
||||
3. Easier debugging (you know which cookie is which)
|
||||
4. Standards compliance
|
||||
Reference in New Issue
Block a user