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

@@ -13,6 +13,17 @@ Phase 2 additions:
- Run the idempotent seed (welcome post, About page, system user).
- Instantiate :class:`PostService` and :class:`PageService` and
expose them on ``app.state`` for route-level DI.
Phase 3 additions:
- Build an ``itsdangerous.URLSafeTimedSerializer`` on
``settings.secret_key`` and attach to ``app.state``.
- Instantiate :class:`AuditService`, :class:`EmailService`,
:class:`SessionService`, :class:`AuthService` and attach them to
``app.state``.
- Create a SlowAPI :class:`Limiter` and register the
``RateLimitExceeded`` exception handler (renders
``admin/rate_limited.html`` + HTTP 429 + audit row).
- Include the admin router.
"""
from __future__ import annotations
@@ -20,19 +31,27 @@ from __future__ import annotations
from pathlib import Path
import structlog
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from itsdangerous import URLSafeTimedSerializer
from slowapi.errors import RateLimitExceeded
from app import __version__
from app.config import get_settings
from app.db import build_engine, run_migrations
from app.logging_config import configure_logging
from app.models.seed import run_seed
from app.routes.admin import router as admin_router
from app.routes.health import router as health_router
from app.routes.public import router as public_router
from app.services.audit import AuditService
from app.services.auth import AuthService
from app.services.email import EmailService
from app.services.pages import PageService
from app.services.posts import PostService
from app.services.rate_limit import create_limiter
from app.services.sessions import SessionService
# Resolve the package root once so template / static paths stay correct
@@ -53,12 +72,12 @@ def create_app() -> FastAPI:
3. Build the SQLAlchemy engine and install the PRAGMA listener.
4. Apply SQL migrations (idempotent — no-op after first boot).
5. Run the seed (idempotent — marked via ``schema_migrations``).
6. Instantiate :class:`PostService` / :class:`PageService` and
attach them to ``app.state`` so route dependencies can resolve
them via ``request.app.state``.
6. Instantiate services and attach them to ``app.state`` so route
dependencies can resolve them via ``request.app.state``.
7. Mount static files, attach the shared :class:`Jinja2Templates`,
and register routers.
8. Emit a single ``app_started`` structured log event.
and register routers (including admin).
8. Wire the SlowAPI limiter and its exception handler.
9. Emit a single ``app_started`` structured log event.
"""
# Parse + validate configuration first so a bad environment fails fast
# with a clear pydantic error before we touch logging / FastAPI.
@@ -99,7 +118,8 @@ def create_app() -> FastAPI:
# would be circular once admin/auth routers are added in later
# phases). Route handlers pull it via a ``Depends(get_templates)``
# function defined next to the routes.
application.state.templates = Jinja2Templates(directory=_TEMPLATES_DIR)
templates = Jinja2Templates(directory=_TEMPLATES_DIR)
application.state.templates = templates
# Store the engine + services on ``app.state`` so the
# dependency-injection helpers in :mod:`app.services.*` can find
@@ -108,10 +128,46 @@ def create_app() -> FastAPI:
application.state.post_service = PostService(engine)
application.state.page_service = PageService(engine)
# --- Phase 3 wiring -----------------------------------------------------
# itsdangerous signer: signs (and later verifies) session-cookie
# values using SECRET_KEY and the salt "session". The same instance
# is shared by every request — cheap to construct, no state beyond
# the key.
signer = URLSafeTimedSerializer(settings.secret_key, salt="session")
application.state.signer = signer
# Audit first — EmailService and AuthService both depend on it
# (EmailService indirectly via the request-path contract: failures
# are logged, never raised).
audit_service = AuditService(engine)
email_service = EmailService(settings, templates)
session_service = SessionService(engine, signer, settings)
auth_service = AuthService(
engine, email_service, session_service, audit_service, settings
)
application.state.audit_service = audit_service
application.state.email_service = email_service
application.state.session_service = session_service
application.state.auth_service = auth_service
# SlowAPI limiter + exception handler. The limiter is a module-level
# singleton in app.services.rate_limit (because @limiter.limit has
# to be applied at endpoint-definition time, before include_router).
# We still attach it to app.state so SlowAPI's request-path
# middleware can reach it via request.app.state.limiter.
limiter = create_limiter()
application.state.limiter = limiter
application.add_exception_handler(
RateLimitExceeded, _make_rate_limit_handler(templates, audit_service)
)
# Register routers. Kept explicit (no dynamic discovery) so the set of
# mounted endpoints is trivially auditable.
application.include_router(health_router)
application.include_router(public_router)
application.include_router(admin_router)
# Single structured startup event. Do NOT include secret material.
logger = structlog.get_logger(__name__)
@@ -125,6 +181,41 @@ def create_app() -> FastAPI:
return application
def _make_rate_limit_handler(
templates: Jinja2Templates,
audit_service: AuditService,
):
"""Build the FastAPI exception handler for ``RateLimitExceeded``.
Renders ``admin/rate_limited.html`` at HTTP 429 and writes a
``rate_limited`` audit row scoped to the IP path. Email-scope
rate-limits are handled inside AuthService and don't come through
this handler.
"""
async def _handler(request: Request, exc: RateLimitExceeded):
# Best-effort endpoint path for the audit detail; the limiter
# doesn't surface a structured endpoint name so we use the URL
# path which is already stable / non-sensitive.
endpoint = request.url.path
ip = request.client.host if request.client else ""
ua = request.headers.get("user-agent", "")
audit_service.record(
"rate_limited",
ip=ip,
user_agent=ua,
detail={"scope": "ip", "endpoint": endpoint},
)
return templates.TemplateResponse(
request,
"admin/rate_limited.html",
{},
status_code=429,
)
return _handler
# Module-level ASGI handle. Uvicorn / gunicorn import this as
# ``app.main:app``. Building it at import time is intentional: it fails
# loudly at container start if configuration is invalid.