diff --git a/daemon/handlers/auth.py b/daemon/handlers/auth.py index e218c40..96f87a7 100644 --- a/daemon/handlers/auth.py +++ b/daemon/handlers/auth.py @@ -90,7 +90,8 @@ def auth_login(_request: Any, body: Any) -> dict[str, Any]: if not username or not password: raise ValueError("username and password are required") - if not check_login_rate(username): + client_ip = body.get("client_ip") + if not check_login_rate(username, client_ip): raise ValueError("Too many login attempts. Please try again later.") user = verify_user_password(username, password) @@ -449,11 +450,12 @@ def webauthn_authenticate_finish(_request: Any, body: Any) -> dict[str, Any]: username = body.get("username") assertion_response = body.get("assertion_response") auth_options = body.get("auth_options") + client_ip = body.get("client_ip") if not username or not assertion_response or not auth_options: raise ValueError("username, assertion_response, and auth_options are required") - if not check_webauthn_rate(username): + if not check_webauthn_rate(username, client_ip): raise ValueError("Too many WebAuthn attempts. Please try again later.") try: diff --git a/lib/auth.py b/lib/auth.py index 9f93ccf..166ba9c 100644 --- a/lib/auth.py +++ b/lib/auth.py @@ -394,25 +394,34 @@ _login_limiter = RateLimiter(max_attempts=10, window_seconds=300) _webauthn_limiter = RateLimiter(max_attempts=5, window_seconds=600) -def check_login_rate(username: str) -> bool: +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). + Args: username: The login attempt username. + client_ip: The client IP address (from X-Real-IP header). Returns: True if the attempt is allowed, False if rate limited. """ + if client_ip and not _login_limiter.is_allowed(client_ip): + return False return _login_limiter.is_allowed(username) -def check_webauthn_rate(username: str) -> bool: +def check_webauthn_rate(username: str, client_ip: str | None = None) -> bool: """Check if WebAuthn authentication is rate-limited for the given username. Args: username: The WebAuthn attempt username. + client_ip: The client IP address (from X-Real-IP header). Returns: True if the attempt is allowed, False if rate limited. """ + if client_ip and not _webauthn_limiter.is_allowed(client_ip): + return False return _webauthn_limiter.is_allowed(username) diff --git a/webui/api/auth.py b/webui/api/auth.py index 7e4a7bb..167a949 100644 --- a/webui/api/auth.py +++ b/webui/api/auth.py @@ -49,6 +49,7 @@ def login(): """ try: body = request.get_json(silent=True) or {} + body["client_ip"] = request.headers.get("X-Real-IP") or request.remote_addr return _ok(post(POST_AUTH_LOGIN, body)) except Exception as exc: logger.error("Login failed: %s", exc) @@ -289,6 +290,7 @@ def webauthn_authenticate_finish(): """ try: body = request.get_json(silent=True) or {} + body["client_ip"] = request.headers.get("X-Real-IP") or request.remote_addr return _ok(post(POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH, body)) except Exception as exc: logger.error("WebAuthn authenticate finish failed: %s", exc) diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js index 0b27746..fbb3f0e 100644 --- a/webui/static/hoover/websocket.js +++ b/webui/static/hoover/websocket.js @@ -12,6 +12,7 @@ import { tryRefreshToken } from './api.js?v=12'; let _wsConn = null; let _wsReconnectMs = 0; let _wsFailCount = 0; +let _wsRefreshing = false; /** Direct onMessage handlers — { topics, handler, unsubscribed }[] */ const _directHandlers = []; @@ -44,28 +45,24 @@ function _wsConnect() { _wsConn.onopen = () => { _wsReconnectMs = 0; _wsFailCount = 0; + _wsRefreshing = false; }; _wsConn.onclose = () => { if (!window.__auth_token__) return; _wsFailCount++; - if (_wsFailCount >= 3) { - // Attempt token refresh after repeated failures. The reconnect - // is handled inside the IIFE to avoid double-scheduling when - // refresh succeeds. - (async () => { - const ok = await tryRefreshToken(); - if (ok) { - _wsFailCount = 0; - _wsReconnectMs = 0; - _wsConn = null; - setTimeout(_wsConnect, 100); - } else { - _wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000); - setTimeout(_wsConnect, _wsReconnectMs); - } - })(); + if (_wsFailCount >= 3 && !_wsRefreshing) { + _wsRefreshing = true; + tryRefreshToken().then(ok => { + _wsRefreshing = false; + _wsFailCount = 0; + _wsReconnectMs = 0; + _wsConn = null; + setTimeout(_wsConnect, 100); + }).catch(() => { + _wsRefreshing = false; + }); return; }