netx/netx_api/auth_rate_limit.py
oliver 20c2fcd496 Harden auth with revocable sessions, cookies, and single-login default.
Issue short-lived access JWTs backed by AuthSession rows, HttpOnly cookies with refresh rotation, idle timeout, session management UI, WebCRT ownership caps, and optional Redis login rate limits; new logins revoke other sessions by default.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 14:13:37 +08:00

139 lines
4.2 KiB
Python

"""Login brute-force protection: Redis when configured, else in-process."""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from typing import Any
from .config import settings
_log = logging.getLogger("netx.auth.rate")
_lock = threading.Lock()
@dataclass
class _Bucket:
window_start: float
failures: int
locked_until: float = 0.0
_buckets: dict[str, _Bucket] = {}
_redis_client: Any | None = None
_redis_failed = False
def _key(username: str, client_ip: str) -> str:
return f"{str(client_ip or '').strip().lower()}|{str(username or '').strip().lower()}"
def _limits() -> tuple[int, int, int]:
max_fail = max(3, int(getattr(settings, "auth_login_max_failures", 10) or 10))
window = max(60, int(getattr(settings, "auth_login_window_sec", 300) or 300))
lockout = max(60, int(getattr(settings, "auth_login_lockout_sec", 900) or 900))
return max_fail, window, lockout
def _get_redis() -> Any | None:
global _redis_client, _redis_failed
url = str(getattr(settings, "auth_redis_url", "") or "").strip()
if not url:
return None
if _redis_failed:
return None
if _redis_client is not None:
return _redis_client
try:
import redis # type: ignore
client = redis.Redis.from_url(url, decode_responses=True, socket_connect_timeout=0.5)
client.ping()
_redis_client = client
_log.info("auth login rate-limit using Redis")
return _redis_client
except Exception:
_redis_failed = True
_log.warning("auth Redis unavailable; falling back to in-process rate-limit", exc_info=True)
return None
def login_lock_remaining(username: str, client_ip: str) -> float:
"""Seconds remaining on lockout, or 0 if not locked."""
k = _key(username, client_ip)
r = _get_redis()
if r is not None:
try:
ttl = r.ttl(f"netx:auth:lock:{k}")
if isinstance(ttl, int) and ttl > 0:
return float(ttl)
return 0.0
except Exception:
_log.debug("redis lock_remaining failed", exc_info=True)
now = time.time()
with _lock:
row = _buckets.get(k)
if row is None:
return 0.0
return max(0.0, float(row.locked_until) - now)
def register_login_failure(username: str, client_ip: str) -> float:
"""Record a failed login. Returns lock remaining seconds (0 if not yet locked)."""
max_fail, window, lockout = _limits()
k = _key(username, client_ip)
r = _get_redis()
if r is not None:
try:
lock_key = f"netx:auth:lock:{k}"
fail_key = f"netx:auth:fail:{k}"
existing = r.ttl(lock_key)
if isinstance(existing, int) and existing > 0:
return float(existing)
count = int(r.incr(fail_key))
if count == 1:
r.expire(fail_key, window)
if count >= max_fail:
r.setex(lock_key, lockout, "1")
r.delete(fail_key)
return float(lockout)
return 0.0
except Exception:
_log.debug("redis register_failure failed; using memory", exc_info=True)
now = time.time()
with _lock:
row = _buckets.get(k)
if row is None or (now - row.window_start) > window:
row = _Bucket(window_start=now, failures=0)
_buckets[k] = row
if row.locked_until > now:
return float(row.locked_until - now)
row.failures += 1
if row.failures >= max_fail:
row.locked_until = now + lockout
return float(lockout)
return 0.0
def clear_login_failures(username: str, client_ip: str) -> None:
k = _key(username, client_ip)
r = _get_redis()
if r is not None:
try:
r.delete(f"netx:auth:fail:{k}", f"netx:auth:lock:{k}")
except Exception:
_log.debug("redis clear_failures failed", exc_info=True)
with _lock:
_buckets.pop(k, None)
def reset_login_rate_limit_for_tests() -> None:
global _redis_client, _redis_failed
with _lock:
_buckets.clear()
_redis_client = None
_redis_failed = False