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:
105
app/main.py
105
app/main.py
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user