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:
2026-04-21 16:20:51 -05:00
parent 4b088e5045
commit 59dea99079
25 changed files with 2673 additions and 15 deletions

View File

@@ -92,6 +92,20 @@ class Settings(BaseSettings):
session_max_days: int = Field(default=30, ge=1, le=365)
magic_link_ttl_min: int = Field(default=15, ge=1, le=60)
# --- Public URL for link construction ---------------------------------
# Used to build the absolute URL emailed in magic-link auth. Defaults
# to the local uvicorn address so dev flows work out of the box; the
# production validator below forbids an un-set value only implicitly
# (if the site is served off 127.0.0.1 in prod the deploy is broken
# for reasons unrelated to this field).
public_base_url: str = Field(
default="http://127.0.0.1:8000",
description=(
"Absolute base URL (scheme+host+port) used to build links in "
"outbound emails, e.g. the magic-link URL."
),
)
# --- Build metadata ----------------------------------------------------
# Injected at Docker build time via an ARG/ENV. Surfaced via /healthz so
# operators can confirm which build is live.
@@ -136,6 +150,35 @@ class Settings(BaseSettings):
)
return self
@model_validator(mode="after")
def _require_auth_config_in_production(self) -> "Settings":
"""Ensure auth-critical settings are populated in production.
Security control: magic-link auth depends on Resend to deliver
one-time login tokens and on the admin allowlist to gate access.
A production deploy that's missing any of these would either
silently fall back to the dev log (exposing login URLs in logs)
or accept an empty allowlist (locking the site open to nobody
but also preventing any admin from logging in). Either outcome
is a Phase 3 regression; fail fast instead.
"""
if self.app_env != "production":
return self
missing: list[str] = []
if not self.resend_api_key:
missing.append("RESEND_API_KEY")
if not self.resend_from:
missing.append("RESEND_FROM")
if not self.admin_emails or not self.admin_emails_list:
missing.append("ADMIN_EMAILS")
if missing:
raise ValueError(
"Production configuration is missing required values: "
+ ", ".join(missing)
+ ". These are needed for magic-link admin auth."
)
return self
@lru_cache(maxsize=1)
def get_settings() -> Settings: