"""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