Files
chicken_babies_site/app/routes/admin.py
Phillip Tarrant 59dea99079 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.
2026-04-21 16:20:51 -05:00

276 lines
9.6 KiB
Python

"""Admin auth routes — magic-link login, consume, landing, logout.
Every handler here is deliberately thin: it delegates to
:class:`app.services.auth.AuthService` or
:class:`app.services.sessions.SessionService` and translates the
result into an HTTP response. No SQL, no crypto, no email building.
Anti-enumeration contract
-------------------------
POST /admin/login always renders the same ``login_sent.html`` template
with identical copy, identical status, and identical cookie state,
regardless of whether the submitted address is on the allowlist. The
only side-effects that differ are:
- token row inserted (allowlisted)
- email dispatched (allowlisted)
- audit row has ``allowlisted=true`` vs ``false``
GET /admin/auth/consume/{token} likewise uses a single failure page
for every bad-token reason (missing / unknown / expired / already
used). The specific reason lives only in the audit log.
"""
from __future__ import annotations
import re
from typing import Optional
import structlog
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from app.dependencies.auth import require_admin
from app.models.entities import User
from app.services.auth import AuthService, RateLimitedError
from app.services.rate_limit import limiter
from app.services.sessions import COOKIE_NAME, SessionService
router: APIRouter = APIRouter(tags=["admin"])
_log = structlog.get_logger(__name__)
# Lightweight email regex. We intentionally avoid ``email-validator``
# (not pinned in requirements.txt) and a full RFC-5322 parser; the goal
# is only to reject empty strings and obviously-not-an-email submissions
# before calling the allowlist. Full validation is unnecessary because
# non-allowlisted addresses are silently ignored anyway.
_EMAIL_RE: re.Pattern[str] = re.compile(
r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
)
def _get_templates(request: Request) -> Jinja2Templates:
"""Return the app-scoped :class:`Jinja2Templates`."""
return request.app.state.templates
def _get_auth_service(request: Request) -> AuthService:
"""Return the app-scoped :class:`AuthService`."""
return request.app.state.auth_service
def _get_session_service(request: Request) -> SessionService:
"""Return the app-scoped :class:`SessionService`."""
return request.app.state.session_service
def _client_ip(request: Request) -> str:
"""Best-effort client IP from the request.
Starlette already respects ``X-Forwarded-For`` via the proxy-headers
middleware Uvicorn installs with ``--proxy-headers``; that means
``request.client.host`` is the real client IP.
"""
return request.client.host if request.client else ""
def _user_agent(request: Request) -> str:
"""Return the submitted User-Agent header (empty string if missing)."""
return request.headers.get("user-agent", "")
# ---------------------------------------------------------------------------
# GET /admin/login
# ---------------------------------------------------------------------------
@router.get("/admin/login", response_class=HTMLResponse, summary="Admin login form")
def admin_login_form(
request: Request,
templates: Jinja2Templates = Depends(_get_templates),
) -> HTMLResponse:
"""Render the email-entry form.
No CSRF on the POST yet — see the ``# TODO(phase-6-csrf)`` in
``admin_logout``. This handler accepts any caller.
"""
return templates.TemplateResponse(
request,
"admin/login.html",
{"error": None, "email": ""},
)
# ---------------------------------------------------------------------------
# POST /admin/login
# ---------------------------------------------------------------------------
@router.post("/admin/login", response_class=HTMLResponse, summary="Request magic link")
@limiter.limit("5/15 minutes")
def admin_login_submit(
request: Request,
email: str = Form(default=""),
templates: Jinja2Templates = Depends(_get_templates),
auth: AuthService = Depends(_get_auth_service),
) -> Response:
"""Handle the login form submission.
Flow:
1. Normalize + validate the email format. On format error we
re-render ``login.html`` with a message (this is a UX concession
— an invalid email shape is not a successful submission, so
there's no enumeration risk).
2. Call :meth:`AuthService.request_link`.
3. Regardless of allowlist membership: render ``login_sent.html``
with identical copy. On per-email rate-limit: render
``rate_limited.html`` + 429.
Rate limiting
-------------
The ``@limiter.limit`` decoration is applied dynamically from
``app.main`` so tests can bypass it — see the route registration
helper in ``app.main``.
"""
normalized = (email or "").strip().lower()
if not normalized or not _EMAIL_RE.match(normalized):
# Format error surfaces as a flash; there's nothing to leak
# here because we haven't checked the allowlist yet.
return templates.TemplateResponse(
request,
"admin/login.html",
{
"error": "Please enter a valid email address.",
"email": email or "",
},
status_code=400,
)
ip = _client_ip(request)
ua = _user_agent(request)
try:
auth.request_link(email=normalized, ip=ip, user_agent=ua)
except RateLimitedError:
# Per-email DB-side limit tripped. The SlowAPI IP-level limit
# is handled separately via the registered exception handler.
return templates.TemplateResponse(
request,
"admin/rate_limited.html",
{},
status_code=429,
)
return templates.TemplateResponse(
request,
"admin/login_sent.html",
{},
)
# ---------------------------------------------------------------------------
# GET /admin/auth/consume/{token}
# ---------------------------------------------------------------------------
@router.get(
"/admin/auth/consume/{token}",
summary="Consume magic-link token",
)
@limiter.limit("20/15 minutes")
def admin_auth_consume(
request: Request,
token: str,
templates: Jinja2Templates = Depends(_get_templates),
auth: AuthService = Depends(_get_auth_service),
sessions: SessionService = Depends(_get_session_service),
) -> Response:
"""Consume a magic-link token and, on success, set the session cookie.
Failure path returns 400 + generic ``invalid or expired`` page.
Success path sets ``cb_session`` and 303-redirects to ``/admin``.
"""
ip = _client_ip(request)
ua = _user_agent(request)
result = auth.consume(raw_token=token, ip=ip, user_agent=ua)
if result is None:
# Generic failure response — the audit log has the real reason.
return templates.TemplateResponse(
request,
"admin/login_failed.html",
{},
status_code=400,
)
_user, _session, cookie_value = result
# 303 forces the browser to GET /admin on the next request.
response = RedirectResponse(url="/admin", status_code=303)
response.set_cookie(value=cookie_value, **sessions.cookie_params())
return response
# ---------------------------------------------------------------------------
# GET /admin
# ---------------------------------------------------------------------------
@router.get("/admin", response_class=HTMLResponse, summary="Admin landing")
def admin_index(
request: Request,
user: User = Depends(require_admin),
templates: Jinja2Templates = Depends(_get_templates),
) -> HTMLResponse:
"""Render the authenticated admin landing page.
``require_admin`` handles the redirect-to-login case; by the time
this handler runs, ``user`` is guaranteed to be populated.
"""
return templates.TemplateResponse(
request,
"admin/index.html",
{"user": user},
)
# ---------------------------------------------------------------------------
# POST /admin/logout
# ---------------------------------------------------------------------------
# TODO(phase-6-csrf): Require a double-submit CSRF token on this POST.
# Phase 6 will add middleware that validates a signed token against a
# cookie; until then SameSite=Lax blocks cross-site POSTs in all
# evergreen browsers, which is sufficient for this deploy's threat
# model.
@router.post("/admin/logout", summary="Log out the current admin")
def admin_logout(
request: Request,
user: User = Depends(require_admin),
sessions: SessionService = Depends(_get_session_service),
) -> Response:
"""Revoke the current session and clear the cookie.
Always issues a 303 redirect to ``/admin/login`` so browsers
transparently follow and show the login form (with no cookie).
"""
from app.services.sessions import SessionService as _SS # noqa: F401
# Look up the session again via the cookie so we can revoke it
# and emit a properly-correlated audit row.
cookie_value: Optional[str] = request.cookies.get(COOKIE_NAME)
session = sessions.lookup(cookie_value)
audit = request.app.state.audit_service
if session is not None:
sessions.revoke(session)
audit.record(
"session_revoked",
email=user.email,
user_id=user.id,
ip=_client_ip(request),
user_agent=_user_agent(request),
detail={"session_id": session.token_hash[-6:]},
)
response = RedirectResponse(url="/admin/login", status_code=303)
# Clear the cookie by setting an empty value with Max-Age=0 and the
# same Path so the browser actually removes it.
response.delete_cookie(key=COOKIE_NAME, path="/")
return response