feat: phase 3 admin magic-link auth — tokens, sessions, rate limits, audit
End-to-end passwordless admin auth. /admin/login accepts an email, POSTs
mint a 256-bit magic-link token stored only as SHA-256 in
magic_link_tokens (15-min TTL, single-use via atomic rowcount UPDATE).
Resend delivers the link; in dev with no API key, EmailService logs a
structured magic_link_dev_fallback event with the URL so the flow works
offline. /admin/auth/consume/{token} verifies, upserts a users row
(display_name from email local-part), creates a sessions row, and drops
an itsdangerous-signed cb_session cookie (HttpOnly, SameSite=Lax, Secure
in prod). /admin renders a placeholder "Welcome, <name>" page pending
Phase 4 CMS. /admin/logout flips revoked_at rather than deleting the row
to preserve the audit trail.
Rate limits use SlowAPI's in-memory limiter (5/15min/IP on login,
20/15min/IP on consume) plus a DB per-email count to catch
IP-rotating abuse. ADMIN_EMAILS enforces allowlist; non-allowlisted
submissions return the same "check your inbox" page with no token
inserted and no email sent (anti-enumeration). Every event lands in
auth_events via AuditService: link_requested, link_consumed,
consume_failed, session_created, session_revoked, rate_limited.
Add a production config validator refusing empty RESEND_API_KEY,
RESEND_FROM, or ADMIN_EMAILS; add PUBLIC_BASE_URL for email link
construction. CSRF deferred to Phase 6 per roadmap scoping; logout
handler marked # TODO(phase-6-csrf).
Mark Phase 3 complete in docs/ROADMAP.md.
This commit is contained in:
@@ -113,15 +113,49 @@ High-level phased plan. Each phase ends in a mergeable `dev` state and a passing
|
||||
- **No new packages.** All deps were already pinned in Phase 0's `requirements.txt`.
|
||||
- **Verification run:** `python -c "from app.main import app"` ✓ · `pytest -q` 36 passed ✓ · fresh-boot smoke: `/` shows welcome title, `/about` shows seeded Markdown, `/healthz` 200 ✓ · `PRAGMA journal_mode=wal` ✓ · second boot logs `migrations_up_to_date` + `seed_skipped`, table counts stay `users=1 pages=1 posts=1` ✓ · `docker compose config` exit 0 ✓.
|
||||
|
||||
## Phase 3 — Admin Auth (Magic Link)
|
||||
## Phase 3 — Admin Auth (Magic Link) ✅
|
||||
|
||||
- `/admin/login` — email-only form.
|
||||
- POST creates a magic-link token (256-bit, hashed at rest, 15-min TTL, single-use), sends via Resend.
|
||||
- Click link → create signed session cookie (30-day) → redirect `/admin`.
|
||||
- Logout revokes the session row (does not delete — audit trail).
|
||||
- Rate limits (SlowAPI): 5 / 15 min / IP *and* / email.
|
||||
- Allowlist from `ADMIN_EMAILS` env var. Non-allowlisted addresses: return same success message, send no email (no user enumeration).
|
||||
- Audit log row for every auth event.
|
||||
**Completed:** 2026-04-21
|
||||
|
||||
**Summary:** Passwordless admin auth end-to-end: email form → 256-bit magic-link token (SHA-256 at rest, 15-min TTL, atomic single-use consume) → Resend email with dev-log fallback → itsdangerous-signed server-side session cookie (30d) → `/admin` landing → logout revokes the row without deleting. SlowAPI per-IP + DB per-email rate limits, `ADMIN_EMAILS` allowlist with anti-enumeration, every event audited to `auth_events`.
|
||||
|
||||
**Key files:**
|
||||
- `app/services/audit.py` — `AuditService.record(event_type, ...)` writes an `auth_events` row + mirrors the event to structlog; `detail` is JSON. Referenced session by last-6 hash chars only.
|
||||
- `app/services/email.py` — `EmailService.send_magic_link(...)`: renders HTML + text templates, posts via Resend when `resend_api_key` set, otherwise logs `magic_link_dev_fallback` with the URL (dev shortcut). Never raises from the request path; production startup refuses to boot without the key.
|
||||
- `app/services/sessions.py` — `SessionService(engine, signer, settings)` — `create/lookup/revoke`. Raw session ID exists only in memory and the signed cookie; DB stores `sha256(raw)`. Cookie `cb_session`, `HttpOnly=True`, `SameSite=lax`, `Secure` ON in prod / OFF in dev, `Path=/`, `Max-Age=SESSION_MAX_DAYS*86400`.
|
||||
- `app/services/auth.py` — `AuthService.request_link` (allowlist check → DB per-email rate-limit count → insert token row → send email → audit) and `AuthService.consume` (atomic `UPDATE magic_link_tokens SET used_at WHERE token_hash AND used_at IS NULL AND expires_at>now`, then upsert `users` row, then `SessionService.create`). Audit writes live outside the consume transaction to sidestep SQLite's single-writer semantics.
|
||||
- `app/services/rate_limit.py` — module-level `limiter = Limiter(key_func=get_remote_address, storage_uri="memory://")`. Singleton because `@limiter.limit` decorates at import time.
|
||||
- `app/routes/admin.py` — all admin HTTP routes; `POST /admin/login` decorated `@limiter.limit("5/15 minutes")`, `GET /admin/auth/consume/{token}` decorated `@limiter.limit("20/15 minutes")`.
|
||||
- `app/dependencies/auth.py` — `get_current_user` and `require_admin` (raises `HTTPException(status_code=303, headers={"Location": "/admin/login"})`).
|
||||
- `app/templates/admin/` — `base.html`, `login.html`, `login_sent.html`, `login_failed.html`, `index.html`, `rate_limited.html`.
|
||||
- `app/templates/emails/magic_link.html` + `magic_link.txt` — magic-link email bodies.
|
||||
- `tests/test_auth_service.py`, `test_admin_routes.py`, `test_rate_limit.py`, `test_session_service.py`, `test_email_service.py` — 25 new tests covering token lifecycle, single-use, expiry, allowlist anti-enumeration, signed-cookie round-trip, revoke, user upsert, IP + DB email limits, dev email fallback, production config refusal.
|
||||
- `app/main.py` — builds `URLSafeTimedSerializer(salt="session")`, instantiates audit/email/sessions/auth onto `app.state`, installs SlowAPI `RateLimitExceeded` handler that renders `admin/rate_limited.html` with 429 and audits `rate_limited` with `scope="ip"`, includes admin router.
|
||||
- `app/config.py` — added `public_base_url` field (default `http://127.0.0.1:8000`); added `_require_auth_config_in_production` model validator: production boot refuses empty `RESEND_API_KEY` / `RESEND_FROM` / `ADMIN_EMAILS`.
|
||||
- `.env.example` — added `PUBLIC_BASE_URL=http://127.0.0.1:8000`.
|
||||
|
||||
**Endpoints created:**
|
||||
- `GET /admin/login` — email-only login form (public, no CSRF — pre-auth).
|
||||
- `POST /admin/login` — rate-limited (5/15min/IP + 5/15min/email via DB count). Always renders `login_sent.html` regardless of allowlist (anti-enumeration). Audit row `link_requested` with `{"allowlisted": bool}` every time.
|
||||
- `GET /admin/auth/consume/{token}` — rate-limited (20/15min/IP). Atomic single-use consume; on success sets `cb_session` cookie + 303 to `/admin`; on failure renders generic `login_failed.html` (no reason leakage).
|
||||
- `GET /admin` — `require_admin`; renders placeholder `index.html` with `{{ user.display_name }}` + logout form. Phase 4 replaces with real CMS dashboard.
|
||||
- `POST /admin/logout` — `require_admin`; flips `sessions.revoked_at = now`, clears cookie with `Max-Age=0`, 303 to `/admin/login`. Row preserved for audit. Marked `# TODO(phase-6-csrf)` — SameSite=Lax blocks cross-site POSTs in current browsers; Phase 6 adds a double-submit token.
|
||||
|
||||
**Key details:**
|
||||
- **Hash-at-rest for both tokens and session IDs.** DB stores `sha256(raw).hexdigest()`; raw values never persisted, never logged. Audit detail only references last-6 hash chars for correlation.
|
||||
- **Cookie signed with itsdangerous `URLSafeTimedSerializer(secret_key, salt="session")`.** Forged cookie fails signature check before any DB lookup.
|
||||
- **Anti-enumeration verified:** non-allowlisted POST → same 200 HTML response, zero rows inserted into `magic_link_tokens`, `link_requested` audit logs `{"allowlisted": false}`.
|
||||
- **Rate limit verified:** 5 POSTs succeed + 6th returns 429 from same IP; DB per-email count applied even when SlowAPI would allow.
|
||||
- **User auto-upsert on consume:** first successful consume inserts a `users` row with `display_name = local-part.title()` (e.g. `driver@example.com` → "Driver"), `active=1`; subsequent logins update `last_login_at`.
|
||||
- **Dev Resend fallback:** when `resend_api_key` is falsy, `EmailService` emits `magic_link_dev_fallback` structured log with the full URL and returns cleanly — never 500s the request path.
|
||||
- **Production config guardrails:** `app_env == "production"` requires non-empty `RESEND_API_KEY`, `RESEND_FROM`, `ADMIN_EMAILS`. Missing any → `ValueError` at startup.
|
||||
- **Single-writer deadlock avoided:** `AuthService.consume` does the atomic token update inside `engine.begin()`, then captures outcome flags, then opens separate transactions for audit + session creation. Correctness unchanged (single-use guarantee via `rowcount==1`).
|
||||
- **Audit trail smoke-tested rows:** `link_requested` (per attempt), `link_consumed`, `session_created`, `session_revoked`, `rate_limited`, `consume_failed` — all emitted during driver verification.
|
||||
- **Phase 4 hooks ready:** `require_admin` dependency + `get_current_user` are callable from any admin route. Admin CMS POSTs will reuse them.
|
||||
- **Phase 6 TODO markers** on the logout handler flag the CSRF pickup point.
|
||||
|
||||
**Verification run:**
|
||||
`python -c "from app.main import app"` ✓ · `pytest -q` 61 passed ✓ · end-to-end smoke (fresh DB, driver allowlisted): login form 200, POST login 200, dev-log URL captured, consume 200 + cookie set, `/admin` 200 with "Welcome, Driver", logout 303, post-logout `/admin` 303 to login ✓ · IP rate limit fires at 5 (6th is 429) ✓ · non-allowlisted POST 200 + zero tokens in DB ✓ · `auth_events` shows `link_requested`/`link_consumed`/`session_created`/`session_revoked`/`rate_limited` rows as expected ✓ · `docker compose config` exit 0 ✓.
|
||||
|
||||
## Phase 4 — Admin CMS
|
||||
|
||||
|
||||
Reference in New Issue
Block a user