Files
chicken_babies_site/app/services/email.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

142 lines
5.3 KiB
Python

"""Transactional email sender with a dev-mode log fallback.
Thin wrapper around the Resend API (``resend.Emails.send``). Renders
both HTML and plaintext magic-link bodies from Jinja templates to keep
copy out of Python code.
Security and UX rules
---------------------
- **Never raise on missing credentials in development.** A 500 from the
login POST would expose whether an email is on the allowlist
(successful sends would succeed, non-allowlisted "sends" would still
short-circuit) and it would also break local dev. In development we
log a ``magic_link_dev_fallback`` structured event with the full
magic-link URL so the developer can copy it.
- **Production must fail at startup** if Resend credentials are absent;
that validator lives in :class:`app.config.Settings`, not here.
- Never log the raw token on its own — only as part of the URL in the
dev fallback (which is the whole point of the fallback).
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
import structlog
from fastapi.templating import Jinja2Templates
from app.config import Settings
_log = structlog.get_logger(__name__)
class EmailService:
"""Send magic-link emails via Resend, with a dev-mode log fallback."""
def __init__(self, settings: Settings, templates: Jinja2Templates) -> None:
"""Store dependencies by reference.
Parameters
----------
settings:
Application settings; used to pick up ``resend_api_key`` /
``resend_from`` / ``app_env`` at send time so rotating them
at runtime (dev) works.
templates:
Shared Jinja2 environment. We reuse the app-level one so
template autoescape defaults and the search path match the
rest of the site.
"""
self._settings: Settings = settings
self._templates: Jinja2Templates = templates
def send_magic_link(
self,
*,
to: str,
url: str,
display_name: str,
ttl_min: int,
expires_at: datetime,
) -> None:
"""Send a magic-link email to ``to`` or log the URL in dev.
Behavior
--------
- If ``settings.resend_api_key`` is truthy, render both bodies
and send via Resend.
- Otherwise (development only — the production config validator
refuses to boot without a key), emit a structured log event
``magic_link_dev_fallback`` that includes the full URL.
Never raises in the dev fallback path; errors from the Resend
API surface as logged exceptions so the request-handler layer
always returns the same response shape regardless of whether
the email was actually sent (CWE-200 / anti-enumeration).
"""
# Build both template bodies first so any rendering error
# surfaces before we talk to the network.
ctx = {
"display_name": display_name,
"magic_link_url": url,
"expires_at": expires_at.isoformat(),
"ttl_min": ttl_min,
}
html_body = self._render("emails/magic_link.html", ctx)
text_body = self._render("emails/magic_link.txt", ctx)
api_key: Optional[str] = self._settings.resend_api_key
sender: Optional[str] = self._settings.resend_from
# Dev fallback path: no key configured. Log the URL at INFO so
# the developer can complete the flow, and return.
if not api_key or not sender:
_log.info(
"magic_link_dev_fallback",
to=to,
# Raw token is embedded in the URL; acceptable because
# this path ONLY runs in local dev (production validator
# refuses to boot without RESEND_API_KEY).
magic_link_url=url,
ttl_min=ttl_min,
)
return
# Real send. We import here to avoid taking a hard import-time
# dependency on `resend`'s module-level state during tests that
# never exercise the send path.
try:
import resend # type: ignore[import-untyped]
resend.api_key = api_key
resend.Emails.send(
{
"from": sender,
"to": to,
"subject": "Your Chicken Babies R Us admin login link",
"html": html_body,
"text": text_body,
}
)
_log.info("magic_link_email_sent", to=to)
except Exception: # noqa: BLE001
# Do NOT re-raise from the request path — see anti-enumeration
# note at module top. Log with redacted context.
_log.exception("magic_link_email_failed", to=to)
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _render(self, template_name: str, context: dict) -> str:
"""Render a Jinja template to a string.
We use the underlying Jinja environment directly so we get a
plain string back (``Jinja2Templates.TemplateResponse`` wraps
the output in an HTTP response, which is not what we want for
outbound email bodies).
"""
template = self._templates.env.get_template(template_name)
return template.render(**context)