Auth: rate limiter, WebAuthn domain awareness, misc fixes
- Rate limiter tracks failures only; success resets counter - Record failures/successes after password verification, not before - WebAuthn rp_id/origin resolved dynamically from request domain - Management domains auto-discovered from nginx backend config - All WebAuthn operations validate domain against management list - Add GET /api/auth/webauthn/capable endpoint for frontend checks - Frontend checkWebAuthnCapable() function for domain-gated UI - Timing side-channel fix: pre-compute dummy hash at module load - Builtin admin seeded with random password (logged at WARNING) - Logout handler returns consistent response shape
This commit is contained in:
+36
-17
@@ -346,19 +346,19 @@ def blacklist_expired() -> None:
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Simple sliding-window rate limiter for login attempts.
|
||||
"""Sliding-window rate limiter that tracks successes and failures separately.
|
||||
|
||||
Maintains per-key attempt timestamps and rejects requests that exceed
|
||||
the allowed count within the window.
|
||||
Failures are counted against the limit. A successful operation resets
|
||||
the failure counter for that key.
|
||||
"""
|
||||
|
||||
def __init__(self, max_attempts: int = 5, window_seconds: int = 300) -> None:
|
||||
self.max_attempts = max_attempts
|
||||
self.window = window_seconds
|
||||
self.attempts: dict[str, list[float]] = {}
|
||||
self.failures: dict[str, list[float]] = {}
|
||||
|
||||
def is_allowed(self, key: str) -> bool:
|
||||
"""Check if a request from *key* is allowed.
|
||||
"""Check if a request from *key* is allowed (does NOT record the attempt).
|
||||
|
||||
Args:
|
||||
key: Identifier for the rate limit bucket (e.g., username or IP).
|
||||
@@ -368,25 +368,29 @@ class RateLimiter:
|
||||
"""
|
||||
now = time.time()
|
||||
cutoff = now - self.window
|
||||
timestamps = self.attempts.get(key, [])
|
||||
timestamps = self.failures.get(key, [])
|
||||
|
||||
# Clean old entries
|
||||
self.attempts[key] = [t for t in timestamps if t > cutoff]
|
||||
self.failures[key] = [t for t in timestamps if t > cutoff]
|
||||
|
||||
if len(self.attempts[key]) >= self.max_attempts:
|
||||
return False
|
||||
return len(self.failures[key]) < self.max_attempts
|
||||
|
||||
self.attempts[key].append(now)
|
||||
return True
|
||||
def record_failure(self, key: str) -> None:
|
||||
"""Record a failed attempt for *key*."""
|
||||
self.failures.setdefault(key, []).append(time.time())
|
||||
|
||||
def record_success(self, key: str) -> None:
|
||||
"""Reset the failure counter for *key* on a successful operation."""
|
||||
self.failures.pop(key, None)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Remove expired entries from all buckets."""
|
||||
now = time.time()
|
||||
cutoff = now - self.window
|
||||
for key in list(self.attempts):
|
||||
self.attempts[key] = [t for t in self.attempts[key] if t > cutoff]
|
||||
if not self.attempts[key]:
|
||||
del self.attempts[key]
|
||||
for key in list(self.failures):
|
||||
self.failures[key] = [t for t in self.failures[key] if t > cutoff]
|
||||
if not self.failures[key]:
|
||||
del self.failures[key]
|
||||
|
||||
|
||||
# Global rate limiters
|
||||
@@ -397,8 +401,9 @@ _webauthn_limiter = RateLimiter(max_attempts=5, window_seconds=600)
|
||||
def check_login_rate(username: str, client_ip: str | None = None) -> bool:
|
||||
"""Check if login is rate-limited for the given username.
|
||||
|
||||
Uses dual-key tracking: always records by IP (catches enumeration attacks),
|
||||
additionally records by username (catches legitimate users who forget password).
|
||||
Checks failure counts for both IP and username buckets without
|
||||
recording anything. Callers must invoke record_login_failure() or
|
||||
record_login_success() after the password verification step.
|
||||
|
||||
Args:
|
||||
username: The login attempt username.
|
||||
@@ -412,6 +417,20 @@ def check_login_rate(username: str, client_ip: str | None = None) -> bool:
|
||||
return _login_limiter.is_allowed(username)
|
||||
|
||||
|
||||
def record_login_failure(username: str, client_ip: str | None = None) -> None:
|
||||
"""Record a failed login attempt."""
|
||||
if client_ip:
|
||||
_login_limiter.record_failure(client_ip)
|
||||
_login_limiter.record_failure(username)
|
||||
|
||||
|
||||
def record_login_success(username: str, client_ip: str | None = None) -> None:
|
||||
"""Record a successful login (resets failure counter)."""
|
||||
if client_ip:
|
||||
_login_limiter.record_success(client_ip)
|
||||
_login_limiter.record_success(username)
|
||||
|
||||
|
||||
def check_webauthn_rate(username: str, client_ip: str | None = None) -> bool:
|
||||
"""Check if WebAuthn authentication is rate-limited for the given username.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user