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:
142
tests/test_rate_limit.py
Normal file
142
tests/test_rate_limit.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Rate-limit tests for the admin auth flow.
|
||||
|
||||
Covers:
|
||||
- IP-level SlowAPI limit: 6th POST /admin/login from the same IP within
|
||||
15 minutes returns 429 + ``rate_limited.html`` body.
|
||||
- Per-email DB limit: 6th POST /admin/login for the same email (even
|
||||
from different IPs — which TestClient can't really simulate without
|
||||
middleware tricks, so we directly insert 5 recent tokens to prime
|
||||
the DB) returns 429.
|
||||
- Every 429 path writes a ``rate_limited`` audit event.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_app(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> Iterator[tuple[TestClient, dict]]:
|
||||
"""Build a fresh FastAPI app wired to a tmp DB + captured email URL."""
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path}/admin.db")
|
||||
monkeypatch.setenv("ADMIN_EMAILS", "headhen@example.com")
|
||||
monkeypatch.setenv("APP_ENV", "development")
|
||||
monkeypatch.setenv(
|
||||
"SECRET_KEY", "test-only-secret-key-0123456789abcdef-XYZ"
|
||||
)
|
||||
monkeypatch.setenv("RESEND_API_KEY", "")
|
||||
monkeypatch.setenv("PUBLIC_BASE_URL", "http://testserver")
|
||||
|
||||
from app import config as _config
|
||||
|
||||
_config.get_settings.cache_clear()
|
||||
|
||||
import app.main as main_module
|
||||
|
||||
importlib.reload(main_module)
|
||||
app = main_module.app
|
||||
|
||||
captured: dict = {"urls": []}
|
||||
app.state.email_service.send_magic_link = lambda **kw: captured["urls"].append( # type: ignore[assignment]
|
||||
kw["url"]
|
||||
)
|
||||
|
||||
from app.services.rate_limit import limiter
|
||||
|
||||
limiter.reset()
|
||||
|
||||
with TestClient(app) as client:
|
||||
yield client, captured
|
||||
|
||||
_config.get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_ip_rate_limit_trips_on_sixth(admin_app) -> None:
|
||||
"""Five POSTs succeed; the sixth from the same IP returns 429."""
|
||||
client, _ = admin_app
|
||||
|
||||
# Use a non-allowlisted email so we don't bump the DB per-email
|
||||
# limit at the same time — isolates the IP-level limit.
|
||||
for i in range(5):
|
||||
resp = client.post(
|
||||
"/admin/login", data={"email": f"nobody{i}@example.com"}
|
||||
)
|
||||
assert resp.status_code == 200, (i, resp.text[:200])
|
||||
|
||||
resp = client.post("/admin/login", data={"email": "nobody5@example.com"})
|
||||
assert resp.status_code == 429
|
||||
assert "Too many attempts" in resp.text
|
||||
|
||||
# Audit row written.
|
||||
with client.app.state.engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT detail FROM auth_events"
|
||||
" WHERE event_type = 'rate_limited'"
|
||||
)
|
||||
).mappings().all()
|
||||
assert len(rows) >= 1
|
||||
assert any("\"scope\": \"ip\"" in str(r["detail"]) for r in rows)
|
||||
|
||||
|
||||
def test_per_email_rate_limit_trips_on_sixth(
|
||||
admin_app, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Priming 5 recent tokens for an email causes the 6th request to 429."""
|
||||
client, _ = admin_app
|
||||
|
||||
# Disable the SlowAPI IP limiter temporarily so we can isolate the
|
||||
# DB-side per-email check. We can't call six requests through the
|
||||
# IP limiter within 15 minutes — the IP limit would trip first.
|
||||
from app.services.rate_limit import limiter
|
||||
|
||||
limiter.enabled = False
|
||||
try:
|
||||
# Seed 5 recent tokens for the allowlisted email.
|
||||
now = datetime.now(timezone.utc)
|
||||
with client.app.state.engine.begin() as conn:
|
||||
for i in range(5):
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO magic_link_tokens"
|
||||
" (email, token_hash, created_at, expires_at,"
|
||||
" request_ip)"
|
||||
" VALUES (:e, :h, :c, :x, :ip)"
|
||||
),
|
||||
{
|
||||
"e": "headhen@example.com",
|
||||
"h": f"seed-hash-{i}-" + ("x" * 50),
|
||||
"c": now.isoformat(),
|
||||
"x": (now + timedelta(minutes=15)).isoformat(),
|
||||
"ip": "",
|
||||
},
|
||||
)
|
||||
|
||||
# 6th request trips the DB-side limiter.
|
||||
resp = client.post(
|
||||
"/admin/login", data={"email": "headhen@example.com"}
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
assert "Too many attempts" in resp.text
|
||||
|
||||
# rate_limited audit row with scope=email.
|
||||
with client.app.state.engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT detail FROM auth_events"
|
||||
" WHERE event_type = 'rate_limited'"
|
||||
)
|
||||
).mappings().all()
|
||||
assert any("\"scope\": \"email\"" in str(r["detail"]) for r in rows)
|
||||
finally:
|
||||
limiter.enabled = True
|
||||
limiter.reset()
|
||||
Reference in New Issue
Block a user