diff --git a/AppImage/ProxMenux-1.2.1.1-beta.AppImage b/AppImage/ProxMenux-1.2.1.1-beta.AppImage index 8e23f573..bac4fdbc 100755 Binary files a/AppImage/ProxMenux-1.2.1.1-beta.AppImage and b/AppImage/ProxMenux-1.2.1.1-beta.AppImage differ diff --git a/AppImage/ProxMenux-1.2.1.1-beta.AppImage.sha256 b/AppImage/ProxMenux-1.2.1.1-beta.AppImage.sha256 index 065a3188..fc9813ba 100644 --- a/AppImage/ProxMenux-1.2.1.1-beta.AppImage.sha256 +++ b/AppImage/ProxMenux-1.2.1.1-beta.AppImage.sha256 @@ -1 +1 @@ -774332ee537b6c24caadc0a2f8ad5c8952f4a07d7c875ec04e6a310383371c09 /tmp/ProxMenux-1.2.1.1-beta.AppImage +e1a6ae02b7a8dc65cf31c6b3fecfd3719531377adc0978a12f949edbf3cac8e3 /tmp/ProxMenux-1.2.1.1-beta.AppImage diff --git a/AppImage/app/page.tsx b/AppImage/app/page.tsx index 826117f7..f2b8e2a7 100644 --- a/AppImage/app/page.tsx +++ b/AppImage/app/page.tsx @@ -29,17 +29,38 @@ export default function Home() { const response = await fetch(getApiUrl("/api/auth/status"), { headers: token ? { Authorization: `Bearer ${token}` } : {}, }) - + + // 401 here means the token is present but invalid — typically signed + // under a previous jwt_secret (rotated on AppImage upgrade or fresh + // install). If we let this fall into the catch below, the dashboard + // would render and every authenticated component would fire its own + // 401 in parallel, flooding the backend logs and looping reloads. + // Drop the dead token and force the Login screen instead. + if (response.status === 401) { + try { + localStorage.removeItem("proxmenux-auth-token") + } catch { + // private browsing — best-effort + } + setAuthStatus({ + loading: false, + authEnabled: true, + authConfigured: true, + authenticated: false, + }) + return + } + // Check if response is valid JSON before parsing if (!response.ok) { throw new Error(`HTTP ${response.status}`) } - + const contentType = response.headers.get("content-type") if (!contentType || !contentType.includes("application/json")) { throw new Error("Response is not JSON") } - + const data = await response.json() const authenticated = data.auth_enabled ? data.authenticated : true diff --git a/AppImage/components/login.tsx b/AppImage/components/login.tsx index f027a7ad..bef41889 100644 --- a/AppImage/components/login.tsx +++ b/AppImage/components/login.tsx @@ -76,6 +76,11 @@ export function Login({ onLogin }: LoginProps) { } localStorage.setItem("proxmenux-auth-token", data.token) + try { + sessionStorage.removeItem("proxmenux-auth-401-handled") + } catch { + // ignore + } if (rememberMe) { localStorage.setItem("proxmenux-saved-username", username) diff --git a/AppImage/lib/api-config.ts b/AppImage/lib/api-config.ts index 1927654c..afeb2be7 100644 --- a/AppImage/lib/api-config.ts +++ b/AppImage/lib/api-config.ts @@ -92,19 +92,28 @@ export async function fetchApi(endpoint: string, options?: RequestInit): Prom if (!response.ok) { if (response.status === 401) { // Token is missing, expired, or signed under a previous JWT_SECRET - // (audit Tier 4 #22 rotates per-install). Drop the stale token and - // bounce the user to login — the previous behavior just threw and - // left the dashboard stuck on a blank state. Audit Tier 2 residual. + // (rotated per-install). Drop the stale token and force a single + // reload so the page-level auth gate (`app/page.tsx`) can render + // instead of cascading 401s from every authenticated + // component on mount. The sessionStorage flag is essential: a + // page like Hardware/Storage fires 10-20 SWR fetches in parallel, + // and without dedup each of them would race to reload the tab — + // observed in the wild as ~180 "Invalid token" log lines per + // second from a single browser running an upgraded Monitor. if (typeof window !== "undefined") { try { localStorage.removeItem("proxmenux-auth-token") } catch { // localStorage might be unavailable in private browsing — ignore. } - // Avoid redirect loops if we're already on the auth page. - const path = window.location.pathname - if (!path.startsWith("/auth") && !path.startsWith("/login")) { - window.location.assign("/") + try { + if (!sessionStorage.getItem("proxmenux-auth-401-handled")) { + sessionStorage.setItem("proxmenux-auth-401-handled", "1") + window.location.reload() + } + } catch { + // sessionStorage unavailable — fall back to a plain reload. + window.location.reload() } } throw new Error(`Unauthorized: ${endpoint}`) diff --git a/AppImage/scripts/auth_manager.py b/AppImage/scripts/auth_manager.py index 3bed9fc7..d080f366 100644 --- a/AppImage/scripts/auth_manager.py +++ b/AppImage/scripts/auth_manager.py @@ -14,6 +14,8 @@ import hashlib import hmac import secrets import base64 +import threading +import time from datetime import datetime, timedelta from pathlib import Path @@ -341,6 +343,40 @@ def verify_token_full(token): return None, None +_AUTH_LOG_RATE = {'last_ts': 0.0, 'suppressed': 0, 'last_msg': ''} +_AUTH_LOG_LOCK = threading.Lock() + + +def _log_auth_failure_throttled(msg): + """Log a JWT verification failure at most once every 30 seconds. + + A browser whose token was invalidated by a jwt_secret rotation can + fire dozens of authenticated requests per page load (SWR fetches + + WebSocket reconnects); without throttling this floods the journal + with hundreds of identical 'Invalid token: Signature verification + failed' lines per second and stalls journald. We keep the first + occurrence verbatim and emit one summary line every 30s with the + suppressed count, so the operator still has visibility of the + issue without the cascade. + """ + now = time.time() + with _AUTH_LOG_LOCK: + elapsed = now - _AUTH_LOG_RATE['last_ts'] + if elapsed >= 30: + if _AUTH_LOG_RATE['suppressed']: + print(f"[auth] {_AUTH_LOG_RATE['last_msg']} " + f"(+{_AUTH_LOG_RATE['suppressed']} more in last " + f"{int(elapsed)}s)") + else: + print(f"[auth] {msg}") + _AUTH_LOG_RATE['last_ts'] = now + _AUTH_LOG_RATE['suppressed'] = 0 + _AUTH_LOG_RATE['last_msg'] = msg + else: + _AUTH_LOG_RATE['suppressed'] += 1 + _AUTH_LOG_RATE['last_msg'] = msg + + def verify_token(token): """ Verify a JWT token @@ -376,10 +412,10 @@ def verify_token(token): payload = jwt.decode(token, _get_jwt_secret(), algorithms=[JWT_ALGORITHM]) return payload.get('username') except jwt.ExpiredSignatureError: - print("Token has expired") + _log_auth_failure_throttled("Token has expired") return None except jwt.InvalidTokenError as e: - print(f"Invalid token: {e}") + _log_auth_failure_throttled(f"Invalid token: {e}") return None