fix: dual-key rate limiting for auth + websocket reconnect guard

- Pass client IP (X-Real-IP header) through Flask to daemon for both
  password login and WebAuthn authenticate-finish endpoints
- Rate limiter now checks both IP and username buckets: IP layer catches
  enumeration/brute-force attacks across multiple usernames; username
  layer protects against single-account targeting from multiple IPs
- Add _wsRefreshing flag to prevent double-scheduling reconnect when
  onclose fires during token refresh; simplify async IIFE to .then()/.catch()
- Reset _wsRefreshing on websocket onopen for safety
This commit is contained in:
2026-07-28 00:54:48 +00:00
parent 76cd219050
commit 358573567d
4 changed files with 30 additions and 20 deletions
+4 -2
View File
@@ -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:
+11 -2
View File
@@ -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)
+2
View File
@@ -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)
+9 -12
View File
@@ -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) {
if (_wsFailCount >= 3 && !_wsRefreshing) {
_wsRefreshing = true;
tryRefreshToken().then(ok => {
_wsRefreshing = false;
_wsFailCount = 0;
_wsReconnectMs = 0;
_wsConn = null;
setTimeout(_wsConnect, 100);
} else {
_wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000);
setTimeout(_wsConnect, _wsReconnectMs);
}
})();
}).catch(() => {
_wsRefreshing = false;
});
return;
}