"""Magic-link auth, sessions, staff-key, rate limiting (in-memory sliding window). Constant-time comparison via hmac.compare_digest for token hashes + staff key. """ from __future__ import annotations import hashlib import hmac import logging import os import secrets import time from collections import defaultdict, deque from datetime import datetime, timedelta, timezone from fastapi import Depends, HTTPException, Request, status from . import db from .db import get_conn, new_uuid, utcnow_iso logger = logging.getLogger("dre.auth") MAGIC_TOKEN_TTL_MIN = 15 SESSION_TTL_DAYS = 7 # --------------------------------------------------------------- # Rate limiting — in-memory sliding window (single-instance v1) # --------------------------------------------------------------- class RateLimiter: def __init__(self) -> None: self._by_email: dict[str, deque[float]] = defaultdict(deque) self._by_ip: dict[str, deque[float]] = defaultdict(deque) def _prune(self, dq: deque[float], window_sec: float) -> None: cutoff = time.time() - window_sec while dq and dq[0] < cutoff: dq.popleft() def check_email(self, email: str, max_count: int, window_sec: float) -> bool: dq = self._by_email[email] self._prune(dq, window_sec) if len(dq) >= max_count: return False dq.append(time.time()) return True def check_ip(self, ip: str, max_count: int, window_sec: float) -> bool: dq = self._by_ip[ip] self._prune(dq, window_sec) if len(dq) >= max_count: return False dq.append(time.time()) return True def check(self, email: str | None, ip: str, max_email: int, email_window: float, max_ip: int, ip_window: float) -> bool: if email and not self.check_email(email, max_email, email_window): return False if not self.check_ip(ip, max_ip, ip_window): return False return True _limiter = RateLimiter() def rate_limit_auth_request(email: str | None, ip: str) -> bool: return _limiter.check(email, ip, max_email=3, email_window=900, max_ip=10, ip_window=3600) def rate_limit_auth_verify(ip: str) -> bool: return _limiter.check(None, ip, max_email=999, email_window=1, max_ip=10, ip_window=900) def rate_limit_intake(ip: str) -> bool: return _limiter.check_ip(ip, max_count=20, window_sec=3600) # --------------------------------------------------------------- # Token / hash helpers # --------------------------------------------------------------- def _sha256_hex(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() def generate_magic_token() -> str: return secrets.token_urlsafe(32) def generate_session_token() -> str: return secrets.token_urlsafe(32) def _compare_hash(a: str, b: str) -> bool: return hmac.compare_digest(a, b) # --------------------------------------------------------------- # Staff key # --------------------------------------------------------------- def _staff_key() -> str: return os.environ.get("DRE_STAFF_KEY", "") def verify_staff_key(provided: str | None) -> bool: key = _staff_key() if not key or not provided: return False return _compare_hash(provided, key) # --------------------------------------------------------------- # FastAPI dependencies # --------------------------------------------------------------- def get_client_ip(request: Request) -> str: # Cloudflare / Caddy may set X-Forwarded-For; use first hop xff = request.headers.get("x-forwarded-for") if xff: return xff.split(",")[0].strip() return request.client.host if request.client else "unknown" def require_staff(request: Request) -> None: """Staff-key auth dependency. Raises 403 if missing/wrong.""" provided = request.headers.get("x-dre-staff-key") if not verify_staff_key(provided): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={"code": "forbidden", "message": "Valid staff key required."}, ) def require_client(request: Request) -> dict: """Client session auth dependency. Returns {'client_id':..., 'client_number':...}. Raises 401 if missing/invalid/expired.""" auth = request.headers.get("authorization", "") if not auth.lower().startswith("bearer "): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail={"code": "unauthorized", "message": "Authentication required."}, ) raw_token = auth.split(" ", 1)[1].strip() if not raw_token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail={"code": "unauthorized", "message": "Authentication required."}, ) token_hash = _sha256_hex(raw_token) now = utcnow_iso() with get_conn() as conn: row = conn.execute( "SELECT s.id, s.client_id, s.expires_at, s.revoked_at, c.client_number " "FROM sessions s JOIN clients c ON c.id = s.client_id " "WHERE s.session_hash = ?", (token_hash,), ).fetchone() if row is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail={"code": "unauthorized", "message": "Invalid or expired session."}, ) if row["revoked_at"] is not None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail={"code": "unauthorized", "message": "Session revoked."}, ) if row["expires_at"] < now: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail={"code": "unauthorized", "message": "Session expired."}, ) # Touch last_seen conn.execute( "UPDATE sessions SET last_seen_at = ? WHERE id = ?", (now, row["id"]), ) conn.commit() return {"client_id": row["client_id"], "client_number": row["client_number"]} def create_magic_token(conn, client_id: str, ip: str) -> str: """Create a magic-link token row. Returns the RAW token (caller emails it, never stores it).""" raw = generate_magic_token() token_hash = _sha256_hex(raw) now = utcnow_iso() expires_at = (datetime.now(timezone.utc) + timedelta(minutes=MAGIC_TOKEN_TTL_MIN)).strftime("%Y-%m-%dT%H:%M:%SZ") conn.execute( "INSERT INTO auth_tokens (id, client_id, token_hash, expires_at, consumed_at, requested_ip, created_at) " "VALUES (?, ?, ?, ?, NULL, ?, ?)", (new_uuid(), client_id, token_hash, expires_at, ip, now), ) return raw def prune_expired_tokens(conn) -> None: """Delete auth_token rows older than 1 day (lazy sweep on verify).""" cutoff = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ") conn.execute("DELETE FROM auth_tokens WHERE expires_at < ?", (cutoff,)) def verify_magic_token(conn, raw_token: str) -> str | None: """Hash token, find unexpired+unconsumed row. If found: mark consumed, return client_id. Returns None if no match.""" token_hash = _sha256_hex(raw_token) now = utcnow_iso() prune_expired_tokens(conn) row = conn.execute( "SELECT id, client_id FROM auth_tokens WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?", (token_hash, now), ).fetchone() if row is None: return None conn.execute("UPDATE auth_tokens SET consumed_at = ? WHERE id = ?", (now, row["id"])) return row["client_id"] def create_session(conn, client_id: str) -> tuple[str, str]: """Create a session. Returns (raw_session_token, expires_at_iso).""" raw = generate_session_token() session_hash = _sha256_hex(raw) now = utcnow_iso() expires_at = (datetime.now(timezone.utc) + timedelta(days=SESSION_TTL_DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ") conn.execute( "INSERT INTO sessions (id, client_id, session_hash, expires_at, revoked_at, created_at, last_seen_at) " "VALUES (?, ?, ?, ?, NULL, ?, NULL)", (new_uuid(), client_id, session_hash, expires_at, now), ) return raw, expires_at