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:
2026-04-21 16:20:51 -05:00
parent 4b088e5045
commit 59dea99079
25 changed files with 2673 additions and 15 deletions

View File

@@ -92,6 +92,20 @@ class Settings(BaseSettings):
session_max_days: int = Field(default=30, ge=1, le=365)
magic_link_ttl_min: int = Field(default=15, ge=1, le=60)
# --- Public URL for link construction ---------------------------------
# Used to build the absolute URL emailed in magic-link auth. Defaults
# to the local uvicorn address so dev flows work out of the box; the
# production validator below forbids an un-set value only implicitly
# (if the site is served off 127.0.0.1 in prod the deploy is broken
# for reasons unrelated to this field).
public_base_url: str = Field(
default="http://127.0.0.1:8000",
description=(
"Absolute base URL (scheme+host+port) used to build links in "
"outbound emails, e.g. the magic-link URL."
),
)
# --- Build metadata ----------------------------------------------------
# Injected at Docker build time via an ARG/ENV. Surfaced via /healthz so
# operators can confirm which build is live.
@@ -136,6 +150,35 @@ class Settings(BaseSettings):
)
return self
@model_validator(mode="after")
def _require_auth_config_in_production(self) -> "Settings":
"""Ensure auth-critical settings are populated in production.
Security control: magic-link auth depends on Resend to deliver
one-time login tokens and on the admin allowlist to gate access.
A production deploy that's missing any of these would either
silently fall back to the dev log (exposing login URLs in logs)
or accept an empty allowlist (locking the site open to nobody
but also preventing any admin from logging in). Either outcome
is a Phase 3 regression; fail fast instead.
"""
if self.app_env != "production":
return self
missing: list[str] = []
if not self.resend_api_key:
missing.append("RESEND_API_KEY")
if not self.resend_from:
missing.append("RESEND_FROM")
if not self.admin_emails or not self.admin_emails_list:
missing.append("ADMIN_EMAILS")
if missing:
raise ValueError(
"Production configuration is missing required values: "
+ ", ".join(missing)
+ ". These are needed for magic-link admin auth."
)
return self
@lru_cache(maxsize=1)
def get_settings() -> Settings:

View File

@@ -0,0 +1,8 @@
"""FastAPI dependency helpers.
Route-level ``Depends(...)`` functions that don't belong to a single
service live here. Phase 3 introduces ``app.dependencies.auth`` for
``get_current_user`` / ``require_admin``.
"""
from __future__ import annotations

89
app/dependencies/auth.py Normal file
View File

@@ -0,0 +1,89 @@
"""Auth dependencies for admin routes.
Two ``Depends(...)`` helpers:
- :func:`get_current_user` — returns a :class:`User` or ``None`` based
on the signed ``cb_session`` cookie. Never raises.
- :func:`require_admin` — same lookup but raises an HTTP 303 redirect
to ``/admin/login`` if no user is authenticated. Used by every route
that must be logged-in.
Cookie handling
---------------
The cookie is read via ``request.cookies`` (Starlette strips secure /
httponly flags off by the time the app sees it; they only affect how
the browser stores and presents the cookie). Unsigning, hashing, and
DB lookup are delegated to :class:`app.services.sessions.SessionService`.
"""
from __future__ import annotations
from typing import Optional
from fastapi import Depends, HTTPException, Request
from sqlalchemy import text
from app.models.entities import User
from app.models.mappers import row_to_user
from app.services.sessions import COOKIE_NAME, SessionService
def _get_session_service(request: Request) -> SessionService:
"""Return the app-scoped :class:`SessionService` for DI.
Private to this module — routes and other dependencies resolve the
service via :func:`get_current_user` / :func:`require_admin` rather
than reaching across the dependency graph.
"""
return request.app.state.session_service
def get_current_user(
request: Request,
sessions: SessionService = Depends(_get_session_service),
) -> Optional[User]:
"""Return the authenticated :class:`User` or ``None``.
Never raises. A malformed / expired / revoked cookie simply
resolves to ``None`` so that un-authed viewers can hit admin
login pages without tripping an exception handler.
"""
cookie_value = request.cookies.get(COOKIE_NAME)
session = sessions.lookup(cookie_value)
if session is None:
return None
# We need the user row to render "Welcome, <display_name>" on the
# admin index. Query directly here instead of adding a full
# UserService for one call site.
engine = request.app.state.engine
with engine.connect() as conn:
row = conn.execute(
text(
"SELECT id, email, display_name, created_at,"
" last_login_at, active"
" FROM users WHERE id = :id AND active = 1 LIMIT 1"
),
{"id": session.user_id},
).mappings().first()
if row is None:
return None
return row_to_user(row)
def require_admin(
user: Optional[User] = Depends(get_current_user),
) -> User:
"""Return the authenticated user or redirect to login.
Uses a 303 "See Other" so the browser switches to GET on the
followup request — correct behavior for both initial page loads
and the post-consume redirect chain.
"""
if user is None:
raise HTTPException(
status_code=303,
headers={"Location": "/admin/login"},
)
return user

View File

@@ -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.

275
app/routes/admin.py Normal file
View File

@@ -0,0 +1,275 @@
"""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

119
app/services/audit.py Normal file
View File

@@ -0,0 +1,119 @@
"""Append-only auth audit log service.
Writes one row per auth event into the ``auth_events`` table. The rest of
the auth stack calls :meth:`AuditService.record` to persist a structured,
queryable audit trail without having to know the SQL or the row schema.
Security notes
--------------
- NEVER pass raw tokens, raw session IDs, or email bodies into ``detail``.
Correlate sessions via the last 6 hex chars of their stored hash, never
the full hash and never the raw value (CWE-200).
- ``detail`` is persisted as JSON text; the schema column is ``TEXT NOT
NULL DEFAULT '{}'`` and the writer always provides a valid JSON object.
- All writes go through parameterized SQL with ``sqlalchemy.text``
``:bind`` parameters; no string interpolation (CWE-89).
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, Mapping, Optional
import structlog
from sqlalchemy import Engine, text
_log = structlog.get_logger(__name__)
class AuditService:
"""Persist rows into ``auth_events``.
The service is intentionally tiny: one write method plus a helper
fetcher used by tests. No caching (this is an append-only audit
log and reads are rare).
"""
def __init__(self, engine: Engine) -> None:
"""Store the shared SQLAlchemy engine by reference.
The service never opens its own engine — it reuses the one
wired on ``app.state.engine``.
"""
self._engine: Engine = engine
def record(
self,
event_type: str,
*,
email: Optional[str] = None,
user_id: Optional[int] = None,
ip: str = "",
user_agent: str = "",
detail: Optional[Mapping[str, Any]] = None,
) -> None:
"""Insert a single audit row.
Parameters
----------
event_type:
One of the Phase 3 event types: ``link_requested``,
``link_consumed``, ``consume_failed``, ``session_created``,
``session_revoked``, ``rate_limited``.
email:
Submitted / target email (nullable when the event doesn't
have one, e.g. session_revoked where we key off user_id).
user_id:
Foreign key into ``users``; nullable for pre-auth events.
ip:
Client IP at time of event. Always captured when available;
empty string is acceptable for events originating outside
a request context (which Phase 3 does not currently emit,
but the column is NOT NULL and we want the door closed).
user_agent:
Client UA at time of event. Same NOT-NULL rationale as ``ip``.
detail:
Event-specific structured context (dict-like). Serialized
to a compact JSON string. Defaults to ``{}`` when absent.
NEVER put a raw token or session ID here — only hashes (or
their last 6 chars) and other non-sensitive metadata.
"""
# Always serialize to JSON text; the DB column enforces NOT NULL
# with an empty-object default, and we honor that contract here
# rather than relying on the default.
detail_json = json.dumps(dict(detail) if detail is not None else {})
now_iso = datetime.now(timezone.utc).isoformat()
with self._engine.begin() as conn:
conn.execute(
text(
"INSERT INTO auth_events"
" (event_type, email, user_id, ip, user_agent,"
" created_at, detail)"
" VALUES (:event_type, :email, :user_id, :ip,"
" :user_agent, :created_at, :detail)"
),
{
"event_type": event_type,
"email": email,
"user_id": user_id,
"ip": ip or "",
"user_agent": user_agent or "",
"created_at": now_iso,
"detail": detail_json,
},
)
# Mirror the audit row to structured logs at INFO. We never log
# the raw token / session ID, only the same detail dict (which
# the caller already scrubbed) and the non-sensitive envelope.
_log.info(
"auth_event",
event_type=event_type,
email=email,
user_id=user_id,
detail=detail or {},
)

399
app/services/auth.py Normal file
View File

@@ -0,0 +1,399 @@
"""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

141
app/services/email.py Normal file
View File

@@ -0,0 +1,141 @@
"""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)

View File

@@ -0,0 +1,44 @@
"""SlowAPI rate-limiter wiring for auth endpoints.
Only one limiter is used process-wide; it's built in
:func:`create_limiter` and stored on ``app.state.limiter`` so the
``@limiter.limit`` decorator can pick it up from the request.
Storage is ``memory://``. At this scale (single-digit requests/second,
single container) a persistent backend is not worth the operational
cost, and the consequences of losing limiter state on restart are
acceptable — the DB-side per-email check (in
:mod:`app.services.auth`) catches sustained abuse across restarts.
"""
from __future__ import annotations
from slowapi import Limiter
from slowapi.util import get_remote_address
# Process-wide singleton. Module-level because SlowAPI's ``@limiter.limit``
# decorator has to be applied at endpoint-definition time (before the
# router is wired into the FastAPI app), and that has to reference the
# same limiter instance that the request path consults via
# ``request.app.state.limiter``.
#
# Storage is ``memory://``: in-process, single-container scale. Restarts
# drop in-flight counters — acceptable because the DB-side per-email
# check in :mod:`app.services.auth` backs this up for longer-lived abuse
# patterns.
limiter: Limiter = Limiter(
key_func=get_remote_address,
storage_uri="memory://",
)
def create_limiter() -> Limiter:
"""Return the process-wide :class:`slowapi.Limiter` singleton.
Kept as a function (rather than exposing the module-level
``limiter`` directly) to match the service-factory pattern used by
:class:`AuditService` / :class:`EmailService` / ... and to give
tests a hook to monkeypatch if they ever need a per-test limiter.
"""
return limiter

240
app/services/sessions.py Normal file
View File

@@ -0,0 +1,240 @@
"""Server-side session issuance, lookup, and revocation.
Sessions combine two storage layers:
1. **Database row** in ``sessions``. Stores ``sha256(raw)`` as
``token_hash`` (never the raw token), ``expires_at`` set to
``SESSION_MAX_DAYS`` from creation, and ``revoked_at`` for
soft-delete on logout.
2. **Signed cookie** (``cb_session``). Carries the raw session id
wrapped by :class:`itsdangerous.URLSafeTimedSerializer` with the
configured ``SECRET_KEY`` and salt ``session``. The signature
prevents tampering; the DB lookup prevents replay after logout.
Security and behavior rules
---------------------------
- Raw session IDs live only in memory and the outbound cookie. The DB
stores only the SHA-256 hash.
- ``Secure`` cookie flag is OFF in development (so plain-HTTP
``127.0.0.1`` works) and ON in production. All other flags
(``HttpOnly``, ``SameSite=Lax``, ``Path=/``) are always set.
- Revocation flips ``revoked_at`` but NEVER deletes the row; the audit
trail must survive logout.
"""
from __future__ import annotations
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
import structlog
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from sqlalchemy import Engine, text
from app.config import Settings
from app.models.entities import Session
from app.models.mappers import row_to_session
_log = structlog.get_logger(__name__)
# Cookie name used on every request. Shared constant so routes and
# dependencies don't drift.
COOKIE_NAME: str = "cb_session"
def _sha256(raw: str) -> str:
"""Return the hex SHA-256 of a raw token.
SHA-256 is explicitly permitted for non-password one-way hashing
(see docs/security.md CWE-327). Tokens here are already
high-entropy (256-bit ``secrets.token_urlsafe(32)``) so a single
pass is sufficient — we don't need a slow-hash KDF.
"""
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
class SessionService:
"""Create, look up, and revoke admin sessions."""
def __init__(
self,
engine: Engine,
signer: URLSafeTimedSerializer,
settings: Settings,
) -> None:
"""Store the engine, signer, and settings by reference.
Parameters
----------
engine:
Shared SQLAlchemy engine.
signer:
Pre-built ``itsdangerous.URLSafeTimedSerializer`` bound to
``settings.secret_key`` with salt ``"session"``. Injected
rather than constructed here so the same instance is used
across all consumers (and tests can monkeypatch).
settings:
Application settings; we read ``session_max_days`` and
``app_env`` (to decide the ``Secure`` cookie flag).
"""
self._engine: Engine = engine
self._signer: URLSafeTimedSerializer = signer
self._settings: Settings = settings
# ------------------------------------------------------------------
# Creation
# ------------------------------------------------------------------
def create(
self,
*,
user_id: int,
ip: str,
user_agent: str,
) -> tuple[Session, str]:
"""Mint a new session row and return the signed cookie value.
Returns a ``(session, cookie_value)`` tuple. The caller is
responsible for attaching the cookie to the HTTP response with
the appropriate flags — use :meth:`cookie_params` for those.
"""
raw = secrets.token_urlsafe(32) # ≥256 bits of entropy
token_hash = _sha256(raw)
now = datetime.now(timezone.utc)
expires_at = now + timedelta(days=self._settings.session_max_days)
with self._engine.begin() as conn:
result = conn.execute(
text(
"INSERT INTO sessions"
" (user_id, token_hash, created_at, expires_at,"
" ip, user_agent)"
" VALUES (:user_id, :token_hash, :created_at,"
" :expires_at, :ip, :user_agent)"
),
{
"user_id": user_id,
"token_hash": token_hash,
"created_at": now.isoformat(),
"expires_at": expires_at.isoformat(),
"ip": ip or "",
"user_agent": user_agent or "",
},
)
new_id = int(result.lastrowid) # type: ignore[arg-type]
row = conn.execute(
text(
"SELECT id, user_id, token_hash, created_at, expires_at,"
" ip, user_agent, revoked_at"
" FROM sessions WHERE id = :id"
),
{"id": new_id},
).mappings().first()
if row is None: # pragma: no cover — insert just succeeded
raise RuntimeError("failed to reload just-inserted session row")
session = row_to_session(row)
cookie_value = self._signer.dumps(raw)
return session, cookie_value
# ------------------------------------------------------------------
# Lookup
# ------------------------------------------------------------------
def lookup(self, cookie_value: Optional[str]) -> Optional[Session]:
"""Resolve a cookie value to an active :class:`Session`.
Returns ``None`` on:
- missing cookie
- invalid signature
- expired signature (we reject if older than
``session_max_days``)
- no matching row
- session revoked or past its ``expires_at``
All failure modes intentionally return the same ``None`` so
callers cannot distinguish them (CWE-200).
"""
if not cookie_value:
return None
max_age_s = self._settings.session_max_days * 86400
try:
raw: str = self._signer.loads(cookie_value, max_age=max_age_s)
except SignatureExpired:
_log.info("session_cookie_expired")
return None
except BadSignature:
_log.info("session_cookie_bad_signature")
return None
token_hash = _sha256(raw)
now_iso = datetime.now(timezone.utc).isoformat()
with self._engine.connect() as conn:
row = conn.execute(
text(
"SELECT id, user_id, token_hash, created_at, expires_at,"
" ip, user_agent, revoked_at"
" FROM sessions"
" WHERE token_hash = :h"
" AND revoked_at IS NULL"
" AND expires_at > :now"
" LIMIT 1"
),
{"h": token_hash, "now": now_iso},
).mappings().first()
if row is None:
return None
return row_to_session(row)
# ------------------------------------------------------------------
# Revocation
# ------------------------------------------------------------------
def revoke(self, session: Session) -> None:
"""Mark a session as revoked (soft-delete).
The row stays in the DB so the audit trail remains intact; the
``revoked_at`` timestamp is what the ``lookup`` query filters
on.
"""
now_iso = datetime.now(timezone.utc).isoformat()
with self._engine.begin() as conn:
conn.execute(
text(
"UPDATE sessions SET revoked_at = :now"
" WHERE id = :id AND revoked_at IS NULL"
),
{"now": now_iso, "id": session.id},
)
# ------------------------------------------------------------------
# Cookie helpers
# ------------------------------------------------------------------
def cookie_params(self) -> dict:
"""Return kwargs for ``response.set_cookie`` matching our policy.
Values:
- ``key`` = :data:`COOKIE_NAME`
- ``httponly=True``
- ``samesite="lax"``
- ``secure`` = True only in production (plain-HTTP 127.0.0.1
needs ``Secure=False`` in dev)
- ``max_age`` = ``session_max_days * 86400``
- ``path="/"``
The caller supplies ``value`` separately.
"""
return {
"key": COOKIE_NAME,
"httponly": True,
"samesite": "lax",
"secure": self._settings.app_env == "production",
"max_age": self._settings.session_max_days * 86400,
"path": "/",
}

View File

@@ -0,0 +1,76 @@
{#
Minimal admin layout shell.
Reuses the public site's CSS (palette + typography) so Head Hen sees a
consistent visual language without us maintaining a second stylesheet.
Intentionally simpler than the public base: no marketing hero,
no multi-link primary nav — just the brand mark, the admin context
label, and a logout control when the viewer is authenticated.
Context the child template may override:
- title : <title> content
- content : main body
- user : app.models.entities.User | None
(passed by the index route; omitted on pre-auth pages)
#}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Admin &mdash; Chicken Babies R Us{% endblock %}</title>
<meta name="robots" content="noindex, nofollow">
<link rel="icon" href="{{ url_for('static', path='img/favicon.ico') }}" sizes="any">
<link rel="stylesheet" href="{{ url_for('static', path='css/site.css') }}">
</head>
<body class="admin-body">
<a class="skip-link" href="#main-content">Skip to main content</a>
<header class="site-header">
<div class="wrap site-header__wrap">
<a class="site-header__brand" href="/" aria-label="Chicken Babies R Us home">
<picture>
<source srcset="{{ url_for('static', path='img/logo.webp') }}" type="image/webp">
<img src="{{ url_for('static', path='img/logo.png') }}"
alt="Chicken Babies R Us"
height="48"
class="site-header__logo">
</picture>
</a>
<nav class="site-nav" aria-label="Admin">
<ul class="site-nav__list">
<li class="site-nav__item">
<span class="site-nav__link" aria-current="page">Admin</span>
</li>
{% if user is defined and user %}
<li class="site-nav__item">
{#
Plain POST form — no CSRF token yet. Phase 6 adds a
double-submit token; SameSite=Lax is sufficient in the
meantime.
#}
<form action="/admin/logout" method="post" class="site-nav__logout-form">
<button type="submit" class="btn btn--link">Log out</button>
</form>
</li>
{% endif %}
</ul>
</nav>
</div>
</header>
<main id="main-content" class="site-main" tabindex="-1">
<div class="wrap">
{% block content %}{% endblock %}
</div>
</main>
<footer class="site-footer">
<div class="wrap site-footer__wrap">
<p class="site-footer__tag">
Chicken Babies R Us &middot; Admin
</p>
</div>
</footer>
</body>
</html>

View File

@@ -0,0 +1,29 @@
{#
Admin landing page (post-login).
Phase 3 keeps this minimal — Phase 4 turns it into the CMS
dashboard (pages + posts + media). For now it's a welcome screen +
logout control (rendered in admin/base.html's nav since ``user`` is
present in context).
Context:
- user : app.models.entities.User (guaranteed by require_admin)
#}
{% extends "admin/base.html" %}
{% block title %}Welcome &mdash; Admin{% endblock %}
{% block content %}
<article class="page-article">
<header class="page-article__header">
<h1 class="page-article__title">Welcome, {{ user.display_name }}</h1>
</header>
<p>
You're logged in as <code>{{ user.email }}</code>. The dashboard
(pages, posts, media) lands in Phase 4.
</p>
<p>
<a href="/">&laquo; Back to the public site</a>
</p>
</article>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{#
Admin login form.
Single email input, no JS. If the submitted address is on the
allowlist (ADMIN_EMAILS env var), the POST handler emails a
one-time magic link; otherwise it silently succeeds without
sending (anti-enumeration).
Context:
- error : str | None (format validation errors only)
- email : str (pre-fill on re-render after format error)
#}
{% extends "admin/base.html" %}
{% block title %}Log in &mdash; Admin{% endblock %}
{% block content %}
<article class="page-article">
<header class="page-article__header">
<h1 class="page-article__title">Admin log in</h1>
</header>
<p>
Enter your admin email and we'll send a one-time login link
that expires after 15 minutes.
</p>
{% if error %}
<p class="admin-flash admin-flash--error" role="alert">{{ error }}</p>
{% endif %}
<form class="contact-form" action="/admin/login" method="post" novalidate>
<div class="contact-form__field">
<label for="admin-email">Email</label>
<input type="email"
id="admin-email"
name="email"
autocomplete="email"
value="{{ email or '' }}"
required>
</div>
<div class="contact-form__actions">
<button type="submit" class="btn btn--primary">Send login link</button>
</div>
</form>
</article>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{#
Generic failure page for GET /admin/auth/consume/{token}.
Deliberately does NOT distinguish between:
- token not found
- token expired
- token already consumed
The audit log has the real reason; the visitor only needs to know
they need a fresh link.
#}
{% extends "admin/base.html" %}
{% block title %}Login link invalid &mdash; Admin{% endblock %}
{% block content %}
<article class="page-article">
<header class="page-article__header">
<h1 class="page-article__title">Login link invalid or expired</h1>
</header>
<p>
That login link isn't valid any more. Links expire after 15
minutes and can only be used once.
</p>
<p>
<a href="/admin/login">Request a new link</a>
</p>
</article>
{% endblock %}

View File

@@ -0,0 +1,25 @@
{#
Post-submission page rendered after POST /admin/login.
Same copy for every outcome (allowlisted vs. not) to avoid leaking
which emails are real admins.
#}
{% extends "admin/base.html" %}
{% block title %}Check your inbox &mdash; Admin{% endblock %}
{% block content %}
<article class="page-article">
<header class="page-article__header">
<h1 class="page-article__title">Check your inbox</h1>
</header>
<p>
If your email is on the allowlist, you'll receive a login link
shortly. The link expires after 15 minutes and can only be used
once.
</p>
<p>
<a href="/admin/login">Request another link</a>
</p>
</article>
{% endblock %}

View File

@@ -0,0 +1,25 @@
{#
Rendered at HTTP 429 when either the SlowAPI IP limit or the DB-side
per-email limit trips on POST /admin/login.
Same template for both trigger paths — we don't want to tell the
submitter whether the limit was IP-wide or email-specific.
#}
{% extends "admin/base.html" %}
{% block title %}Too many attempts &mdash; Admin{% endblock %}
{% block content %}
<article class="page-article">
<header class="page-article__header">
<h1 class="page-article__title">Too many attempts</h1>
</header>
<p>
You've requested too many login links recently. Please wait a
few minutes before trying again.
</p>
<p>
<a href="/admin/login">Back to login</a>
</p>
</article>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{#
HTML body for the magic-link email.
Plain, minimal inline-styled markup — no external CSS because email
clients are hostile to it. The link text IS the URL so users can
verify the destination before clicking.
Context:
- display_name : str
- magic_link_url : str
- expires_at : ISO-8601 str
- ttl_min : int
#}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Admin login link &mdash; Chicken Babies R Us</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #2B3A42; max-width: 560px; margin: 0 auto; padding: 24px;">
<h1 style="font-size: 20px; margin: 0 0 16px;">Admin login link</h1>
<p>Hi {{ display_name }},</p>
<p>
Use the link below to log in to the Chicken Babies R Us admin.
The link works for {{ ttl_min }} minutes and can only be used once.
</p>
<p>
<a href="{{ magic_link_url }}" style="color: #5D8AA8; word-break: break-all;">
{{ magic_link_url }}
</a>
</p>
<p style="color: #6b7a80; font-size: 13px;">
Expires at {{ expires_at }}. If you didn't request this, you can
safely ignore the email &mdash; no action will be taken.
</p>
</body>
</html>

View File

@@ -0,0 +1,12 @@
{# Plaintext body for the magic-link email. Mirrors the HTML version. #}
Hi {{ display_name }},
Use the link below to log in to the Chicken Babies R Us admin.
The link works for {{ ttl_min }} minutes and can only be used once.
{{ magic_link_url }}
Expires at {{ expires_at }}.
If you didn't request this, you can safely ignore the email -- no
action will be taken.