fix: harden dashboard RBAC and cookie auth flows
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m17s

Restrict Docker/Files writes to admins, move terminal websocket auth to cookie-first with temporary query fallback, and migrate refresh-token handling to httpOnly cookies for safer session persistence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gan, Jimmy
2026-03-09 23:44:23 +08:00
parent e72665c466
commit 4201917e17
11 changed files with 185 additions and 115 deletions
+12 -7
View File
@@ -14,7 +14,7 @@
import InfoEngine from "./routes/InfoEngine.svelte";
import Login from "./routes/Login.svelte";
import { onMount } from "svelte";
import { getToken, checkAuth, setToken, setRefreshToken, currentUser, setCurrentUser, getPreferences, savePreferences } from "./lib/api.js";
import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js";
const KNOWN_PAGES = new Set([
"dashboard",
@@ -106,11 +106,10 @@
// Try proxy auth first (Authelia forward-auth sets Remote-User header)
try {
const proxyRes = await fetch("/api/auth/proxy");
const proxyRes = await fetch("/api/auth/proxy", { credentials: "same-origin" });
if (proxyRes.ok) {
const data = await proxyRes.json();
setToken(data.access_token);
setRefreshToken(data.refresh_token);
authorized = true;
await fetchUserInfo();
const requestedPage = getRequestedPage();
@@ -123,11 +122,13 @@
}
// Regular token auth check
const token = getToken();
if (!token) {
if (!getToken()) {
const refreshed = await tryRefreshSession();
if (!refreshed) {
loading = false;
authorized = false;
return;
}
}
try {
@@ -160,9 +161,13 @@
}
}
function logout() {
async function logout() {
try {
await logoutSession();
} catch (e) {
console.error("Logout failed:", e);
}
setToken("");
setRefreshToken("");
window.location.reload();
}
</script>
+18 -30
View File
@@ -1,5 +1,5 @@
const BASE = "/api";
let token = localStorage.getItem("token") || "";
let token = "";
// Current user info populated after auth
export let currentUser = { username: "", role: "", pages: [] };
@@ -18,33 +18,24 @@ export function getToken() {
return token;
}
export function setRefreshToken(t) {
if (t) localStorage.setItem("refresh_token", t);
else localStorage.removeItem("refresh_token");
}
function getRefreshToken() {
return localStorage.getItem("refresh_token") || "";
}
// Refresh token is cookie-managed server-side.
export function setRefreshToken() {}
let refreshPromise = null;
async function tryRefresh() {
export async function tryRefreshSession() {
if (refreshPromise) return refreshPromise;
const rt = getRefreshToken();
if (!rt) return false;
refreshPromise = (async () => {
try {
const r = await fetch(BASE + "/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: rt }),
credentials: "same-origin",
});
if (!r.ok) return false;
const data = await r.json();
setToken(data.access_token);
setRefreshToken(data.refresh_token);
return true;
setToken(data.access_token || "");
return !!data.access_token;
} catch {
return false;
} finally {
@@ -54,15 +45,6 @@ async function tryRefresh() {
return refreshPromise;
}
export async function getWebSocketToken(forceRefresh = false) {
if (!forceRefresh && token) return token;
const refreshed = await tryRefresh();
if (refreshed && token) return token;
setToken("");
setRefreshToken("");
return "";
}
async function request(path, opts = {}) {
const headers = opts.headers || {};
if (token) headers["Authorization"] = `Bearer ${token}`;
@@ -73,27 +55,29 @@ async function request(path, opts = {}) {
delete opts.json;
}
const fetchOpts = { ...opts, headers };
if (path.startsWith("/auth/")) fetchOpts.credentials = "same-origin";
try {
let r = await fetch(BASE + path, { ...opts, headers });
let r = await fetch(BASE + path, fetchOpts);
if (r.status === 401 && !path.includes("/auth/refresh")) {
const refreshed = await tryRefresh();
const refreshed = await tryRefreshSession();
if (refreshed) {
headers["Authorization"] = `Bearer ${token}`;
r = await fetch(BASE + path, { ...opts, headers });
r = await fetch(BASE + path, fetchOpts);
}
}
if (r.status === 401) {
setToken("");
setRefreshToken("");
throw new Error("Unauthorized");
}
if (!r.ok) {
const error = new Error(`HTTP ${r.status}`);
error.status = r.status;
try { error.body = await r.json(); } catch { }
try { error.body = await r.json(); } catch {}
throw error;
}
return r.json();
@@ -140,6 +124,10 @@ export function checkAuth() {
return request("/auth/me");
}
export function logout() {
return request("/auth/logout", { method: "POST" });
}
export function getPreferences() {
return get("/auth/preferences");
}
+1 -3
View File
@@ -1,5 +1,5 @@
<script>
import { login, setToken, setRefreshToken, get, post } from "../lib/api.js";
import { login, setToken } from "../lib/api.js";
import { fade, fly } from "svelte/transition";
let username = $state("");
@@ -56,7 +56,6 @@
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Passkey verification failed"); }
const data = await res.json();
setToken(data.access_token);
setRefreshToken(data.refresh_token);
window.location.reload();
} catch (e) {
if (e.name === "NotAllowedError") { error = "Passkey authentication cancelled"; }
@@ -79,7 +78,6 @@
const res = await login(payload); // api.js helper sends JSON
setToken(res.access_token);
setRefreshToken(res.refresh_token);
// Determine if we need to reload or just update state.
// Reload is safest to clear any stale state.
window.location.reload();
+10 -8
View File
@@ -1,7 +1,7 @@
<script>
import { onMount, onDestroy } from "svelte";
import { VoiceSession } from "../lib/voice.js";
import { getWebSocketToken } from "../lib/api.js";
import { tryRefreshSession } from "../lib/api.js";
const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:";
const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
@@ -253,14 +253,16 @@
}
async function connect(forceRefresh = false) {
const token = await getWebSocketToken(forceRefresh);
if (!token) {
handleAuthExpired();
return null;
if (forceRefresh) {
const refreshed = await tryRefreshSession();
if (!refreshed) {
handleAuthExpired();
return null;
}
}
const ws = new WebSocket(
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}`
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}`
);
ws.binaryType = "arraybuffer";
@@ -307,9 +309,9 @@
if (e.code === 1008 && reason === "Unauthorized") {
if (!closedManually && !authRecoveryInFlight) {
authRecoveryInFlight = true;
const recoveredToken = await getWebSocketToken(true);
const refreshed = await tryRefreshSession();
authRecoveryInFlight = false;
if (recoveredToken) {
if (refreshed) {
reconnecting = true;
updateTab(tabId, {
connected: false,