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

400 lines
15 KiB
Python

"""Magic-link auth orchestration.
Glues token issuance / consumption, user auto-upsert on consume,
email delivery, session creation, and audit logging together behind
two methods:
- :meth:`AuthService.request_link` — handle POST /admin/login
- :meth:`AuthService.consume` — handle GET /admin/auth/consume/{token}
Security decisions are concentrated here:
- Raw tokens live only in memory, the outbound email URL, and the
single SHA-256 hash that ends up in the DB.
- The allowlist check is ALWAYS performed with lowercased emails.
- Non-allowlisted requests receive an identical response shape (handled
by the caller; this service just short-circuits the token issue and
still audits via ``link_requested`` with ``allowlisted=false``).
- Rate limiting has two layers:
1. SlowAPI IP-level decorator on the route (outside this module).
2. DB-side per-email COUNT inside :meth:`request_link` — returns a
sentinel that the route converts into an HTTP 429.
- Consume is atomic: an ``UPDATE ... WHERE token_hash=? AND used_at IS
NULL AND expires_at > ?`` with a ``rowcount == 1`` guard.
"""
from __future__ import annotations
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
import structlog
from sqlalchemy import Engine, text
from app.config import Settings
from app.models.entities import Session, User
from app.models.mappers import row_to_user
from app.services.audit import AuditService
from app.services.email import EmailService
from app.services.sessions import SessionService
_log = structlog.get_logger(__name__)
# Per-email rate limit: at most 5 tokens issued in a 15-minute window.
# This is the DB-backed layer that survives process restarts; the
# SlowAPI IP-level decorator on the route is the first line of defense.
_PER_EMAIL_WINDOW_MIN: int = 15
_PER_EMAIL_MAX: int = 5
def _sha256(raw: str) -> str:
"""Return the hex SHA-256 of a raw token.
SHA-256 for one-way hashing of high-entropy tokens is acceptable
(see docs/security.md CWE-327). These tokens are already ≥256 bits
from ``secrets.token_urlsafe(32)`` so a KDF is unnecessary.
"""
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
class RateLimitedError(Exception):
"""Raised when a per-email rate limit trips inside the service.
Surfaced to the route layer so it can emit a 429 + render the
``rate_limited.html`` template. Not used for IP-level limits —
those are handled by SlowAPI's own exception.
"""
class AuthService:
"""Request-link / consume orchestration for admin auth."""
def __init__(
self,
engine: Engine,
email: EmailService,
sessions: SessionService,
audit: AuditService,
settings: Settings,
) -> None:
"""Store collaborators by reference."""
self._engine: Engine = engine
self._email: EmailService = email
self._sessions: SessionService = sessions
self._audit: AuditService = audit
self._settings: Settings = settings
# ------------------------------------------------------------------
# request_link
# ------------------------------------------------------------------
def request_link(
self,
*,
email: str,
ip: str,
user_agent: str,
) -> None:
"""Handle POST /admin/login for a validated email.
Behavior
--------
1. Lowercase the email (allowlist comparison is case-insensitive).
2. Check the admin allowlist.
3. If NOT allowlisted: audit ``link_requested`` with
``allowlisted=false`` and return. No token row, no email.
4. If allowlisted: run the DB-side per-email rate-limit check.
On trip: audit ``rate_limited`` and raise
:class:`RateLimitedError`.
5. Otherwise: insert a fresh token row (hash at rest), audit
``link_requested`` with ``allowlisted=true``, and call
:meth:`EmailService.send_magic_link`.
Callers MUST render the same "check your inbox" page regardless
of the allowlist branch — see the admin route for the
anti-enumeration contract.
"""
email = email.strip().lower()
allowlisted = email in self._settings.admin_emails_list
# Always audit the request — this is the trail that catches
# non-allowlisted attempts without leaking that info back to
# the submitter.
if not allowlisted:
self._audit.record(
"link_requested",
email=email,
ip=ip,
user_agent=user_agent,
detail={"allowlisted": False},
)
return
# DB-side per-email rate limit. We run it AFTER the allowlist
# check so non-allowlisted spam doesn't cause extra queries.
cutoff = datetime.now(timezone.utc) - timedelta(
minutes=_PER_EMAIL_WINDOW_MIN
)
with self._engine.connect() as conn:
row = conn.execute(
text(
"SELECT COUNT(*) AS c FROM magic_link_tokens"
" WHERE email = :email AND created_at > :cutoff"
),
{"email": email, "cutoff": cutoff.isoformat()},
).mappings().first()
recent_count = int(row["c"]) if row is not None else 0
if recent_count >= _PER_EMAIL_MAX:
self._audit.record(
"rate_limited",
email=email,
ip=ip,
user_agent=user_agent,
detail={"scope": "email", "endpoint": "/admin/login"},
)
raise RateLimitedError("per-email token limit reached")
# Mint the token — raw lives only in memory + email URL.
raw = secrets.token_urlsafe(32)
token_hash = _sha256(raw)
now = datetime.now(timezone.utc)
expires_at = now + timedelta(
minutes=self._settings.magic_link_ttl_min
)
with self._engine.begin() as conn:
conn.execute(
text(
"INSERT INTO magic_link_tokens"
" (email, token_hash, created_at, expires_at,"
" request_ip)"
" VALUES (:email, :token_hash, :created_at,"
" :expires_at, :request_ip)"
),
{
"email": email,
"token_hash": token_hash,
"created_at": now.isoformat(),
"expires_at": expires_at.isoformat(),
"request_ip": ip or "",
},
)
self._audit.record(
"link_requested",
email=email,
ip=ip,
user_agent=user_agent,
detail={"allowlisted": True},
)
# Build the magic-link URL. Using a path param (not a query
# string) keeps the raw token out of many access-log formats.
base = self._settings.public_base_url.rstrip("/")
url = f"{base}/admin/auth/consume/{raw}"
display_name = email.split("@", 1)[0].title() or email
# EmailService never raises in dev; in prod it may log an
# exception but still return. Either way the request-path
# response is identical.
self._email.send_magic_link(
to=email,
url=url,
display_name=display_name,
ttl_min=self._settings.magic_link_ttl_min,
expires_at=expires_at,
)
# ------------------------------------------------------------------
# consume
# ------------------------------------------------------------------
def consume(
self,
*,
raw_token: str,
ip: str,
user_agent: str,
) -> Optional[tuple[User, Session, str]]:
"""Consume a magic-link token and issue a session.
Returns ``(user, session, cookie_value)`` on success, ``None``
on any invalid / expired / already-used / unknown token (the
caller should render the generic failure page with HTTP 400).
Concurrency safety: the UPDATE statement's WHERE clause is the
atomic guard — if two requests race with the same token, only
one will get ``rowcount == 1``. The loser is treated as a
replay.
"""
if not raw_token:
# Audit runs in its own transaction (see notes below) so we
# can record the event without opening a write lock we'd
# then try to re-enter from this service.
self._audit.record(
"consume_failed",
ip=ip,
user_agent=user_agent,
detail={"reason": "not_found"},
)
return None
token_hash = _sha256(raw_token)
now = datetime.now(timezone.utc)
now_iso = now.isoformat()
# ``engine.begin()`` holds a write lock on SQLite for its whole
# scope. AuditService opens its OWN transaction and would be
# blocked behind this one — so we do not call ``self._audit``
# until AFTER this block exits. Track the outcome locally.
reloaded = None
failure_reason: Optional[str] = None
email: Optional[str] = None
user_id: Optional[int] = None
# Atomic single-use: only mark the row used if it's still
# valid. rowcount tells us whether we were the one to claim it.
with self._engine.begin() as conn:
update_result = conn.execute(
text(
"UPDATE magic_link_tokens"
" SET used_at = :now"
" WHERE token_hash = :h"
" AND used_at IS NULL"
" AND expires_at > :now"
),
{"now": now_iso, "h": token_hash},
)
if update_result.rowcount != 1:
# Distinguish expired / used / not_found for the audit
# trail (not for the client response). Run an extra
# SELECT only on the failure path so the happy path
# stays a single write.
row = conn.execute(
text(
"SELECT expires_at, used_at FROM magic_link_tokens"
" WHERE token_hash = :h LIMIT 1"
),
{"h": token_hash},
).mappings().first()
if row is None:
failure_reason = "not_found"
elif row["used_at"] is not None:
failure_reason = "used"
else:
failure_reason = "expired"
else:
# Token claimed. Look up the email so we can upsert the user.
token_row = conn.execute(
text(
"SELECT email FROM magic_link_tokens"
" WHERE token_hash = :h LIMIT 1"
),
{"h": token_hash},
).mappings().first()
# Should always exist — the UPDATE just wrote to it.
if token_row is None: # pragma: no cover — impossible path
failure_reason = "not_found"
else:
email = token_row["email"]
# Upsert user. Existing row → bump last_login_at and
# re-activate; missing row → insert with titlecased
# local part as display_name and active=1.
user_row = conn.execute(
text("SELECT id FROM users WHERE email = :e"),
{"e": email},
).mappings().first()
if user_row is None:
display_name = email.split("@", 1)[0].title() or email
insert_result = conn.execute(
text(
"INSERT INTO users"
" (email, display_name, created_at,"
" last_login_at, active)"
" VALUES (:email, :display_name, :created_at,"
" :last_login_at, 1)"
),
{
"email": email,
"display_name": display_name,
"created_at": now_iso,
"last_login_at": now_iso,
},
)
user_id = int(insert_result.lastrowid) # type: ignore[arg-type]
else:
user_id = int(user_row["id"])
conn.execute(
text(
"UPDATE users"
" SET last_login_at = :now, active = 1"
" WHERE id = :id"
),
{"now": now_iso, "id": user_id},
)
# Reload the user row so we return a fully-populated
# entity.
reloaded = conn.execute(
text(
"SELECT id, email, display_name, created_at,"
" last_login_at, active"
" FROM users WHERE id = :id"
),
{"id": user_id},
).mappings().first()
# --- Post-transaction: audit + session create --------------------
# Running these AFTER the transaction closes avoids the SQLite
# single-writer deadlock that would happen if AuditService tried
# to open a new write connection from inside the block above.
if failure_reason is not None:
self._audit.record(
"consume_failed",
ip=ip,
user_agent=user_agent,
detail={"reason": failure_reason},
)
return None
if reloaded is None: # pragma: no cover — impossible path
return None
user = row_to_user(reloaded)
# Session creation runs in its own transaction now that the
# consume write lock has been released.
session, cookie_value = self._sessions.create(
user_id=user.id, ip=ip, user_agent=user_agent
)
self._audit.record(
"link_consumed",
email=email,
user_id=user.id,
ip=ip,
user_agent=user_agent,
detail={"user_id": user.id},
)
self._audit.record(
"session_created",
email=email,
user_id=user.id,
ip=ip,
user_agent=user_agent,
# last 6 hex chars of the stored hash — enough to correlate,
# not enough to reverse.
detail={
"session_id": session.token_hash[-6:],
"user_id": user.id,
},
)
return user, session, cookie_value