"""Durable throttling state for operator sign-in attempts.""" import hashlib import ipaddress import math import sqlite3 from pathlib import Path from typing import Callable class LoginAttemptStoreError(RuntimeError): """Raised when sign-in throttling state cannot be accessed safely.""" def client_source(peer_host: str, forwarded_for: str, trusted_proxy_cidrs: str) -> str: """Resolve a canonical client IP without trusting arbitrary forwarding headers.""" try: peer = ipaddress.ip_address(peer_host) trusted = [ ipaddress.ip_network(value.strip()) for value in trusted_proxy_cidrs.split(",") if value.strip() ] except ValueError: return peer_host if not any(peer in network for network in trusted): return str(peer) try: forwarded = [ ipaddress.ip_address(value.strip()) for value in forwarded_for.split(",") if value.strip() ] except ValueError: return str(peer) for address in reversed(forwarded): if not any(address in network for network in trusted): return str(address) return str(forwarded[0]) if forwarded else str(peer) class LoginAttemptStore: def __init__( self, path: str | Path, *, clock: Callable[[], float], max_failures: int, window_seconds: int, max_entries: int = 10_000, lock_timeout_seconds: float = 0.1, alert_bucket_seconds: int = 60 * 60, alert_retention_seconds: int = 30 * 24 * 60 * 60, max_alert_buckets: int = 720, ) -> None: self.path = Path(path) self.clock = clock self.max_failures = max(1, max_failures) self.window_seconds = max(1, window_seconds) self.max_entries = max(1, max_entries) self.lock_timeout_seconds = lock_timeout_seconds self.alert_bucket_seconds = max(1, alert_bucket_seconds) self.alert_retention_seconds = max(1, alert_retention_seconds) self.max_alert_buckets = max(1, max_alert_buckets) @staticmethod def _digest(source: str) -> str: return hashlib.sha256(source.encode()).hexdigest() def _connect(self) -> sqlite3.Connection: try: self.path.parent.mkdir(parents=True, exist_ok=True) connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds) connection.execute( """ CREATE TABLE IF NOT EXISTS login_attempts ( source_hash TEXT PRIMARY KEY, failures INTEGER NOT NULL, window_started_at REAL NOT NULL ) """ ) connection.execute( """ CREATE TABLE IF NOT EXISTS login_admissions ( bucket TEXT NOT NULL, source_hash TEXT NOT NULL, attempts INTEGER NOT NULL, window_started_at REAL NOT NULL, PRIMARY KEY (bucket, source_hash) ) """ ) connection.execute( """ CREATE TABLE IF NOT EXISTS login_alerts ( method TEXT NOT NULL, bucket_started_at INTEGER NOT NULL, failed_count INTEGER NOT NULL DEFAULT 0, blocked_count INTEGER NOT NULL DEFAULT 0, first_at INTEGER NOT NULL, last_at INTEGER NOT NULL, PRIMARY KEY (method, bucket_started_at) ) """ ) return connection except (OSError, sqlite3.Error) as exc: raise LoginAttemptStoreError( "Sign-in throttling is temporarily unavailable" ) from exc def retry_after(self, source: str) -> int: now = self.clock() try: with self._connect() as connection: row = connection.execute( "SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?", (self._digest(source),), ).fetchone() except (OSError, sqlite3.Error) as exc: raise LoginAttemptStoreError( "Sign-in throttling is temporarily unavailable" ) from exc if row is None or row[0] < self.max_failures: return 0 remaining = row[1] + self.window_seconds - now return max(0, math.ceil(remaining)) def admit(self, bucket: str, source: str, *, limit: int) -> int: """Consume one fixed-window admission or return seconds until retry.""" now = self.clock() source_hash = self._digest(source) limit = max(1, limit) try: with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") connection.execute( "DELETE FROM login_admissions WHERE window_started_at + ? <= ?", (self.window_seconds, now), ) row = connection.execute( "SELECT attempts, window_started_at FROM login_admissions " "WHERE bucket = ? AND source_hash = ?", (bucket, source_hash), ).fetchone() if row is not None and row[0] >= limit: return max(1, math.ceil(row[1] + self.window_seconds - now)) if row is None: connection.execute( "INSERT INTO login_admissions VALUES (?, ?, 1, ?)", (bucket, source_hash, now), ) else: connection.execute( "UPDATE login_admissions SET attempts = attempts + 1 " "WHERE bucket = ? AND source_hash = ?", (bucket, source_hash), ) connection.execute( """ DELETE FROM login_admissions WHERE (bucket, source_hash) NOT IN ( SELECT bucket, source_hash FROM login_admissions ORDER BY window_started_at DESC, rowid DESC LIMIT ? ) """, (self.max_entries,), ) return 0 except (OSError, sqlite3.Error) as exc: raise LoginAttemptStoreError( "Sign-in throttling is temporarily unavailable" ) from exc def _record_alert( self, connection: sqlite3.Connection, method: str, *, blocked: bool, now: float ) -> None: safe_method = method if method in {"token", "passkey"} else "unknown" occurred_at = int(now) bucket = occurred_at - (occurred_at % self.alert_bucket_seconds) failed = 0 if blocked else 1 blocked_count = 1 if blocked else 0 connection.execute( """ INSERT INTO login_alerts( method, bucket_started_at, failed_count, blocked_count, first_at, last_at ) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(method, bucket_started_at) DO UPDATE SET failed_count = failed_count + excluded.failed_count, blocked_count = blocked_count + excluded.blocked_count, first_at = MIN(first_at, excluded.first_at), last_at = MAX(last_at, excluded.last_at) """, (safe_method, bucket, failed, blocked_count, occurred_at, occurred_at), ) connection.execute( "DELETE FROM login_alerts WHERE (method, bucket_started_at) NOT IN " "(SELECT method, bucket_started_at FROM login_alerts " "ORDER BY bucket_started_at DESC LIMIT ?)", (self.max_alert_buckets,), ) def record_failure(self, source: str, *, method: str = "token") -> None: now = self.clock() source_hash = self._digest(source) try: with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") connection.execute( "DELETE FROM login_attempts WHERE window_started_at + ? <= ?", (self.window_seconds, now), ) row = connection.execute( "SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?", (source_hash,), ).fetchone() if row is None or row[1] + self.window_seconds <= now: connection.execute( "INSERT OR REPLACE INTO login_attempts VALUES (?, 1, ?)", (source_hash, now), ) else: connection.execute( "UPDATE login_attempts SET failures = failures + 1 WHERE source_hash = ?", (source_hash,), ) connection.execute( """ DELETE FROM login_attempts WHERE source_hash NOT IN ( SELECT source_hash FROM login_attempts ORDER BY window_started_at DESC, rowid DESC LIMIT ? ) """, (self.max_entries,), ) self._record_alert(connection, method, blocked=False, now=now) except (OSError, sqlite3.Error) as exc: raise LoginAttemptStoreError( "Sign-in throttling is temporarily unavailable" ) from exc def record_blocked(self, *, method: str = "token") -> None: now = self.clock() try: with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") self._record_alert(connection, method, blocked=True, now=now) except (OSError, sqlite3.Error) as exc: raise LoginAttemptStoreError( "Sign-in throttling is temporarily unavailable" ) from exc def list_alerts(self, *, limit: int = 24) -> list[dict[str, int | str]]: bounded_limit = min(100, max(1, limit)) now = int(self.clock()) try: with self._connect() as connection: connection.execute( "DELETE FROM login_alerts WHERE last_at < ?", (now - self.alert_retention_seconds,), ) rows = connection.execute( "SELECT method, failed_count, blocked_count, first_at, last_at " "FROM login_alerts ORDER BY bucket_started_at DESC LIMIT ?", (bounded_limit,), ).fetchall() except (OSError, sqlite3.Error) as exc: raise LoginAttemptStoreError( "Sign-in throttling is temporarily unavailable" ) from exc return [ { "method": row[0], "failed_count": row[1], "blocked_count": row[2], "first_at": row[3], "last_at": row[4], } for row in rows ] def clear(self, source: str) -> None: try: with self._connect() as connection: connection.execute( "DELETE FROM login_attempts WHERE source_hash = ?", (self._digest(source),), ) except (OSError, sqlite3.Error) as exc: raise LoginAttemptStoreError( "Sign-in throttling is temporarily unavailable" ) from exc