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.
400 lines
15 KiB
Python
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
|