diff --git a/daemon/handlers/auth.py b/daemon/handlers/auth.py index 9bdb030..87399f0 100644 --- a/daemon/handlers/auth.py +++ b/daemon/handlers/auth.py @@ -155,10 +155,11 @@ def auth_refresh(_request: Any, body: Any) -> dict[str, Any]: """Handle token refresh. Validates the refresh token, blacklists it, and issues a new access token. + Requires refresh_token and session_id in the request body for session binding. Args: _request: Unused. - body: Dict with ``refresh_token``. + body: Dict with ``refresh_token`` and ``session_id``. Returns: Dict with new access token, refresh token, user, and permissions. @@ -173,7 +174,8 @@ def auth_refresh(_request: Any, body: Any) -> dict[str, Any]: if not refresh_token: raise ValueError("refresh_token is required") - payload = validate_token(refresh_token, token_type="refresh") + session_id = body.get("session_id") + payload = validate_token(refresh_token, token_type="refresh", session_id=session_id) if payload is None: raise ValueError("Invalid or expired refresh token") diff --git a/lib/auth.py b/lib/auth.py index c7e65a8..c56d289 100644 --- a/lib/auth.py +++ b/lib/auth.py @@ -126,11 +126,15 @@ def generate_access_token( return jwt.encode(payload, secret, algorithm=algorithm) -def generate_refresh_token(username: str) -> str: +def generate_refresh_token(username: str, session_id: str | None = None) -> str: """Generate a new refresh token for *username*. Args: username: The authenticated username. + session_id: Session binding ID included in the token payload. + When present, the refresh endpoint requires a matching session_id, + preventing a stolen refresh token from being usable without the + originating browser session. Returns: JWT refresh token string. @@ -150,6 +154,8 @@ def generate_refresh_token(username: str) -> str: "jti": str(uuid.uuid4()), "type": "refresh", } + if session_id: + payload["session_id"] = session_id return jwt.encode(payload, secret, algorithm=algorithm) @@ -165,7 +171,7 @@ def generate_tokens(username: str, permissions: dict[str, str]) -> dict[str, str """ session_id = secrets.token_urlsafe(16) access_token = generate_access_token(username, permissions, session_id) - refresh_token = generate_refresh_token(username) + refresh_token = generate_refresh_token(username, session_id) _persist_refresh_token(username, refresh_token) return { "access_token": access_token, @@ -397,7 +403,9 @@ class RateLimiter: del self.failures[key] -# Global rate limiters +# Global rate limiters — in-memory only. Counts reset on daemon restart +# (SIGHUP reload, process restart). Acceptable for a single-user appliance +# where restarts are rare; brute-force windows briefly reset post-restart. _login_limiter = RateLimiter(max_attempts=10, window_seconds=300) _webauthn_limiter = RateLimiter(max_attempts=5, window_seconds=600) diff --git a/tests/test_auth.py b/tests/test_auth.py index 51ed4e2..5472a22 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -811,6 +811,30 @@ class TestTokenValidationEdgeCases: payload = validate_token(token, "refresh") assert payload is None + def test_refresh_token_with_session_id_validates(self) -> None: + """Refresh token with session_id validates when matching session_id provided.""" + create_user("sessuser", "password123") + token = generate_refresh_token("sessuser", session_id="mysession") + payload = validate_token(token, "refresh", session_id="mysession") + assert payload is not None + assert payload["sub"] == "sessuser" + assert payload["session_id"] == "mysession" + + def test_refresh_token_with_wrong_session_id_rejected(self) -> None: + """Refresh token with session_id is rejected when wrong session_id provided.""" + create_user("sessuser", "password123") + token = generate_refresh_token("sessuser", session_id="mysession") + payload = validate_token(token, "refresh", session_id="wrongsession") + assert payload is None + + def test_refresh_token_generate_tokens_includes_session_id(self) -> None: + """generate_tokens produces refresh token with session_id.""" + create_user("tokuser", "password123") + tokens = generate_tokens("tokuser", {"firewall": "rw"}) + refresh = decode_token(tokens["refresh_token"]) + assert refresh is not None + assert refresh["session_id"] == tokens["session_id"] + # ═══════════════════════════════════════════════════════════════════════════ # Multi-user tests (Phase 3) diff --git a/webui/server.py b/webui/server.py index c78d1a4..89bb39f 100644 --- a/webui/server.py +++ b/webui/server.py @@ -259,6 +259,9 @@ def _log_request_finish(response): ) # Content Security Policy — prevent inline script execution and XSS + # NOTE: connect-src 'self' is safe because all XHR/fetch/WS calls go through + # nginx on the same origin. If WS or API routing ever changes to use a + # different host/port directly, the CSP must be updated accordingly. if "Content-Security-Policy" not in response.headers: response.headers["Content-Security-Policy"] = ( "default-src 'self'; " diff --git a/webui/static/app.js b/webui/static/app.js index ce3921d..afeb58a 100644 --- a/webui/static/app.js +++ b/webui/static/app.js @@ -263,6 +263,14 @@ export async function initApp() { render(mainEl, MainContent); } + // Listen for login events to update router state after auth + window.addEventListener('auth:login', () => { + router.isAuthenticated = true; + if (!router.state.path.startsWith('/login')) { + fetchInitialData(); + } + }); + // Check auth state before connecting WS const ok = await initAuth(); if (ok) { diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js index 6f6fa2a..2c1c0f5 100644 --- a/webui/static/hoover/api.js +++ b/webui/static/hoover/api.js @@ -83,41 +83,44 @@ let _refreshPromise = null; * * @returns {Promise} ``true`` if refresh succeeded */ -async function tryRefreshToken() { - _refreshPromise = _refreshPromise || (async () => { - try { - const stored = getStoredAuth(); - if (!stored.refresh) return false; + async function tryRefreshToken() { + if (!_refreshPromise) { + _refreshPromise = (async () => { + try { + const stored = getStoredAuth(); + if (!stored.refresh) return false; - const res = await fetch('/api/auth/refresh', { - method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ refresh_token: stored.refresh }), - credentials: 'same-origin', - }); - if (res.status !== 200) { + const res = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ refresh_token: stored.refresh, session_id: stored.session_id }), + credentials: 'same-origin', + }); + if (res.status !== 200) { + clearAuthTokens(); + return false; + } + const json = await res.json(); + if (!json.ok || !json.data?.tokens) { + clearAuthTokens(); + return false; + } + const tokens = json.data.tokens; + setAuthToken(tokens.access_token); + sessionStorage.setItem('vw:refresh', tokens.refresh_token); + sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000)); + sessionStorage.setItem('vw:session_id', tokens.session_id); + if (json.data.user) { + sessionStorage.setItem('vw:user', JSON.stringify(json.data.user)); + } + return true; + } catch (err) { + console.warn('[Auth] Token refresh failed:', err); clearAuthTokens(); return false; } - const json = await res.json(); - if (!json.ok || !json.data?.tokens) { - clearAuthTokens(); - return false; - } - const tokens = json.data.tokens; - setAuthToken(tokens.access_token); - sessionStorage.setItem('vw:refresh', tokens.refresh_token); - sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000)); - sessionStorage.setItem('vw:session_id', tokens.session_id); - if (json.data.user) { - sessionStorage.setItem('vw:user', JSON.stringify(json.data.user)); - } - return true; - } catch { - clearAuthTokens(); - return false; - } - })(); + })(); + } return _refreshPromise.finally(() => { _refreshPromise = null; }); } diff --git a/webui/static/hoover/components/auth.js b/webui/static/hoover/components/auth.js index f05a601..8954c0c 100644 --- a/webui/static/hoover/components/auth.js +++ b/webui/static/hoover/components/auth.js @@ -130,6 +130,13 @@ export function handleLoginSuccess(data, redirectPath = '/dashboard') { } scheduleTokenRefresh(); } + // Notify app.js that auth is established (used to set router.isAuthenticated) + window.dispatchEvent(new CustomEvent('auth:login', { + detail: { + permissions: sessionStorage.getItem('vw:permissions') ? + JSON.parse(sessionStorage.getItem('vw:permissions')) : {}, + }, + })); window.location.hash = redirectPath; } diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js index 8545867..31ac0de 100644 --- a/webui/static/hoover/websocket.js +++ b/webui/static/hoover/websocket.js @@ -91,7 +91,9 @@ function _wsConnect() { try { const msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data; handleMessage(msg); - } catch (_) {} + } catch (err) { + console.warn('[WS] Failed to parse message:', err); + } }; } @@ -124,7 +126,7 @@ function handleMessage(msg) { for (const h of _directHandlers) { if (h.unsubscribed) continue; if (topics.some(t => h.topics.includes(t) || h.topics.includes('*'))) { - try { h.handler(msg); } catch (_) {} + try { h.handler(msg); } catch (err) { console.warn('[WS] Handler error:', err); } } } }