Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user