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.
142 lines
5.3 KiB
Python
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)
|