Files
oai-web/server/login_limiter.py
2026-04-08 12:43:24 +02:00

142 lines
5.2 KiB
Python

"""
login_limiter.py — Two-tier brute-force protection for the login endpoint.
Tier 1: 5 failures within 30 minutes → 30-minute lockout.
Tier 2: Same IP gets locked out again within 24 hours → permanent lockout
(requires admin action to unlock via Settings → Security).
All timestamps are unix wall-clock (time.time()) so they can be shown in the UI.
State is in-process memory; it resets on server restart.
"""
from __future__ import annotations
import logging
import time
from typing import Any
logger = logging.getLogger(__name__)
# ── Config ────────────────────────────────────────────────────────────────────
MAX_ATTEMPTS = 5 # failures before tier-1 lockout
ATTEMPT_WINDOW = 1800 # 30 min — window in which failures are counted
LOCKOUT_DURATION = 1800 # 30 min — tier-1 lockout duration
RECURRENCE_WINDOW = 86400 # 24 h — if locked again within this period → tier-2
# ── State ─────────────────────────────────────────────────────────────────────
# Per-IP entry shape:
# failures: [unix_ts, ...] recent failed attempts (pruned to ATTEMPT_WINDOW)
# locked_until: float | None unix_ts when tier-1 lockout expires
# permanent: bool tier-2: admin must unlock
# lockouts_24h: [unix_ts, ...] when tier-1 lockouts were applied (pruned to 24 h)
# locked_at: float | None when the current lockout started (for display)
_STATE: dict[str, dict[str, Any]] = {}
def _entry(ip: str) -> dict[str, Any]:
if ip not in _STATE:
_STATE[ip] = {
"failures": [],
"locked_until": None,
"permanent": False,
"lockouts_24h": [],
"locked_at": None,
}
return _STATE[ip]
# ── Public API ────────────────────────────────────────────────────────────────
def is_locked(ip: str) -> tuple[bool, str]:
"""Return (locked, kind) where kind is 'permanent', 'temporary', or ''."""
e = _entry(ip)
if e["permanent"]:
return True, "permanent"
if e["locked_until"] and time.time() < e["locked_until"]:
return True, "temporary"
return False, ""
def record_failure(ip: str) -> None:
"""Record a failed login attempt; apply lockout if threshold is reached."""
e = _entry(ip)
now = time.time()
e["failures"].append(now)
# Prune to the counting window
cutoff = now - ATTEMPT_WINDOW
e["failures"] = [t for t in e["failures"] if t > cutoff]
if len(e["failures"]) < MAX_ATTEMPTS:
return # threshold not reached yet
# Threshold reached — determine tier
cutoff_24h = now - RECURRENCE_WINDOW
e["lockouts_24h"] = [t for t in e["lockouts_24h"] if t > cutoff_24h]
if e["lockouts_24h"]:
# Already locked before in the last 24 h → permanent
e["permanent"] = True
e["locked_until"] = None
e["locked_at"] = now
logger.warning("[login_limiter] %s permanently locked (repeat offender within 24 h)", ip)
else:
# First offence → 30-minute lockout
e["locked_until"] = now + LOCKOUT_DURATION
e["lockouts_24h"].append(now)
e["locked_at"] = now
logger.warning("[login_limiter] %s locked for 30 minutes", ip)
e["failures"] = [] # reset after triggering lockout
def clear_failures(ip: str) -> None:
"""Called on successful login — clears the failure counter for this IP."""
if ip in _STATE:
_STATE[ip]["failures"] = []
def unlock(ip: str) -> bool:
"""Admin action: fully reset lockout state for an IP. Returns False if unknown."""
if ip not in _STATE:
return False
_STATE[ip].update(permanent=False, locked_until=None, locked_at=None,
failures=[], lockouts_24h=[])
logger.info("[login_limiter] %s unlocked by admin", ip)
return True
def unlock_all() -> int:
"""Admin action: unlock every locked IP. Returns count unlocked."""
count = 0
for ip, e in _STATE.items():
if e["permanent"] or (e["locked_until"] and time.time() < e["locked_until"]):
e.update(permanent=False, locked_until=None, locked_at=None,
failures=[], lockouts_24h=[])
count += 1
return count
def list_locked() -> list[dict]:
"""Return info dicts for all currently locked IPs (for the admin UI)."""
now = time.time()
result = []
for ip, e in _STATE.items():
if e["permanent"]:
result.append({
"ip": ip,
"type": "permanent",
"locked_at": e["locked_at"],
"locked_until": None,
})
elif e["locked_until"] and now < e["locked_until"]:
result.append({
"ip": ip,
"type": "temporary",
"locked_at": e["locked_at"],
"locked_until": e["locked_until"],
})
return result