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:
2026-07-29 02:48:53 +00:00
parent 6d30f1387e
commit 48f8d0be18
11 changed files with 335 additions and 65 deletions
+36 -17
View File
@@ -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.