142 lines
5.2 KiB
Python
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
|