From e80736ec378064046c85935bd383befbb182693c Mon Sep 17 00:00:00 2001 From: KingKDot Date: Mon, 13 Apr 2026 17:15:06 -0500 Subject: [PATCH] huge update to RBAC --- Overlord-Server/public/assets/console.js | 3 +- Overlord-Server/public/assets/feature-gate.js | 76 ++++++ Overlord-Server/public/assets/filebrowser.js | 3 +- Overlord-Server/public/assets/hvnc.js | 7 +- Overlord-Server/public/assets/keylogger.js | 3 +- Overlord-Server/public/assets/processes.js | 3 +- .../public/assets/remotedesktop.js | 7 +- Overlord-Server/public/assets/settings.js | 149 +++++++++++ Overlord-Server/public/assets/users.js | 247 ++++++++++++++++++ Overlord-Server/public/assets/voice.js | 9 +- Overlord-Server/public/assets/webcam.js | 6 +- Overlord-Server/public/settings.html | 44 ++++ Overlord-Server/src/auth.ts | 56 +++- Overlord-Server/src/db.ts | 130 +++++++++ .../src/server/routes/auth-routes.ts | 158 ++++++++++- .../src/server/routes/file-download-routes.ts | 9 + .../src/server/routes/users-routes.ts | 85 +++++- .../src/server/routes/ws-upgrade-routes.ts | 50 +++- .../src/server/ws-console-rd-hvnc.ts | 25 +- .../server/ws-file-process-proxy-keylogger.ts | 19 +- Overlord-Server/src/server/ws-voice.ts | 7 +- Overlord-Server/src/users.ts | 140 ++++++++++ 22 files changed, 1205 insertions(+), 31 deletions(-) create mode 100644 Overlord-Server/public/assets/feature-gate.js diff --git a/Overlord-Server/public/assets/console.js b/Overlord-Server/public/assets/console.js index bb0f1a1..bb9504e 100644 --- a/Overlord-Server/public/assets/console.js +++ b/Overlord-Server/public/assets/console.js @@ -1,5 +1,6 @@ import { ansiToHtml } from "./ansi.js"; import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; +import { checkFeatureAccess } from "./feature-gate.js"; const outputEl = document.getElementById("console-output"); const statusPill = document.getElementById("status-pill"); const clientLabel = document.getElementById("client-label"); @@ -152,4 +153,4 @@ function wireInput() { }); } wireInput(); -connect(); +checkFeatureAccess("console", clientId).then(ok => ok && connect()); diff --git a/Overlord-Server/public/assets/feature-gate.js b/Overlord-Server/public/assets/feature-gate.js new file mode 100644 index 0000000..36c2e71 --- /dev/null +++ b/Overlord-Server/public/assets/feature-gate.js @@ -0,0 +1,76 @@ +const DENY_LABELS = { + feature: "You do not have permission to access this feature.", + client: "You do not have access to this client.", +}; + +/** + * @param {string} feature + * @param {string} clientId + * @returns {Promise} + */ +export async function checkFeatureAccess(feature, clientId) { + try { + const params = new URLSearchParams({ feature, clientId }); + const res = await fetch(`/api/auth/feature-check?${params}`, { credentials: "include" }); + + if (res.status === 401) { + window.location.href = "/login.html"; + return false; + } + + if (!res.ok) return true; + + const data = await res.json(); + if (data.allowed) return true; + + const reasons = Array.isArray(data.denied) ? data.denied : []; + showAccessDenied(reasons); + return false; + } catch { + return true; + } +} + +function showAccessDenied(reasons) { + const messages = reasons.map((r) => DENY_LABELS[r] || "Access denied.").join(" "); + + document.body.style.visibility = "hidden"; + + const overlay = document.createElement("div"); + overlay.id = "access-denied-overlay"; + overlay.style.cssText = + "position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:#0f172a;visibility:visible;"; + + const card = document.createElement("div"); + card.style.cssText = + "max-width:420px;width:90%;text-align:center;padding:2rem;border-radius:1rem;background:#1e293b;border:1px solid #dc2626;"; + + card.innerHTML = ` +
+ +
+

Access Denied

+

+ ${escapeHtml(messages)} +

+

+ Contact your administrator to request access. +

+ `; + + const closeBtn = document.createElement("button"); + closeBtn.textContent = "Close Tab"; + closeBtn.style.cssText = + "padding:0.5rem 1.25rem;border-radius:0.5rem;background:#334155;color:#e2e8f0;border:1px solid #475569;cursor:pointer;font-size:0.875rem;"; + closeBtn.addEventListener("click", () => window.close()); + + card.appendChild(closeBtn); + overlay.appendChild(card); + document.body.appendChild(overlay); +} + +function escapeHtml(text) { + const el = document.createElement("span"); + el.textContent = text; + return el.innerHTML; +} diff --git a/Overlord-Server/public/assets/filebrowser.js b/Overlord-Server/public/assets/filebrowser.js index 726f64c..875253a 100644 --- a/Overlord-Server/public/assets/filebrowser.js +++ b/Overlord-Server/public/assets/filebrowser.js @@ -1,4 +1,5 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; +import { checkFeatureAccess } from "./feature-gate.js"; const clientId = window.location.pathname.split("/")[1]; let ws = null; @@ -2234,7 +2235,7 @@ function highlightSidebarActive() { }); } -connect(); +checkFeatureAccess("file_browser", clientId).then(ok => ok && connect()); function addTransferToUI(transfer) { const transferItem = document.createElement("div"); diff --git a/Overlord-Server/public/assets/hvnc.js b/Overlord-Server/public/assets/hvnc.js index 2b6d3b6..13540b5 100644 --- a/Overlord-Server/public/assets/hvnc.js +++ b/Overlord-Server/public/assets/hvnc.js @@ -1,11 +1,16 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; +import { checkFeatureAccess } from "./feature-gate.js"; -(function () { +(async function () { const clientId = new URLSearchParams(location.search).get("clientId"); if (!clientId) { alert("Missing clientId"); return; } + + const allowed = await checkFeatureAccess("hvnc", clientId); + if (!allowed) return; + const clientLabel = document.getElementById("clientLabel"); clientLabel.textContent = clientId; diff --git a/Overlord-Server/public/assets/keylogger.js b/Overlord-Server/public/assets/keylogger.js index c625102..b1d2197 100644 --- a/Overlord-Server/public/assets/keylogger.js +++ b/Overlord-Server/public/assets/keylogger.js @@ -1,4 +1,5 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; +import { checkFeatureAccess } from "./feature-gate.js"; const clientId = window.location.pathname.split("/")[1]; if (!clientId) { @@ -712,4 +713,4 @@ fileList.addEventListener("click", (event) => { }); import("/assets/nav.js"); -connect(); +checkFeatureAccess("keylogger", clientId).then(ok => ok && connect()); diff --git a/Overlord-Server/public/assets/processes.js b/Overlord-Server/public/assets/processes.js index e488b55..b5cf816 100644 --- a/Overlord-Server/public/assets/processes.js +++ b/Overlord-Server/public/assets/processes.js @@ -1,4 +1,5 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; +import { checkFeatureAccess } from "./feature-gate.js"; const clientId = window.location.pathname.split("/")[1]; let ws = null; @@ -426,5 +427,5 @@ setInterval(() => { }, 3000); updateStatus("connecting", "Connecting..."); -connect(); +checkFeatureAccess("processes", clientId).then(ok => ok && connect()); updateSortIndicators(); diff --git a/Overlord-Server/public/assets/remotedesktop.js b/Overlord-Server/public/assets/remotedesktop.js index 4b155f6..9ed1bc1 100644 --- a/Overlord-Server/public/assets/remotedesktop.js +++ b/Overlord-Server/public/assets/remotedesktop.js @@ -1,11 +1,16 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; +import { checkFeatureAccess } from "./feature-gate.js"; -(function () { +(async function () { const clientId = new URLSearchParams(location.search).get("clientId"); if (!clientId) { alert("Missing clientId"); return; } + + const allowed = await checkFeatureAccess("remote_desktop", clientId); + if (!allowed) return; + const clientLabel = document.getElementById("clientLabel"); clientLabel.textContent = clientId; diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index a1b9cf4..f43083b 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -827,6 +827,151 @@ async function wipeOfflineClients() { } } +const sessionsTableBody = document.getElementById("sessions-table-body"); +const sessionsMessage = document.getElementById("sessions-message"); +const refreshSessionsBtn = document.getElementById("refresh-sessions-btn"); + +function parseUserAgent(ua) { + if (!ua) return "Unknown"; + if (ua.length > 80) return ua.substring(0, 77) + "..."; + return ua; +} + +function formatRelativeTime(epochSeconds) { + if (!epochSeconds) return "—"; + const diff = Math.floor(Date.now() / 1000) - epochSeconds; + if (diff < 60) return "Just now"; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +function showSessionsMessage(text, type = "ok") { + if (!sessionsMessage) return; + sessionsMessage.textContent = text; + sessionsMessage.classList.remove( + "hidden", + "text-emerald-200", "border-emerald-700", "bg-emerald-900/30", + "text-rose-200", "border-rose-700", "bg-rose-900/30", + ); + if (type === "error") { + sessionsMessage.classList.add("text-rose-200", "border-rose-700", "bg-rose-900/30"); + } else { + sessionsMessage.classList.add("text-emerald-200", "border-emerald-700", "bg-emerald-900/30"); + } + setTimeout(() => sessionsMessage.classList.add("hidden"), 5000); +} + +async function loadSessions() { + if (!sessionsTableBody) return; + + try { + const res = await fetch("/api/sessions", { credentials: "include" }); + if (!res.ok) { + sessionsTableBody.innerHTML = ` + + Failed to load sessions + + `; + return; + } + + const data = await res.json(); + const sessions = Array.isArray(data.sessions) ? data.sessions : []; + + if (sessions.length === 0) { + sessionsTableBody.innerHTML = ` + + No sessions found + + `; + return; + } + + sessionsTableBody.innerHTML = sessions + .map((s) => { + const isExpired = s.expiresAt && s.expiresAt < Math.floor(Date.now() / 1000); + const statusLabel = s.revoked + ? 'Revoked' + : isExpired + ? 'Expired' + : 'Active'; + const currentBadge = s.current + ? ' Current' + : ""; + const canRevoke = !s.revoked && !isExpired; + + return ` + + ${escapeHtml(s.ip || "—")} + ${escapeHtml(parseUserAgent(s.userAgent))} + ${formatDate(s.createdAt)} + ${formatRelativeTime(s.lastActivity)} + ${statusLabel}${currentBadge} + + ${canRevoke ? ` + + ` : ""} + + + `; + }) + .join(""); + } catch (err) { + sessionsTableBody.innerHTML = ` + + Error loading sessions + + `; + } +} + +async function handleRevokeSessionClick(event) { + const button = event.target.closest(".revoke-session-btn"); + if (!button) return; + + const sessionId = button.dataset.sessionId; + const isCurrent = button.dataset.isCurrent === "true"; + + if (isCurrent) { + if (!confirm("This will revoke your current session and log you out. Continue?")) return; + } + + button.disabled = true; + + try { + const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { + method: "DELETE", + credentials: "include", + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + showSessionsMessage(data.error || "Failed to revoke session", "error"); + button.disabled = false; + return; + } + + if (isCurrent) { + window.location.href = "/login.html"; + return; + } + + showSessionsMessage("Session revoked successfully"); + await loadSessions(); + } catch { + showSessionsMessage("Request failed", "error"); + button.disabled = false; + } +} + async function init() { try { await loadCurrentUser(); @@ -872,6 +1017,10 @@ async function init() { refreshBansBtn.addEventListener("click", loadBannedIps); bansTableBody.addEventListener("click", handleUnbanClick); if (wipeOfflineBtn) wipeOfflineBtn.addEventListener("click", wipeOfflineClients); + + await loadSessions(); + if (refreshSessionsBtn) refreshSessionsBtn.addEventListener("click", loadSessions); + if (sessionsTableBody) sessionsTableBody.addEventListener("click", handleRevokeSessionClick); } catch (error) { console.error("settings init failed", error); showMessage("Failed to load settings.", "error"); diff --git a/Overlord-Server/public/assets/users.js b/Overlord-Server/public/assets/users.js index e7fa99f..18fc36a 100644 --- a/Overlord-Server/public/assets/users.js +++ b/Overlord-Server/public/assets/users.js @@ -209,6 +209,26 @@ function renderUsers() { > + ${user.role === "operator" ? ` + + ` : ''} + + +

+ Manage feature access for ${escapeHtml(username)}. + Disabled features will return 403 when accessed. +

+
+ ${features.map(f => { + const meta = FEATURE_LABELS[f] || { label: f, icon: "fa-puzzle-piece" }; + const checked = perms[f] !== false; + return ` + `; + }).join("")} +
+
+ + +
+ `; + document.body.appendChild(modal); + + document.getElementById("close-feature-modal").addEventListener("click", () => modal.remove()); + modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); }); + + document.getElementById("save-feature-perms").addEventListener("click", async () => { + const toggles = modal.querySelectorAll("[data-feature]"); + const permissions = {}; + toggles.forEach(t => { permissions[t.dataset.feature] = t.checked; }); + + try { + const saveRes = await fetch(`/api/users/${userId}/feature-permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ permissions }), + }); + if (!saveRes.ok) throw new Error("Failed to save"); + modal.remove(); + if (window.showToast) window.showToast("Feature permissions updated", "success"); + } catch (err) { + alert("Failed to save feature permissions"); + } + }); + + document.getElementById("reset-feature-perms").addEventListener("click", async () => { + if (!confirm("Reset all feature permissions to defaults (all enabled)?")) return; + try { + const delRes = await fetch(`/api/users/${userId}/feature-permissions`, { method: "DELETE" }); + if (!delRes.ok) throw new Error("Failed to reset"); + modal.remove(); + if (window.showToast) window.showToast("Feature permissions reset", "success"); + } catch (err) { + alert("Failed to reset feature permissions"); + } + }); + } catch (err) { + console.error("Feature permissions error:", err); + alert("Failed to load feature permissions"); + } +}; + window.toggleBuildPermission = async function (userId, username, currentCanBuild) { const newVal = !currentCanBuild; if (!confirm(`${newVal ? 'Grant' : 'Revoke'} build permission for ${username}?`)) return; @@ -511,5 +639,124 @@ window.toggleUploadPermission = async function (userId, username, currentCanUplo } }; +async function viewUserSessions(userId, username) { + try { + const res = await fetch(`/api/users/${userId}/sessions`); + if (!res.ok) throw new Error("Failed to fetch sessions"); + const data = await res.json(); + const sessions = Array.isArray(data.sessions) ? data.sessions : []; + + const existing = document.getElementById("sessions-modal"); + if (existing) existing.remove(); + + const modal = document.createElement("div"); + modal.id = "sessions-modal"; + modal.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/60"; + + const now = Math.floor(Date.now() / 1000); + + const rows = sessions.length === 0 + ? `No sessions` + : sessions.map(s => { + const isExpired = s.expiresAt && s.expiresAt < now; + const status = s.revoked + ? 'Revoked' + : isExpired + ? 'Expired' + : 'Active'; + const canRevoke = !s.revoked && !isExpired; + const lastAct = s.lastActivity ? formatRelTime(s.lastActivity) : "—"; + + return ` + ${escapeHtml(s.ip || "—")} + ${lastAct} + ${status} + + ${canRevoke ? `` : ""} + + `; + }).join(""); + + modal.innerHTML = ` +
+
+

Sessions — ${escapeHtml(username)}

+
+ ${sessions.some(s => !s.revoked) ? `` : ""} + +
+
+
+ + + + + + + + + + ${rows} +
IPLast ActiveStatusAction
+
+
+ `; + + document.body.appendChild(modal); + + modal.addEventListener("click", (e) => { + if (e.target === modal || e.target.closest("#close-sessions-modal")) { + modal.remove(); + } + }); + + modal.querySelectorAll(".admin-revoke-session-btn").forEach(btn => { + btn.addEventListener("click", async () => { + btn.disabled = true; + try { + const r = await fetch(`/api/sessions/${encodeURIComponent(btn.dataset.sessionId)}`, { + method: "DELETE", + }); + if (!r.ok) throw new Error("Failed"); + modal.remove(); + viewUserSessions(userId, username); + } catch { + alert("Failed to revoke session"); + btn.disabled = false; + } + }); + }); + + const revokeAllBtn = document.getElementById("revoke-all-sessions-btn"); + if (revokeAllBtn) { + revokeAllBtn.addEventListener("click", async () => { + if (!confirm(`Revoke all sessions for ${username}? They will be logged out immediately.`)) return; + revokeAllBtn.disabled = true; + try { + const r = await fetch(`/api/users/${userId}/sessions`, { method: "DELETE" }); + if (!r.ok) throw new Error("Failed"); + modal.remove(); + viewUserSessions(userId, username); + } catch { + alert("Failed to revoke sessions"); + revokeAllBtn.disabled = false; + } + }); + } + } catch (err) { + console.error("View sessions error:", err); + alert("Failed to load sessions"); + } +} + +function formatRelTime(epochSeconds) { + if (!epochSeconds) return "—"; + const diff = Math.floor(Date.now() / 1000) - epochSeconds; + if (diff < 60) return "Just now"; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + getCurrentUser(); loadUsers(); diff --git a/Overlord-Server/public/assets/voice.js b/Overlord-Server/public/assets/voice.js index 7f520a4..0310202 100644 --- a/Overlord-Server/public/assets/voice.js +++ b/Overlord-Server/public/assets/voice.js @@ -1,4 +1,5 @@ import { fetchVoiceCapabilities } from "./data.js"; +import { checkFeatureAccess } from "./feature-gate.js"; const params = new URLSearchParams(window.location.search); const clientId = params.get("clientId") || ""; @@ -470,4 +471,10 @@ window.addEventListener("beforeunload", disconnectVoice); updateMuteButtons(); drawWave(localWaveCanvas, localWaveBuffer, localWaveWrite, "rgba(34, 211, 238, 0.95)"); drawWave(remoteWaveCanvas, remoteWaveBuffer, remoteWaveWrite, "rgba(16, 185, 129, 0.95)"); -loadCapabilities(); +checkFeatureAccess("voice", clientId).then(ok => { + if (ok) { + loadCapabilities(); + } else { + connectBtn.disabled = true; + } +}); diff --git a/Overlord-Server/public/assets/webcam.js b/Overlord-Server/public/assets/webcam.js index 68d1015..2998cb4 100644 --- a/Overlord-Server/public/assets/webcam.js +++ b/Overlord-Server/public/assets/webcam.js @@ -1,12 +1,16 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; +import { checkFeatureAccess } from "./feature-gate.js"; -(function () { +(async function () { const clientId = new URLSearchParams(location.search).get("clientId"); if (!clientId) { alert("Missing clientId"); return; } + const allowed = await checkFeatureAccess("webcam", clientId); + if (!allowed) return; + const clientLabel = document.getElementById("clientLabel"); const startBtn = document.getElementById("startBtn"); const stopBtn = document.getElementById("stopBtn"); diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index 4a6c20d..e551273 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -240,6 +240,50 @@ + +
+
+
+
+ +
+
+

Active Sessions

+

View and manage your active login sessions.

+
+
+ +
+ +
+ + + + + + + + + + + + + + + + +
IPBrowser / ClientCreatedLast ActiveStatusAction
Loading...
+
+ + +
+
diff --git a/Overlord-Server/src/auth.ts b/Overlord-Server/src/auth.ts index 53e7289..7ac5088 100644 --- a/Overlord-Server/src/auth.ts +++ b/Overlord-Server/src/auth.ts @@ -11,6 +11,11 @@ import { persistRevokedToken, isTokenRevoked, pruneExpiredRevokedTokens, + createSession, + hashTokenForSession, + revokeSessionByTokenHash, + updateSessionActivity, + pruneExpiredSessions, } from "./db"; const JWT_ISSUER = "overlord-server"; @@ -52,7 +57,10 @@ export interface AuthenticatedUser { role: UserRole; } -export async function generateToken(user: User): Promise { +export async function generateToken( + user: User, + sessionMeta?: { ip?: string; userAgent?: string }, +): Promise { const expiration = `${getSessionTtlHours()}h`; const token = await new SignJWT({ sub: user.username, @@ -66,6 +74,24 @@ export async function generateToken(user: User): Promise { .setExpirationTime(expiration) .sign(getSecretKey()); + const tokenHash = hashTokenForSession(token); + const now = Math.floor(Date.now() / 1000); + const expiresAt = now + getSessionTtlSeconds(); + + try { + createSession({ + id: crypto.randomUUID(), + userId: user.id, + tokenHash, + ip: sessionMeta?.ip || null, + userAgent: sessionMeta?.userAgent || null, + createdAt: now, + expiresAt, + }); + } catch (e) { + console.error("[auth] Failed to create session record:", e); + } + return token; } @@ -120,11 +146,16 @@ export function revokeToken(token: string): void { persistRevokedToken(token, expiresAt); tokenCache.delete(token); + + const tokenHash = hashTokenForSession(token); + revokeSessionByTokenHash(tokenHash); + console.log("[auth] Token revoked (persisted)"); } export async function cleanupBlacklist(): Promise { const pruned = pruneExpiredRevokedTokens(); + const prunedSessions = pruneExpiredSessions(); const cacheNow = Date.now(); let cacheCleared = 0; @@ -135,9 +166,16 @@ export async function cleanupBlacklist(): Promise { } } - if (pruned > 0 || cacheCleared > 0) { + const activityCutoff = cacheNow - SESSION_ACTIVITY_THROTTLE * 2; + for (const [token, ts] of lastActivityUpdate.entries()) { + if (ts < activityCutoff) { + lastActivityUpdate.delete(token); + } + } + + if (pruned > 0 || cacheCleared > 0 || prunedSessions > 0) { console.log( - `[auth] Cleaned up ${pruned} expired tokens from blacklist, ${cacheCleared} from cache`, + `[auth] Cleaned up ${pruned} expired tokens from blacklist, ${prunedSessions} expired sessions, ${cacheCleared} from cache`, ); } } @@ -178,6 +216,9 @@ export function extractTokenFromCookie( return null; } +const SESSION_ACTIVITY_THROTTLE = 60_000; // 60s +const lastActivityUpdate = new Map(); + export async function authenticateRequest( req: Request, ): Promise { @@ -203,6 +244,15 @@ export async function authenticateRequest( return null; } + const now = Date.now(); + const lastUpdate = lastActivityUpdate.get(token) || 0; + if (now - lastUpdate > SESSION_ACTIVITY_THROTTLE) { + lastActivityUpdate.set(token, now); + try { + updateSessionActivity(hashTokenForSession(token)); + } catch { } + } + return { username: payload.sub, userId: payload.userId, diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index 5c98aab..edf8970 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -134,6 +134,24 @@ db.run( `CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at);`, ); +db.run(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL, + ip TEXT, + user_agent TEXT, + created_at INTEGER NOT NULL, + last_activity INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); +`); +db.run(`CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);`); +db.run(`CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);`); +db.run(`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);`); + db.run(` CREATE TABLE IF NOT EXISTS builds ( id TEXT PRIMARY KEY, @@ -613,6 +631,14 @@ export function persistRevokedToken(token: string, expiresAt: number): void { ); } +export function persistRevokedTokenHash(tokenHash: string, expiresAt: number): void { + db.run( + `INSERT OR IGNORE INTO revoked_tokens (token_hash, expires_at) VALUES (?, ?)`, + tokenHash, + expiresAt, + ); +} + export function isTokenRevoked(token: string): boolean { const row = db.query<{ token_hash: string }>( `SELECT token_hash FROM revoked_tokens WHERE token_hash=?`, @@ -634,6 +660,110 @@ export function pruneExpiredRevokedTokens(): number { return result.changes; } +export type SessionRecord = { + id: string; + userId: number; + tokenHash: string; + ip: string | null; + userAgent: string | null; + createdAt: number; + lastActivity: number; + expiresAt: number; + revoked: boolean; +}; + +export function createSession(session: { + id: string; + userId: number; + tokenHash: string; + ip: string | null; + userAgent: string | null; + createdAt: number; + expiresAt: number; +}): void { + db.run( + `INSERT INTO sessions (id, user_id, token_hash, ip, user_agent, created_at, last_activity, expires_at, revoked) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`, + session.id, + session.userId, + session.tokenHash, + session.ip, + session.userAgent, + session.createdAt, + session.createdAt, + session.expiresAt, + ); +} + +export function getSessionByTokenHash(tokenHash: string): SessionRecord | null { + const row = db.query( + `SELECT * FROM sessions WHERE token_hash=? AND revoked=0`, + ).get(tokenHash); + if (!row) return null; + return mapSessionRow(row); +} + +export function getSessionById(id: string): SessionRecord | null { + const row = db.query(`SELECT * FROM sessions WHERE id=?`).get(id); + if (!row) return null; + return mapSessionRow(row); +} + +export function listUserSessions(userId: number): SessionRecord[] { + const rows = db.query( + `SELECT * FROM sessions WHERE user_id=? ORDER BY created_at DESC`, + ).all(userId); + return rows.map(mapSessionRow); +} + +export function updateSessionActivity(tokenHash: string): void { + const now = Math.floor(Date.now() / 1000); + db.run(`UPDATE sessions SET last_activity=? WHERE token_hash=? AND revoked=0`, now, tokenHash); +} + +export function revokeSessionByTokenHash(tokenHash: string): boolean { + const result = db.run(`UPDATE sessions SET revoked=1 WHERE token_hash=? AND revoked=0`, tokenHash); + return result.changes > 0; +} + +export function revokeSessionById(sessionId: string): { tokenHash: string | null } { + const row = db.query<{ token_hash: string }>( + `SELECT token_hash FROM sessions WHERE id=? AND revoked=0`, + ).get(sessionId); + if (!row) return { tokenHash: null }; + db.run(`UPDATE sessions SET revoked=1 WHERE id=?`, sessionId); + return { tokenHash: row.token_hash }; +} + +export function revokeAllUserSessions(userId: number): number { + const result = db.run(`UPDATE sessions SET revoked=1 WHERE user_id=? AND revoked=0`, userId); + return result.changes; +} + +export function pruneExpiredSessions(): number { + const now = Math.floor(Date.now() / 1000); + const result = db.run(`DELETE FROM sessions WHERE expires_at <= ?`, now); + return result.changes; +} + +export function hashTokenForSession(token: string): string { + return hashToken(token); +} + +function mapSessionRow(row: any): SessionRecord { + return { + id: row.id, + userId: row.user_id, + tokenHash: row.token_hash, + ip: row.ip, + userAgent: row.user_agent, + createdAt: row.created_at, + lastActivity: row.last_activity, + expiresAt: row.expires_at, + revoked: !!row.revoked, + }; +} + export function markAllClientsOffline() { db.run(`UPDATE clients SET online=0`); console.log("[db] marked all clients as offline"); diff --git a/Overlord-Server/src/server/routes/auth-routes.ts b/Overlord-Server/src/server/routes/auth-routes.ts index 1af07f4..624f8a1 100644 --- a/Overlord-Server/src/server/routes/auth-routes.ts +++ b/Overlord-Server/src/server/routes/auth-routes.ts @@ -8,13 +8,21 @@ import { revokeToken, } from "../../auth"; import { AuditAction, logAudit } from "../../auditLog"; +import { + listUserSessions, + revokeSessionById, + revokeAllUserSessions, + hashTokenForSession, + persistRevokedTokenHash, + getSessionById, +} from "../../db"; import { logger } from "../../logger"; import { isRateLimited, recordFailedAttempt, recordSuccessfulAttempt, } from "../../rateLimit"; -import { getUserById } from "../../users"; +import { getUserById, canUserAccessClient, canUserAccessFeature, type FeatureName, ALL_FEATURES } from "../../users"; import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie"; type RequestIpProvider = { @@ -63,7 +71,8 @@ export async function handleAuthRoutes( const user = await authenticateUser(username, password); if (user) { - const token = await generateToken(user); + const userAgent = req.headers.get("User-Agent") || null; + const token = await generateToken(user, { ip, userAgent: userAgent || undefined }); const sessionTtlSeconds = getSessionTtlSeconds(); logger.info( @@ -154,6 +163,151 @@ export async function handleAuthRoutes( }); } + if (req.method === "GET" && url.pathname === "/api/sessions") { + const user = await authenticateRequest(req); + if (!user) { + return Response.json({ error: "Not authenticated" }, { status: 401 }); + } + + const sessions = listUserSessions(user.userId); + const currentToken = extractTokenFromRequest(req); + const currentTokenHash = currentToken ? hashTokenForSession(currentToken) : null; + + return Response.json({ + sessions: sessions.map((s) => ({ + id: s.id, + ip: s.ip, + userAgent: s.userAgent, + createdAt: s.createdAt, + lastActivity: s.lastActivity, + expiresAt: s.expiresAt, + revoked: s.revoked, + current: s.tokenHash === currentTokenHash, + })), + }); + } + + if (req.method === "GET" && url.pathname.match(/^\/api\/users\/\d+\/sessions$/)) { + const user = await authenticateRequest(req); + if (!user) { + return Response.json({ error: "Not authenticated" }, { status: 401 }); + } + if (user.role !== "admin") { + return Response.json({ error: "Admin only" }, { status: 403 }); + } + + const targetUserId = parseInt(url.pathname.split("/")[3]); + const sessions = listUserSessions(targetUserId); + + return Response.json({ + sessions: sessions.map((s) => ({ + id: s.id, + ip: s.ip, + userAgent: s.userAgent, + createdAt: s.createdAt, + lastActivity: s.lastActivity, + expiresAt: s.expiresAt, + revoked: s.revoked, + })), + }); + } + + if (req.method === "DELETE" && url.pathname.match(/^\/api\/sessions\/[^/]+$/)) { + const user = await authenticateRequest(req); + if (!user) { + return Response.json({ error: "Not authenticated" }, { status: 401 }); + } + + const sessionId = url.pathname.split("/")[3]; + const session = getSessionById(sessionId); + + if (!session) { + return Response.json({ error: "Session not found" }, { status: 404 }); + } + + if (session.userId !== user.userId && user.role !== "admin") { + return Response.json({ error: "Forbidden" }, { status: 403 }); + } + + const result = revokeSessionById(sessionId); + if (result.tokenHash) { + persistRevokedTokenHash(result.tokenHash, session.expiresAt); + } + + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ + timestamp: Date.now(), + username: user.username, + ip, + action: AuditAction.LOGOUT, + details: `Revoked session ${sessionId}${session.userId !== user.userId ? ` (user ${session.userId})` : ""}`, + success: true, + }); + + return Response.json({ ok: true }); + } + + if (req.method === "DELETE" && url.pathname.match(/^\/api\/users\/\d+\/sessions$/)) { + const user = await authenticateRequest(req); + if (!user) { + return Response.json({ error: "Not authenticated" }, { status: 401 }); + } + if (user.role !== "admin") { + return Response.json({ error: "Admin only" }, { status: 403 }); + } + + const targetUserId = parseInt(url.pathname.split("/")[3]); + + const sessions = listUserSessions(targetUserId).filter((s) => !s.revoked); + for (const s of sessions) { + persistRevokedTokenHash(s.tokenHash, s.expiresAt); + } + + const count = revokeAllUserSessions(targetUserId); + + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ + timestamp: Date.now(), + username: user.username, + ip, + action: AuditAction.LOGOUT, + details: `Revoked all ${count} sessions for user ${targetUserId}`, + success: true, + }); + + return Response.json({ ok: true, revokedCount: count }); + } + + if (req.method === "GET" && url.pathname === "/api/auth/feature-check") { + const user = await authenticateRequest(req); + if (!user) { + return Response.json({ error: "Not authenticated" }, { status: 401 }); + } + + const feature = url.searchParams.get("feature") as FeatureName | null; + const clientId = url.searchParams.get("clientId"); + + if (!feature || !ALL_FEATURES.includes(feature as FeatureName)) { + return Response.json({ error: "Invalid feature" }, { status: 400 }); + } + + const reasons: string[] = []; + + if (!canUserAccessFeature(user.userId, user.role, feature)) { + reasons.push("feature"); + } + + if (clientId && !canUserAccessClient(user.userId, user.role, clientId)) { + reasons.push("client"); + } + + if (reasons.length > 0) { + return Response.json({ allowed: false, denied: reasons }); + } + + return Response.json({ allowed: true }); + } + if (req.method === "GET" && url.pathname === "/api/auth/me") { const user = await authenticateRequest(req); if (!user) { diff --git a/Overlord-Server/src/server/routes/file-download-routes.ts b/Overlord-Server/src/server/routes/file-download-routes.ts index 25ca67f..1000c70 100644 --- a/Overlord-Server/src/server/routes/file-download-routes.ts +++ b/Overlord-Server/src/server/routes/file-download-routes.ts @@ -8,6 +8,7 @@ import { logger } from "../../logger"; import { encodeMessage } from "../../protocol"; import { getConfig } from "../../config"; import { isAuthorizedAgentRequest } from "../agent-auth"; +import { canUserAccessClient } from "../../users"; type RequestIpProvider = { requestIP: (req: Request) => { address?: string } | null | undefined; @@ -358,6 +359,10 @@ export async function handleFileDownloadRoutes( return new Response("Bad request", { status: 400 }); } + if (!canUserAccessClient(user.userId, user.role as any, clientId)) { + return new Response("Forbidden: client access denied", { status: 403 }); + } + logger.debug("[filebrowser] http upload request", { userId: user.userId, clientId, @@ -598,6 +603,10 @@ export async function handleFileDownloadRoutes( return new Response("Bad request", { status: 400 }); } + if (!canUserAccessClient(user.userId, user.role as any, clientId)) { + return new Response("Forbidden: client access denied", { status: 403 }); + } + const target = clientManager.getClient(clientId); if (!target) { return new Response("Client offline", { status: 404 }); diff --git a/Overlord-Server/src/server/routes/users-routes.ts b/Overlord-Server/src/server/routes/users-routes.ts index c6db1f1..06a8ff1 100644 --- a/Overlord-Server/src/server/routes/users-routes.ts +++ b/Overlord-Server/src/server/routes/users-routes.ts @@ -5,10 +5,13 @@ import { requirePermission } from "../../rbac"; import { type ClientAccessRuleKind, type ClientAccessScope, + type FeatureName, + ALL_FEATURES, createUser, deleteUser, getUserById, getUserClientAccessScope, + getUserFeaturePermissions, listUsers, listUserClientAccessRules, removeUserClientAccessRule, @@ -16,6 +19,8 @@ import { setUserClientAccessScope, setUserCanBuild, setUserCanUploadFiles, + setUserFeaturePermissions, + resetUserFeaturePermissions, updateUserPassword, updateUserRole, } from "../../users"; @@ -123,7 +128,8 @@ export async function handleUsersRoutes( }); if (user.userId === userId && targetUser) { - const newToken = await generateToken(targetUser); + const userAgent = req.headers.get("User-Agent") || undefined; + const newToken = await generateToken(targetUser, { ip, userAgent }); const sessionTtlSeconds = getSessionTtlSeconds(); return new Response( JSON.stringify({ @@ -382,6 +388,83 @@ export async function handleUsersRoutes( return Response.json({ success: true, canUploadFiles }); } + if (req.method === "GET" && url.pathname.match(/^\/api\/users\/\d+\/feature-permissions$/)) { + requirePermission(user, "users:manage"); + const userId = parseInt(url.pathname.split("/")[3]); + const targetUser = getUserById(userId); + if (!targetUser) { + return Response.json({ error: "User not found" }, { status: 404 }); + } + + const permissions = getUserFeaturePermissions(userId); + return Response.json({ success: true, permissions, features: ALL_FEATURES }); + } + + if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/feature-permissions$/)) { + const authedUser = requirePermission(user, "users:manage"); + const userId = parseInt(url.pathname.split("/")[3]); + const targetUser = getUserById(userId); + if (!targetUser) { + return Response.json({ error: "User not found" }, { status: 404 }); + } + + const body = await req.json(); + const permissions = body?.permissions; + if (!permissions || typeof permissions !== "object") { + return Response.json({ error: "Invalid permissions object" }, { status: 400 }); + } + + const validated: Partial> = {}; + for (const [key, value] of Object.entries(permissions)) { + if (ALL_FEATURES.includes(key as FeatureName)) { + validated[key as FeatureName] = !!value; + } + } + + const result = setUserFeaturePermissions(userId, validated); + if (!result.success) { + return Response.json({ error: result.error }, { status: 400 }); + } + + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ + timestamp: Date.now(), + username: authedUser.username, + ip, + action: AuditAction.COMMAND, + details: `Updated feature permissions for ${targetUser.username}: ${JSON.stringify(validated)}`, + success: true, + }); + + return Response.json({ success: true, permissions: getUserFeaturePermissions(userId) }); + } + + if (req.method === "DELETE" && url.pathname.match(/^\/api\/users\/\d+\/feature-permissions$/)) { + const authedUser = requirePermission(user, "users:manage"); + const userId = parseInt(url.pathname.split("/")[3]); + const targetUser = getUserById(userId); + if (!targetUser) { + return Response.json({ error: "User not found" }, { status: 404 }); + } + + const result = resetUserFeaturePermissions(userId); + if (!result.success) { + return Response.json({ error: result.error }, { status: 400 }); + } + + const ip = server.requestIP(req)?.address || "unknown"; + logAudit({ + timestamp: Date.now(), + username: authedUser.username, + ip, + action: AuditAction.COMMAND, + details: `Reset feature permissions for ${targetUser.username} to defaults`, + success: true, + }); + + return Response.json({ success: true }); + } + return new Response("Not found", { status: 404 }); } catch (error) { if (error instanceof Response) { diff --git a/Overlord-Server/src/server/routes/ws-upgrade-routes.ts b/Overlord-Server/src/server/routes/ws-upgrade-routes.ts index bdb3b14..e748c44 100644 --- a/Overlord-Server/src/server/routes/ws-upgrade-routes.ts +++ b/Overlord-Server/src/server/routes/ws-upgrade-routes.ts @@ -1,6 +1,8 @@ import { authenticateRequest } from "../../auth"; import { logger } from "../../logger"; import { isIpBanned } from "../../db"; +import { canUserAccessClient, canUserAccessFeature } from "../../users"; +import type { FeatureName } from "../../users"; type RequestServer = { requestIP: (req: Request) => { address?: string } | null | undefined; @@ -40,6 +42,20 @@ function isWsRateLimited(ip: string): boolean { return false; } +function checkOperatorAccess( + user: { userId: number; role: string }, + clientId: string, + feature: FeatureName, +): Response | null { + if (!canUserAccessClient(user.userId, user.role as any, clientId)) { + return new Response("Forbidden: client access denied", { status: 403 }); + } + if (!canUserAccessFeature(user.userId, user.role as any, feature)) { + return new Response("Forbidden: feature access denied", { status: 403 }); + } + return null; +} + export async function handleWsUpgradeRoutes( req: Request, url: URL, @@ -62,9 +78,11 @@ export async function handleWsUpgradeRoutes( return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); } const clientId = consoleWsMatch[1]; + const denied = checkOperatorAccess(user, clientId, "console"); + if (denied) return denied; const sessionId = crypto.randomUUID(); const ip = server.requestIP(req)?.address || ""; - if (server.upgrade(req, { data: { role: "console_viewer", clientId, sessionId, ip, userRole: user.role } })) { + if (server.upgrade(req, { data: { role: "console_viewer", clientId, sessionId, ip, userRole: user.role, userId: user.userId } })) { return new Response(); } return new Response("Upgrade failed", { status: 500 }); @@ -77,7 +95,7 @@ export async function handleWsUpgradeRoutes( return new Response("Unauthorized", { status: 401 }); } const clientId = wsMatch[1]; - const role = (url.searchParams.get("role") as any) || "viewer"; + const role = "client"; const ip = server.requestIP(req)?.address || ""; if (ip && isIpBanned(ip)) { logger.warn(`[auth] Rejected banned IP ${ip} for client ${clientId}`); @@ -100,8 +118,10 @@ export async function handleWsUpgradeRoutes( return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); } const clientId = rdMatch[1]; + const denied = checkOperatorAccess(user, clientId, "remote_desktop"); + if (denied) return denied; const ip = server.requestIP(req)?.address || ""; - if (server.upgrade(req, { data: { role: "rd_viewer", clientId, ip, userRole: user.role } })) { + if (server.upgrade(req, { data: { role: "rd_viewer", clientId, ip, userRole: user.role, userId: user.userId } })) { return new Response(); } return new Response("Upgrade failed", { status: 500 }); @@ -118,8 +138,10 @@ export async function handleWsUpgradeRoutes( return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); } const clientId = hvncMatch[1]; + const denied = checkOperatorAccess(user, clientId, "hvnc"); + if (denied) return denied; const ip = server.requestIP(req)?.address || ""; - if (server.upgrade(req, { data: { role: "hvnc_viewer", clientId, ip, userRole: user.role } })) { + if (server.upgrade(req, { data: { role: "hvnc_viewer", clientId, ip, userRole: user.role, userId: user.userId } })) { return new Response(); } return new Response("Upgrade failed", { status: 500 }); @@ -136,8 +158,10 @@ export async function handleWsUpgradeRoutes( return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); } const clientId = webcamMatch[1]; + const denied = checkOperatorAccess(user, clientId, "webcam"); + if (denied) return denied; const ip = server.requestIP(req)?.address || ""; - if (server.upgrade(req, { data: { role: "webcam_viewer", clientId, ip, userRole: user.role } })) { + if (server.upgrade(req, { data: { role: "webcam_viewer", clientId, ip, userRole: user.role, userId: user.userId } })) { return new Response(); } return new Response("Upgrade failed", { status: 500 }); @@ -154,8 +178,10 @@ export async function handleWsUpgradeRoutes( return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); } const clientId = fbMatch[1]; + const denied = checkOperatorAccess(user, clientId, "file_browser"); + if (denied) return denied; const ip = server.requestIP(req)?.address || ""; - if (server.upgrade(req, { data: { role: "file_browser_viewer", clientId, ip, userRole: user.role } })) { + if (server.upgrade(req, { data: { role: "file_browser_viewer", clientId, ip, userRole: user.role, userId: user.userId } })) { return new Response(); } return new Response("Upgrade failed", { status: 500 }); @@ -172,8 +198,10 @@ export async function handleWsUpgradeRoutes( return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); } const clientId = processMatch[1]; + const denied = checkOperatorAccess(user, clientId, "processes"); + if (denied) return denied; const ip = server.requestIP(req)?.address || ""; - if (server.upgrade(req, { data: { role: "process_viewer", clientId, ip, userRole: user.role } })) { + if (server.upgrade(req, { data: { role: "process_viewer", clientId, ip, userRole: user.role, userId: user.userId } })) { return new Response(); } return new Response("Upgrade failed", { status: 500 }); @@ -190,8 +218,10 @@ export async function handleWsUpgradeRoutes( return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); } const clientId = keyloggerMatch[1]; + const denied = checkOperatorAccess(user, clientId, "keylogger"); + if (denied) return denied; const ip = server.requestIP(req)?.address || ""; - if (server.upgrade(req, { data: { role: "keylogger_viewer", clientId, ip, userRole: user.role } })) { + if (server.upgrade(req, { data: { role: "keylogger_viewer", clientId, ip, userRole: user.role, userId: user.userId } })) { return new Response(); } return new Response("Upgrade failed", { status: 500 }); @@ -208,8 +238,10 @@ export async function handleWsUpgradeRoutes( return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 }); } const clientId = voiceMatch[1]; + const denied = checkOperatorAccess(user, clientId, "voice"); + if (denied) return denied; const ip = server.requestIP(req)?.address || ""; - if (server.upgrade(req, { data: { role: "voice_viewer", clientId, ip, userRole: user.role } })) { + if (server.upgrade(req, { data: { role: "voice_viewer", clientId, ip, userRole: user.role, userId: user.userId } })) { return new Response(); } return new Response("Upgrade failed", { status: 500 }); diff --git a/Overlord-Server/src/server/ws-console-rd-hvnc.ts b/Overlord-Server/src/server/ws-console-rd-hvnc.ts index e4813e5..bb948e0 100644 --- a/Overlord-Server/src/server/ws-console-rd-hvnc.ts +++ b/Overlord-Server/src/server/ws-console-rd-hvnc.ts @@ -11,6 +11,7 @@ import { resolveRuntimeRoot } from "./runtime-paths"; import * as sessionManager from "../sessions/sessionManager"; import type { ConsoleSession, RemoteDesktopViewer, SocketData } from "../sessions/types"; import type { ClientInfo } from "../types"; +import { canUserAccessClient } from "../users"; let _cachedInjectionDll: Uint8Array | null = null; let _dllCachePath: string | null = null; @@ -248,7 +249,11 @@ export function notifyConsoleClosed(clientId: string, reason: string) { } export function handleConsoleViewerOpen(ws: ServerWebSocket) { - const { clientId, sessionId } = ws.data; + const { clientId, sessionId, userId, userRole } = ws.data; + if (userId !== undefined && userRole && !canUserAccessClient(userId, userRole as any, clientId)) { + ws.close(1008, "Forbidden: client access denied"); + return; + } const effectiveSessionId = sessionId || uuidv4(); ws.data.sessionId = effectiveSessionId; const target = clientManager.getClient(clientId); @@ -272,7 +277,11 @@ export function handleConsoleViewerOpen(ws: ServerWebSocket) { } export function handleRemoteDesktopViewerOpen(ws: ServerWebSocket) { - const { clientId } = ws.data; + const { clientId, userId, userRole } = ws.data; + if (userId !== undefined && userRole && !canUserAccessClient(userId, userRole as any, clientId)) { + ws.close(1008, "Forbidden: client access denied"); + return; + } const sessionId = uuidv4(); ws.data.sessionId = sessionId; const target = clientManager.getClient(clientId); @@ -506,7 +515,11 @@ export const hvncStreamingState = new Map(); export function handleWebcamViewerOpen(ws: ServerWebSocket) { - const { clientId } = ws.data; + const { clientId, userId, userRole } = ws.data; + if (userId !== undefined && userRole && !canUserAccessClient(userId, userRole as any, clientId)) { + ws.close(1008, "Forbidden: client access denied"); + return; + } const sessionId = uuidv4(); ws.data.sessionId = sessionId; const target = clientManager.getClient(clientId); @@ -641,7 +654,11 @@ export function handleWebcamViewerMessage(ws: ServerWebSocket, raw: } export function handleHVNCViewerOpen(ws: ServerWebSocket) { - const { clientId } = ws.data; + const { clientId, userId, userRole } = ws.data; + if (userId !== undefined && userRole && !canUserAccessClient(userId, userRole as any, clientId)) { + ws.close(1008, "Forbidden: client access denied"); + return; + } const sessionId = uuidv4(); ws.data.sessionId = sessionId; const target = clientManager.getClient(clientId); diff --git a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts index 38e0637..5121ab2 100644 --- a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts +++ b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts @@ -9,6 +9,7 @@ import { encodeMessage } from "../protocol"; import * as sessionManager from "../sessions/sessionManager"; import type { SocketData } from "../sessions/types"; import { normalizeFileUploadPayload } from "../fileTransfers"; +import { canUserAccessClient } from "../users"; type FileBrowserViewer = { id: string; @@ -61,7 +62,11 @@ function safeSendViewer(ws: ServerWebSocket, payload: unknown) { } export function handleFileBrowserViewerOpen(ws: ServerWebSocket) { - const { clientId } = ws.data; + const { clientId, userId, userRole } = ws.data; + if (userId !== undefined && userRole && !canUserAccessClient(userId, userRole as any, clientId)) { + ws.close(1008, "Forbidden: client access denied"); + return; + } const sessionId = uuidv4(); const target = clientManager.getClient(clientId); const session: FileBrowserViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() }; @@ -280,7 +285,11 @@ export function handleFileBrowserMessage(clientId: string, payload: any, deps: W } export function handleProcessViewerOpen(ws: ServerWebSocket) { - const { clientId } = ws.data; + const { clientId, userId, userRole } = ws.data; + if (userId !== undefined && userRole && !canUserAccessClient(userId, userRole as any, clientId)) { + ws.close(1008, "Forbidden: client access denied"); + return; + } const sessionId = uuidv4(); const target = clientManager.getClient(clientId); const session: ProcessViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() }; @@ -330,7 +339,11 @@ export function handleProcessMessage(clientId: string, payload: any) { } export function handleKeyloggerViewerOpen(ws: ServerWebSocket) { - const { clientId } = ws.data; + const { clientId, userId, userRole } = ws.data; + if (userId !== undefined && userRole && !canUserAccessClient(userId, userRole as any, clientId)) { + ws.close(1008, "Forbidden: client access denied"); + return; + } const sessionId = uuidv4(); const target = clientManager.getClient(clientId); const session = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() }; diff --git a/Overlord-Server/src/server/ws-voice.ts b/Overlord-Server/src/server/ws-voice.ts index 052e237..048bc20 100644 --- a/Overlord-Server/src/server/ws-voice.ts +++ b/Overlord-Server/src/server/ws-voice.ts @@ -4,6 +4,7 @@ import * as clientManager from "../clientManager"; import { encodeMessage } from "../protocol"; import * as sessionManager from "../sessions/sessionManager"; import type { SocketData, VoiceViewer } from "../sessions/types"; +import { canUserAccessClient } from "../users"; function sendVoiceCommand(clientId: string, commandType: "voice_session_start" | "voice_session_stop" | "voice_downlink", payload: Record) { const target = clientManager.getClient(clientId); @@ -32,7 +33,11 @@ function safeJson(ws: ServerWebSocket, payload: Record) { - const { clientId } = ws.data; + const { clientId, userId, userRole } = ws.data; + if (userId !== undefined && userRole && !canUserAccessClient(userId, userRole as any, clientId)) { + ws.close(1008, "Forbidden: client access denied"); + return; + } const sessionId = uuidv4(); ws.data.sessionId = sessionId; const target = clientManager.getClient(clientId); diff --git a/Overlord-Server/src/users.ts b/Overlord-Server/src/users.ts index 03522b5..ce04037 100644 --- a/Overlord-Server/src/users.ts +++ b/Overlord-Server/src/users.ts @@ -84,6 +84,16 @@ db.exec( `CREATE INDEX IF NOT EXISTS idx_user_client_access_rules_user ON user_client_access_rules(user_id)`, ); +db.exec(` + CREATE TABLE IF NOT EXISTS user_feature_permissions ( + user_id INTEGER NOT NULL, + feature TEXT NOT NULL, + allowed INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (user_id, feature), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) +`); + try { db.exec( `ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0`, @@ -397,6 +407,136 @@ export function canUserAccessClient( return false; } +export type FeatureName = + | "console" + | "remote_desktop" + | "hvnc" + | "webcam" + | "file_browser" + | "processes" + | "keylogger" + | "voice"; + +export const ALL_FEATURES: FeatureName[] = [ + "console", + "remote_desktop", + "hvnc", + "webcam", + "file_browser", + "processes", + "keylogger", + "voice", +]; + +const featurePermCache = new Map>(); + +function getFeaturePermCacheEntry(userId: number): Map { + const cached = featurePermCache.get(userId); + if (cached) return cached; + + const rows = db + .prepare("SELECT feature, allowed FROM user_feature_permissions WHERE user_id = ?") + .all(userId) as Array<{ feature: string; allowed: number }>; + + const map = new Map(); + for (const row of rows) { + map.set(row.feature, row.allowed === 1); + } + featurePermCache.set(userId, map); + return map; +} + +function invalidateFeaturePermCache(userId: number): void { + featurePermCache.delete(userId); +} + +export function canUserAccessFeature( + userId: number, + role: UserRole, + feature: FeatureName, +): boolean { + if (role === "admin") return true; + if (role === "viewer") return false; + + const perms = getFeaturePermCacheEntry(userId); + const entry = perms.get(feature); + if (entry === undefined) return true; + return entry; +} + +export function getUserFeaturePermissions( + userId: number, +): Record { + const user = getUserById(userId); + if (!user) { + const result = {} as Record; + for (const f of ALL_FEATURES) result[f] = false; + return result; + } + + const result = {} as Record; + for (const f of ALL_FEATURES) { + result[f] = canUserAccessFeature(userId, user.role, f); + } + return result; +} + +export function setUserFeaturePermission( + userId: number, + feature: FeatureName, + allowed: boolean, +): { success: boolean; error?: string } { + if (!ALL_FEATURES.includes(feature)) { + return { success: false, error: `Invalid feature: ${feature}` }; + } + try { + db.prepare( + "INSERT OR REPLACE INTO user_feature_permissions (user_id, feature, allowed) VALUES (?, ?, ?)", + ).run(userId, feature, allowed ? 1 : 0); + invalidateFeaturePermCache(userId); + return { success: true }; + } catch (err: any) { + logger.error("[users] setUserFeaturePermission error:", err); + return { success: false, error: err.message || "Failed to update feature permission" }; + } +} + +export function setUserFeaturePermissions( + userId: number, + permissions: Partial>, +): { success: boolean; error?: string } { + const stmt = db.prepare( + "INSERT OR REPLACE INTO user_feature_permissions (user_id, feature, allowed) VALUES (?, ?, ?)", + ); + try { + const tx = db.transaction(() => { + for (const [feature, allowed] of Object.entries(permissions)) { + if (!ALL_FEATURES.includes(feature as FeatureName)) continue; + stmt.run(userId, feature, allowed ? 1 : 0); + } + }); + tx(); + invalidateFeaturePermCache(userId); + return { success: true }; + } catch (err: any) { + logger.error("[users] setUserFeaturePermissions error:", err); + return { success: false, error: err.message || "Failed to update feature permissions" }; + } +} + +export function resetUserFeaturePermissions( + userId: number, +): { success: boolean; error?: string } { + try { + db.prepare("DELETE FROM user_feature_permissions WHERE user_id = ?").run(userId); + invalidateFeaturePermCache(userId); + return { success: true }; + } catch (err: any) { + logger.error("[users] resetUserFeaturePermissions error:", err); + return { success: false, error: err.message || "Failed to reset feature permissions" }; + } +} + function validatePasswordPolicy(password: string): string | null { const security = getConfig().security; const minLength = Math.min(128, Math.max(6, Number(security.passwordMinLength) || 6));