"""Generic, per-IP escalation engine shared by resend-verification, resend-password-reset, and failed-login throttling. Two functions, not one, so login can enforce the gate *before* verifying a password (a correct password during a penalty window must still be rejected, or the delay is meaningless) without a check-then-act race: - `peek_throttle` - read-only. Is this IP allowed to attempt `action` right now? Also lazily resets a completed timeout cycle (memory of the offense survives via `offense_count`; only the attempt stage resets - see `IpThrottleState`). - `record_attempt` - call only after the gated action actually happens (a failed login, or a resend that's being sent). Advances the stage/backoff, and escalates into a timeout (and eventually a permanent ban) once the stage array is exhausted. All time comparisons go through `_now()` so tests can monkeypatch it directly to walk the whole escalation ladder in milliseconds of real time - no waiting, no manual brute-forcing. """ from __future__ import annotations from dataclasses import dataclass from datetime import UTC, datetime, timedelta from sqlalchemy.ext.asyncio import AsyncSession from app.db.base import ensure_aware_utc from app.models.enums import ThrottleAction from app.repositories.ip_throttle_repository import IpThrottleRepository # 30s, 1min, 2min, 5min, 5min - shared identically by resend_verification and # resend_reset. The *first* code send (at registration / at a reset request) # also advances this, so "first resend allowed in 30s" is measured from that # original send, not from a first manual resend click. RESEND_BACKOFF_SECONDS: list[int] = [30, 60, 120, 300, 300] # A stage's value is the wait *before the next* attempt (recording attempt K # sets the delay gating attempt K+1) - so 5 truly free attempts (1-5, no # wait before any of them) needs only 4 leading zeros (gating attempts # 2-5), then the 7 real delays gate attempts 6-12 (5s/15s/30s/60s/2min/5min/ # 15min). Recording attempt 12 itself lands on stage_index=11, past the end # of this 11-entry array - exhausted, which is exactly the intended "12th # failure locks the account and starts the IP's timeout ladder" behavior. LOGIN_BACKOFF_SECONDS: list[int] = [0, 0, 0, 0, 5, 15, 30, 60, 120, 300, 900] # 30min, 1h, 2h, 3h, 4h, 5h - indexed by offense_count. Exceeding this length # is a permanent ban. TIMEOUT_LADDER_SECONDS: list[int] = [1800, 3600, 7200, 10800, 14400, 18000] def _now() -> datetime: return datetime.now(UTC) @dataclass(frozen=True) class ThrottleResult: allowed: bool retry_after_seconds: int | None = None banned: bool = False async def is_banned(db: AsyncSession, ip_address: str) -> bool: """Ban-only check, decoupled from any specific action's stage/timeout state - for call sites like register() that have no throttle action of their own but still must never let a banned IP through.""" return await IpThrottleRepository(db).get_ban(ip_address) is not None async def peek_throttle( db: AsyncSession, ip_address: str, action: ThrottleAction ) -> ThrottleResult: repo = IpThrottleRepository(db) ban = await repo.get_ban(ip_address) if ban is not None: return ThrottleResult(allowed=False, banned=True) state = await repo.get_or_create_state(ip_address, action) now = _now() if state.timeout_until is not None: timeout_until = ensure_aware_utc(state.timeout_until) if timeout_until > now: return ThrottleResult( allowed=False, retry_after_seconds=int((timeout_until - now).total_seconds()) ) # Timeout has elapsed - cycle reset. offense_count (the memory of # this IP's history) is deliberately left untouched. state.timeout_until = None state.attempt_count = 0 state.next_allowed_at = None await db.flush() if state.next_allowed_at is not None: next_allowed_at = ensure_aware_utc(state.next_allowed_at) if next_allowed_at > now: return ThrottleResult( allowed=False, retry_after_seconds=int((next_allowed_at - now).total_seconds()) ) return ThrottleResult(allowed=True) async def record_attempt( db: AsyncSession, ip_address: str, action: ThrottleAction, backoff_stages: list[int] ) -> None: repo = IpThrottleRepository(db) state = await repo.get_or_create_state(ip_address, action) now = _now() stage_index = state.attempt_count state.attempt_count += 1 if stage_index < len(backoff_stages): delay = backoff_stages[stage_index] state.next_allowed_at = now + timedelta(seconds=delay) await db.flush() return # Backoff stages exhausted - enter a timeout, escalating in length with # each repeat offense. if state.offense_count < len(TIMEOUT_LADDER_SECONDS): duration = TIMEOUT_LADDER_SECONDS[state.offense_count] state.timeout_until = now + timedelta(seconds=duration) state.next_allowed_at = None state.offense_count += 1 await db.flush() return # Offended again after exhausting the entire timeout ladder - permanent. state.offense_count += 1 state.timeout_until = None state.next_allowed_at = None await repo.create_ban(ip_address, reason=action.value, banned_at=now) await db.flush() async def reset_on_success(db: AsyncSession, ip_address: str, action: ThrottleAction) -> None: """Login-only convenience: a correct login ends that specific attack scenario for this IP, so the stage/cooldown resets - but `offense_count` (this IP's history) is never cleared by a success, only by an admin unban.""" repo = IpThrottleRepository(db) state = await repo.get_state(ip_address, action) if state is None: return state.attempt_count = 0 state.next_allowed_at = None await db.flush()