agnes-the-ai-analyst/app/auth/providers/email.py
ZdenekSrotyr 91caefaca9
security(auth): per-IP rate limit + last-admin guard (#165)
* security(auth): per-IP rate limit on auth endpoints + generalize last-admin guard

Closes #45 and #151.

#45 — every auth endpoint was unthrottled (login, magic-link, token,
bootstrap), leaving us open to password brute-force and SMTP
email-bombing. Wires slowapi (new dep) into the middleware chain with
per-route limits: 10/min on login + token, 5/min on send-link, 3/min on
bootstrap. Returns 429 with Retry-After: 60 once exceeded. Per-IP key
respects the leftmost X-Forwarded-For hop (Caddy in front of the app
strips client-supplied XFF). Operator escape hatch:
AGNES_AUTH_RATELIMIT_ENABLED=0. Test suite disables the limiter via
autouse conftest fixture so existing auth tests that hammer endpoints
in tight loops are unaffected.

#151 — DELETE /api/admin/users/{id}/memberships/{group_id} and the
mirror DELETE /api/admin/groups/{group_id}/members/{user_id} only
guarded against self-removal as last admin. Generalizes to refuse
removing anyone from the seeded Admin group when they are the only
remaining active admin (mirrors the existing
count_admins(active_only=True) <= 1 check on delete_user / update_user).
Recovery from zero admins requires direct DB access, so this closes
a path where a scheduler/bootstrap actor that bypasses normal admin
checks could otherwise empty the group.

* security(auth): throttle remaining email-bombing + token-confirm endpoints

Address code-review gap on PR #165 — the first commit covered /send-link
but missed two endpoints with the IDENTICAL email-bombing surface:

- POST /auth/password/reset       — sends reset mail, anti-enum response
- POST /auth/password/setup/request — sends setup mail, anti-enum response

Both now share the 5/min limit with /send-link.

Also add 10/min to the token-confirm surfaces — high-entropy tokens but
partial leaks via logs / referer have surfaced before, and unbounded
guess rate would let an attacker exhaust the keyspace adjacent to a
leaked prefix:

- POST /auth/email/verify
- GET  /auth/email/verify         — closes the click-through bypass
- POST /auth/password/reset/confirm
- POST /auth/password/setup/confirm

Doc fix: rate_limit.py module docstring + CHANGELOG entry no longer
claim "disable without a redeploy" (misleading). The Limiter constructor
freezes `enabled` from env at import time, matching every other Agnes
env knob — operators set the flag and bounce the container.

Tests: 4 new cases in test_auth_rate_limit.py covering
/reset, /setup/request, /reset/confirm, GET /verify. Full suite:
2583 passed, 32 skipped, 0 failed.

* security(auth): throttle JSON /auth/password/setup — closes form-throttle bypass

Second code-review pass on PR #165 caught a fifth gap: POST /auth/password/setup
(JSON variant, kept for backward compat) consumes the same setup_token as
the web form /setup/confirm but was unthrottled — an attacker brute-forcing
the token just switches from the form path to the JSON path and resumes
at unbounded RPS. Apply the same 10/min limit and signature shape used
on /setup/confirm.

Also extend CHANGELOG note about the JSON-variant bypass for future
operators reading the security entry.

Test: 1 new case (test_password_setup_json_rate_limited_after_10_requests),
9 rate-limit tests + 28 password-flow tests + 41 auth-provider tests pass,
no regressions.

* chore(release): cut 0.30.1 — auth security hardening (rate limit + last-admin guard)
2026-05-02 21:08:33 +02:00

267 lines
10 KiB
Python

"""Email magic link auth provider for FastAPI."""
import logging
import os
import secrets
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
import duckdb
from app.auth.jwt import create_access_token
from app.auth.access import is_user_admin
from app.auth.dependencies import _get_db, is_local_dev_mode
from app.auth.rate_limit import limiter as _rate_limiter
from src.repositories.users import UserRepository
def _role_label(user: dict, conn: duckdb.DuckDBPyConnection) -> str:
"""Display label for the response payload only — `admin` if the user is
in the Admin system group, otherwise `user`. Authorization at runtime
checks `is_user_admin` directly; this label is purely cosmetic."""
return "admin" if is_user_admin(user["id"], conn) else "user"
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth/email", tags=["auth"])
MAGIC_LINK_EXPIRY = 3600 # 1 hour
class MagicLinkRequest(BaseModel):
email: str
class MagicLinkVerify(BaseModel):
email: str
token: str
def is_available() -> bool:
# In dev mode the link is rendered to logs + response, so the provider is "available"
# even without SMTP/SendGrid. Keeps the login UI showing the magic-link option.
if is_local_dev_mode():
return True
return bool(os.environ.get("SMTP_HOST") or os.environ.get("SENDGRID_API_KEY"))
def _has_email_transport() -> bool:
return bool(os.environ.get("SMTP_HOST") or os.environ.get("SENDGRID_API_KEY"))
def _build_magic_link(email: str, token: str) -> str:
# URL-encode email: a literal '+' in a query string decodes to space per
# application/x-www-form-urlencoded, which would break addresses like
# "user+tag@gmail.com" on the GET /verify side.
server_url = os.environ.get("SERVER_URL", "http://localhost:8000")
return f"{server_url}/auth/email/verify?email={quote(email, safe='')}&token={token}"
@router.post("/send-link")
@_rate_limiter.limit("5/minute")
async def send_magic_link(
request: Request,
body: MagicLinkRequest,
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Send a magic link to the user's email.
When SMTP/SendGrid is not configured, or LOCAL_DEV_MODE=1, the link is
logged to stderr and returned in the response body so a developer can
click it without an email transport.
"""
repo = UserRepository(conn)
user = repo.get_by_email(body.email)
# Always return success to prevent email enumeration
if not user:
return {"message": "If this email is registered, you will receive a login link."}
# Generate token
token = secrets.token_urlsafe(32)
repo.update(
id=user["id"],
reset_token=token,
reset_token_created=datetime.now(timezone.utc),
)
link = _build_magic_link(body.email, token)
send_error: str | None = None
if _has_email_transport():
try:
_send_email(body.email, token)
except Exception as e:
send_error = str(e)
logger.error("Failed to send magic link email to %s: %s", body.email, e)
# Dev fallback: expose the link in logs + response so you can click it without SMTP.
# Scoped strictly to LOCAL_DEV_MODE so test and production behavior are unchanged.
if is_local_dev_mode():
logger.warning("=" * 60)
logger.warning("Magic link for %s (LOCAL_DEV_MODE fallback):", body.email)
logger.warning(" %s", link)
logger.warning("=" * 60)
response: dict = {
"message": "Magic link generated (LOCAL_DEV_MODE) — click dev_link to log in.",
"dev_link": link,
}
if send_error:
response["send_error"] = send_error
return response
return {"message": "If this email is registered, you will receive a login link."}
def _consume_token(conn: duckdb.DuckDBPyConnection, email: str, token: str) -> dict:
"""Validate & consume a magic-link token atomically. Returns the user dict or raises 401.
Uses a "compare-and-swap" pattern: instead of setting reset_token to NULL
directly, we first set it to a unique CONSUMED marker that identifies THIS
consumption attempt, then verify that OUR marker was written. Two concurrent
verifies will both try to write their marker, but only one will succeed
(the WHERE clause checks the original token value); the loser's UPDATE is
a no-op, and the loser sees the winner's marker and fails.
DuckDB doesn't expose affected-row count, so the marker is the only way
to distinguish "I won the race" from "someone else won."
"""
# Compute the TTL cutoff in Python — DuckDB doesn't support
# parameterized INTERVAL arithmetic (?, INTERVAL) in all builds.
cutoff = datetime.now(timezone.utc) - timedelta(seconds=MAGIC_LINK_EXPIRY)
# Unique marker for this consumption attempt — lets us detect who won
# the race without relying on DuckDB rowcount (which returns -1).
consume_id = f"CONSUMED:{secrets.token_hex(16)}"
# Step 1: Atomic compare-and-swap. Only succeeds if the token still
# matches the original value and hasn't expired. On success, writes
# OUR consume_id instead of NULL so we can verify ownership.
# DuckDB raises TransactionContext Error on concurrent row conflicts —
# catch and treat as "someone else won the race."
try:
conn.execute(
"UPDATE users SET reset_token = ?, reset_token_created = NULL "
"WHERE email = ? AND reset_token = ? AND reset_token_created IS NOT NULL "
"AND reset_token_created >= ?",
[consume_id, email, token, cutoff],
)
except Exception as exc:
err = str(exc).lower()
if "conflict" in err or "transaction" in err:
raise HTTPException(status_code=401, detail="Invalid or expired link")
raise
# Step 2: Verify that OUR consume_id was written. If a concurrent
# request won the race, we'll see THEIR consume_id (or NULL if they
# already cleared it in step 3) — either way, we fail.
row = conn.execute(
"SELECT reset_token FROM users WHERE email = ?",
[email],
).fetchone()
if not row or row[0] != consume_id:
raise HTTPException(status_code=401, detail="Invalid or expired link")
# Step 3: Clear the consumed marker. Safe to do unconditionally —
# only the winner reaches here, and the marker is transient.
# If this UPDATE fails (DB error), the marker persists but the user
# can still request a new magic link — not a lockout.
try:
conn.execute(
"UPDATE users SET reset_token = NULL WHERE email = ? AND reset_token = ?",
[email, consume_id],
)
except Exception:
logger.warning("Failed to clear CONSUMED marker for %s — marker will persist", email)
# Fetch the user (token is now cleared, but we need the rest of the fields).
# CAS already validated token + expiry atomically, so no further checks
# needed — re-running them now would always fail because reset_token was
# NULL'd in step 3.
repo = UserRepository(conn)
user = repo.get_by_email(email)
if not user:
raise HTTPException(status_code=401, detail="Invalid link")
return user
@router.post("/verify")
@_rate_limiter.limit("10/minute")
async def verify_magic_link(
request: Request,
body: MagicLinkVerify,
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Verify a magic link token and issue JWT (JSON API for programmatic clients).
Rate limited 10/min per IP to slow brute-forcing the 32-byte
``reset_token`` (the same column doubles as the magic-link token).
"""
user = _consume_token(conn, body.email, body.token)
role_label = _role_label(user, conn)
jwt_token = create_access_token(user["id"], user["email"])
return {"access_token": jwt_token, "token_type": "bearer", "email": user["email"], "role": role_label}
@router.get("/verify")
@_rate_limiter.limit("10/minute")
async def verify_magic_link_get(
request: Request,
email: str,
token: str,
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Click-through variant — verifies token, sets cookie, redirects to /dashboard.
This is the URL we embed in outgoing emails (and the dev-fallback link), so
clicking it in a mail client logs the user in without a separate API call.
Rate limited 10/min per IP for the same reason as the POST variant —
don't let the click-through path bypass the brute-force throttle.
"""
user = _consume_token(conn, email, token)
jwt_token = create_access_token(user["id"], user["email"])
# secure=False when DOMAIN is unset so the cookie is actually sent on plain HTTP (dev).
use_secure = os.environ.get("DOMAIN", "") != ""
response = RedirectResponse(url="/dashboard", status_code=302)
response.set_cookie(
key="access_token", value=jwt_token,
httponly=True, max_age=86400, samesite="lax",
secure=use_secure,
)
return response
def _send_email(email: str, token: str):
"""Send magic link email via SMTP or SendGrid."""
link = _build_magic_link(email, token)
sendgrid_key = os.environ.get("SENDGRID_API_KEY")
if sendgrid_key:
import sendgrid
from sendgrid.helpers.mail import Mail
sg = sendgrid.SendGridAPIClient(api_key=sendgrid_key)
message = Mail(
from_email=os.environ.get("EMAIL_FROM_ADDRESS", "noreply@example.com"),
to_emails=email,
subject="Login Link",
html_content=f'<p>Click to login: <a href="{link}">Login</a></p>',
)
sg.send(message)
return
smtp_host = os.environ.get("SMTP_HOST")
if smtp_host:
import smtplib
from email.mime.text import MIMEText
msg = MIMEText(f"Login link: {link}")
msg["Subject"] = "Login Link"
msg["From"] = os.environ.get("SMTP_FROM", "noreply@example.com")
msg["To"] = email
with smtplib.SMTP(smtp_host, int(os.environ.get("SMTP_PORT", "587"))) as s:
if os.environ.get("SMTP_USE_TLS", "true").lower() == "true":
s.starttls()
smtp_user = os.environ.get("SMTP_USER")
if smtp_user:
s.login(smtp_user, os.environ.get("SMTP_PASSWORD", ""))
s.send_message(msg)