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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user