From 3654209b78a9aedc0f1f2c195f7c5f01c391dbe4 Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Wed, 12 Aug 2026 04:27:14 +0000 Subject: [PATCH] auth: fix WS session_id extraction and track WebAuthn success/failure Browsers cannot send custom X-Session-Id header on WebSocket connections, so decode the token payload to extract session_id. Add WebAuthn success/failure recording to support rate limiter counter resets. --- daemon/handlers/auth.py | 22 +++++++++++++++------- daemon/server.py | 13 +++++++++---- lib/auth.py | 14 ++++++++++++++ 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/daemon/handlers/auth.py b/daemon/handlers/auth.py index b68dc19..9bdb030 100644 --- a/daemon/handlers/auth.py +++ b/daemon/handlers/auth.py @@ -39,6 +39,8 @@ from lib.auth import ( get_access_ttl, record_login_failure, record_login_success, + record_webauthn_failure, + record_webauthn_success, validate_token, ) from lib.auth_users import ( @@ -519,13 +521,19 @@ def webauthn_authenticate_finish(_request: Any, body: Any) -> dict[str, Any]: if not check_webauthn_rate(username, client_ip): raise ValueError("Too many WebAuthn attempts. Please try again later.") - verify_authentication( - username, - assertion_response, - auth_options, - origin=origin, - rp_id=rp_id, - ) + try: + verify_authentication( + username, + assertion_response, + auth_options, + origin=origin, + rp_id=rp_id, + ) + except ValueError: + record_webauthn_failure(username, client_ip) + raise + + record_webauthn_success(username, client_ip) user = get_user(username) if user is None: diff --git a/daemon/server.py b/daemon/server.py index 11aef5f..2b06643 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -373,7 +373,7 @@ async def _handle_ws(request: web.Request) -> web.Response: 1. WebSocket subprotocol header (Sec-WebSocket-Protocol: "Bearer ") 2. X-Auth-Token header (nginx-injected) """ - from lib.auth import validate_token + from lib.auth import decode_token, validate_token token_param = None @@ -392,11 +392,16 @@ async def _handle_ws(request: web.Request) -> web.Response: {"ok": False, "error": "authentication required"}, status=401 ) - session_header = request.headers.get("X-Session-Id") - if not session_header: + # Decode token to extract session_id from payload (browsers can't send + # X-Session-Id header on WebSocket connections, only subprotocols) + raw_payload = decode_token(token_param) + if raw_payload is None: return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + payload = validate_token( - token_param, token_type="access", session_id=session_header + token_param, + token_type="access", + session_id=raw_payload.get("session_id"), ) if payload is None: return web.json_response({"ok": False, "error": "unauthorized"}, status=401) diff --git a/lib/auth.py b/lib/auth.py index afae780..9502c14 100644 --- a/lib/auth.py +++ b/lib/auth.py @@ -453,3 +453,17 @@ def check_webauthn_rate(username: str, client_ip: str | None = None) -> bool: if client_ip and not _webauthn_limiter.is_allowed(client_ip): return False return _webauthn_limiter.is_allowed(username) + + +def record_webauthn_failure(username: str, client_ip: str | None = None) -> None: + """Record a failed WebAuthn attempt.""" + if client_ip: + _webauthn_limiter.record_failure(client_ip) + _webauthn_limiter.record_failure(username) + + +def record_webauthn_success(username: str, client_ip: str | None = None) -> None: + """Record a successful WebAuthn attempt (resets failure counter).""" + if client_ip: + _webauthn_limiter.record_success(client_ip) + _webauthn_limiter.record_success(username)