fix: harden dashboard RBAC and cookie auth flows
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m17s
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:
@@ -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>
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user