Initial commit
This commit is contained in:
141
server/login_limiter.py
Normal file
141
server/login_limiter.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user