huge update to RBAC
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { ansiToHtml } from "./ansi.js";
|
import { ansiToHtml } from "./ansi.js";
|
||||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||||
|
import { checkFeatureAccess } from "./feature-gate.js";
|
||||||
const outputEl = document.getElementById("console-output");
|
const outputEl = document.getElementById("console-output");
|
||||||
const statusPill = document.getElementById("status-pill");
|
const statusPill = document.getElementById("status-pill");
|
||||||
const clientLabel = document.getElementById("client-label");
|
const clientLabel = document.getElementById("client-label");
|
||||||
@@ -152,4 +153,4 @@ function wireInput() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
wireInput();
|
wireInput();
|
||||||
connect();
|
checkFeatureAccess("console", clientId).then(ok => ok && connect());
|
||||||
|
|||||||
@@ -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<boolean>}
|
||||||
|
*/
|
||||||
|
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 = `
|
||||||
|
<div style="font-size:3rem;margin-bottom:1rem;">
|
||||||
|
<i class="fa-solid fa-lock" style="color:#ef4444;"></i>
|
||||||
|
</div>
|
||||||
|
<h2 style="font-size:1.25rem;font-weight:700;color:#f1f5f9;margin-bottom:0.5rem;">Access Denied</h2>
|
||||||
|
<p style="color:#94a3b8;font-size:0.875rem;line-height:1.5;margin-bottom:1.5rem;">
|
||||||
|
${escapeHtml(messages)}
|
||||||
|
</p>
|
||||||
|
<p style="color:#64748b;font-size:0.75rem;margin-bottom:1.25rem;">
|
||||||
|
Contact your administrator to request access.
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||||
|
import { checkFeatureAccess } from "./feature-gate.js";
|
||||||
|
|
||||||
const clientId = window.location.pathname.split("/")[1];
|
const clientId = window.location.pathname.split("/")[1];
|
||||||
let ws = null;
|
let ws = null;
|
||||||
@@ -2234,7 +2235,7 @@ function highlightSidebarActive() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
connect();
|
checkFeatureAccess("file_browser", clientId).then(ok => ok && connect());
|
||||||
|
|
||||||
function addTransferToUI(transfer) {
|
function addTransferToUI(transfer) {
|
||||||
const transferItem = document.createElement("div");
|
const transferItem = document.createElement("div");
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||||
|
import { checkFeatureAccess } from "./feature-gate.js";
|
||||||
|
|
||||||
(function () {
|
(async function () {
|
||||||
const clientId = new URLSearchParams(location.search).get("clientId");
|
const clientId = new URLSearchParams(location.search).get("clientId");
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
alert("Missing clientId");
|
alert("Missing clientId");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowed = await checkFeatureAccess("hvnc", clientId);
|
||||||
|
if (!allowed) return;
|
||||||
|
|
||||||
const clientLabel = document.getElementById("clientLabel");
|
const clientLabel = document.getElementById("clientLabel");
|
||||||
clientLabel.textContent = clientId;
|
clientLabel.textContent = clientId;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||||
|
import { checkFeatureAccess } from "./feature-gate.js";
|
||||||
|
|
||||||
const clientId = window.location.pathname.split("/")[1];
|
const clientId = window.location.pathname.split("/")[1];
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
@@ -712,4 +713,4 @@ fileList.addEventListener("click", (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import("/assets/nav.js");
|
import("/assets/nav.js");
|
||||||
connect();
|
checkFeatureAccess("keylogger", clientId).then(ok => ok && connect());
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||||
|
import { checkFeatureAccess } from "./feature-gate.js";
|
||||||
|
|
||||||
const clientId = window.location.pathname.split("/")[1];
|
const clientId = window.location.pathname.split("/")[1];
|
||||||
let ws = null;
|
let ws = null;
|
||||||
@@ -426,5 +427,5 @@ setInterval(() => {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
updateStatus("connecting", "Connecting...");
|
updateStatus("connecting", "Connecting...");
|
||||||
connect();
|
checkFeatureAccess("processes", clientId).then(ok => ok && connect());
|
||||||
updateSortIndicators();
|
updateSortIndicators();
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||||
|
import { checkFeatureAccess } from "./feature-gate.js";
|
||||||
|
|
||||||
(function () {
|
(async function () {
|
||||||
const clientId = new URLSearchParams(location.search).get("clientId");
|
const clientId = new URLSearchParams(location.search).get("clientId");
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
alert("Missing clientId");
|
alert("Missing clientId");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowed = await checkFeatureAccess("remote_desktop", clientId);
|
||||||
|
if (!allowed) return;
|
||||||
|
|
||||||
const clientLabel = document.getElementById("clientLabel");
|
const clientLabel = document.getElementById("clientLabel");
|
||||||
clientLabel.textContent = clientId;
|
clientLabel.textContent = clientId;
|
||||||
|
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-3 py-6 text-center text-rose-300">Failed to load sessions</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const sessions = Array.isArray(data.sessions) ? data.sessions : [];
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
sessionsTableBody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-3 py-6 text-center text-slate-400">No sessions found</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionsTableBody.innerHTML = sessions
|
||||||
|
.map((s) => {
|
||||||
|
const isExpired = s.expiresAt && s.expiresAt < Math.floor(Date.now() / 1000);
|
||||||
|
const statusLabel = s.revoked
|
||||||
|
? '<span class="text-rose-400">Revoked</span>'
|
||||||
|
: isExpired
|
||||||
|
? '<span class="text-slate-500">Expired</span>'
|
||||||
|
: '<span class="text-emerald-400">Active</span>';
|
||||||
|
const currentBadge = s.current
|
||||||
|
? ' <span class="ml-1 px-1.5 py-0.5 text-[10px] rounded bg-sky-600/30 text-sky-300 border border-sky-500/30">Current</span>'
|
||||||
|
: "";
|
||||||
|
const canRevoke = !s.revoked && !isExpired;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2 font-mono text-xs text-slate-100">${escapeHtml(s.ip || "—")}</td>
|
||||||
|
<td class="px-3 py-2 text-slate-300 text-xs max-w-[200px] truncate" title="${escapeHtml(s.userAgent || "")}">${escapeHtml(parseUserAgent(s.userAgent))}</td>
|
||||||
|
<td class="px-3 py-2 text-slate-400 text-xs">${formatDate(s.createdAt)}</td>
|
||||||
|
<td class="px-3 py-2 text-slate-400 text-xs">${formatRelativeTime(s.lastActivity)}</td>
|
||||||
|
<td class="px-3 py-2 text-xs">${statusLabel}${currentBadge}</td>
|
||||||
|
<td class="px-3 py-2 text-right">
|
||||||
|
${canRevoke ? `
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="revoke-session-btn px-2.5 py-1.5 rounded bg-red-700/80 hover:bg-red-600 text-white text-xs"
|
||||||
|
data-session-id="${escapeHtml(s.id)}"
|
||||||
|
${s.current ? 'data-is-current="true"' : ""}
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-right-from-bracket mr-1"></i>Revoke
|
||||||
|
</button>
|
||||||
|
` : ""}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
} catch (err) {
|
||||||
|
sessionsTableBody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-3 py-6 text-center text-rose-300">Error loading sessions</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
await loadCurrentUser();
|
await loadCurrentUser();
|
||||||
@@ -872,6 +1017,10 @@ async function init() {
|
|||||||
refreshBansBtn.addEventListener("click", loadBannedIps);
|
refreshBansBtn.addEventListener("click", loadBannedIps);
|
||||||
bansTableBody.addEventListener("click", handleUnbanClick);
|
bansTableBody.addEventListener("click", handleUnbanClick);
|
||||||
if (wipeOfflineBtn) wipeOfflineBtn.addEventListener("click", wipeOfflineClients);
|
if (wipeOfflineBtn) wipeOfflineBtn.addEventListener("click", wipeOfflineClients);
|
||||||
|
|
||||||
|
await loadSessions();
|
||||||
|
if (refreshSessionsBtn) refreshSessionsBtn.addEventListener("click", loadSessions);
|
||||||
|
if (sessionsTableBody) sessionsTableBody.addEventListener("click", handleRevokeSessionClick);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("settings init failed", error);
|
console.error("settings init failed", error);
|
||||||
showMessage("Failed to load settings.", "error");
|
showMessage("Failed to load settings.", "error");
|
||||||
|
|||||||
@@ -209,6 +209,26 @@ function renderUsers() {
|
|||||||
>
|
>
|
||||||
<i class="fa-solid fa-user-shield"></i>
|
<i class="fa-solid fa-user-shield"></i>
|
||||||
</button>
|
</button>
|
||||||
|
${user.role === "operator" ? `
|
||||||
|
<button
|
||||||
|
class="user-action-btn px-3 py-1.5 text-sm bg-amber-900/30 hover:bg-amber-900/50 text-amber-300 rounded border border-amber-800 transition-colors"
|
||||||
|
data-action="feature-permissions"
|
||||||
|
data-user-id="${user.id}"
|
||||||
|
data-username="${escapeHtml(user.username)}"
|
||||||
|
title="Feature Permissions"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-sliders"></i>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
<button
|
||||||
|
class="user-action-btn px-3 py-1.5 text-sm bg-cyan-900/30 hover:bg-cyan-900/50 text-cyan-300 rounded border border-cyan-800 transition-colors"
|
||||||
|
data-action="view-sessions"
|
||||||
|
data-user-id="${user.id}"
|
||||||
|
data-username="${escapeHtml(user.username)}"
|
||||||
|
title="View Sessions"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-desktop"></i>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="user-action-btn px-3 py-1.5 text-sm bg-red-900/20 hover:bg-red-900/40 text-red-400 rounded border border-red-800 transition-colors"
|
class="user-action-btn px-3 py-1.5 text-sm bg-red-900/20 hover:bg-red-900/40 text-red-400 rounded border border-red-800 transition-colors"
|
||||||
data-action="delete"
|
data-action="delete"
|
||||||
@@ -289,6 +309,12 @@ function attachActionListeners() {
|
|||||||
case "client-access":
|
case "client-access":
|
||||||
configureClientAccess(userId, username, role);
|
configureClientAccess(userId, username, role);
|
||||||
break;
|
break;
|
||||||
|
case "feature-permissions":
|
||||||
|
configureFeaturePermissions(userId, username);
|
||||||
|
break;
|
||||||
|
case "view-sessions":
|
||||||
|
viewUserSessions(userId, username);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -463,6 +489,108 @@ window.configureClientAccess = async function (userId, username, role) {
|
|||||||
window.location.href = `/user-client-access?${params.toString()}`;
|
window.location.href = `/user-client-access?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FEATURE_LABELS = {
|
||||||
|
console: { label: "Console", icon: "fa-terminal" },
|
||||||
|
remote_desktop: { label: "Remote Desktop", icon: "fa-desktop" },
|
||||||
|
hvnc: { label: "HVNC", icon: "fa-window-restore" },
|
||||||
|
webcam: { label: "Webcam", icon: "fa-video" },
|
||||||
|
file_browser: { label: "File Browser", icon: "fa-folder-open" },
|
||||||
|
processes: { label: "Processes", icon: "fa-microchip" },
|
||||||
|
keylogger: { label: "Keylogger", icon: "fa-keyboard" },
|
||||||
|
voice: { label: "Voice", icon: "fa-microphone" },
|
||||||
|
};
|
||||||
|
|
||||||
|
window.configureFeaturePermissions = async function (userId, username) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${userId}/feature-permissions`);
|
||||||
|
if (!res.ok) throw new Error("Failed to load feature permissions");
|
||||||
|
const data = await res.json();
|
||||||
|
const perms = data.permissions;
|
||||||
|
const features = data.features;
|
||||||
|
|
||||||
|
let modal = document.getElementById("feature-perms-modal");
|
||||||
|
if (modal) modal.remove();
|
||||||
|
|
||||||
|
modal = document.createElement("div");
|
||||||
|
modal.id = "feature-perms-modal";
|
||||||
|
modal.className = "fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4";
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="bg-slate-900 border border-slate-700 rounded-xl max-w-md w-full p-6 shadow-2xl">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-xl font-bold text-slate-100">Feature Permissions</h3>
|
||||||
|
<button id="close-feature-modal" class="text-slate-400 hover:text-slate-200 transition-colors">
|
||||||
|
<i class="fa-solid fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-400 mb-4">
|
||||||
|
Manage feature access for <span class="text-slate-200 font-medium">${escapeHtml(username)}</span>.
|
||||||
|
Disabled features will return 403 when accessed.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2 mb-6" id="feature-toggles">
|
||||||
|
${features.map(f => {
|
||||||
|
const meta = FEATURE_LABELS[f] || { label: f, icon: "fa-puzzle-piece" };
|
||||||
|
const checked = perms[f] !== false;
|
||||||
|
return `
|
||||||
|
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 cursor-pointer transition-colors">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<i class="fa-solid ${meta.icon} text-slate-400 w-5 text-center"></i>
|
||||||
|
<span class="text-slate-200 font-medium">${meta.label}</span>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" data-feature="${f}" ${checked ? "checked" : ""}
|
||||||
|
class="w-5 h-5 rounded bg-slate-700 border-slate-600 text-blue-500 focus:ring-blue-500 focus:ring-offset-0 cursor-pointer" />
|
||||||
|
</label>`;
|
||||||
|
}).join("")}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button id="reset-feature-perms" class="flex-1 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg border border-slate-700 transition-colors font-medium">
|
||||||
|
Reset to Defaults
|
||||||
|
</button>
|
||||||
|
<button id="save-feature-perms" class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium">
|
||||||
|
<i class="fa-solid fa-check mr-2"></i>Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
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) {
|
window.toggleBuildPermission = async function (userId, username, currentCanBuild) {
|
||||||
const newVal = !currentCanBuild;
|
const newVal = !currentCanBuild;
|
||||||
if (!confirm(`${newVal ? 'Grant' : 'Revoke'} build permission for ${username}?`)) return;
|
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
|
||||||
|
? `<tr><td colspan="5" class="px-3 py-6 text-center text-slate-400">No sessions</td></tr>`
|
||||||
|
: sessions.map(s => {
|
||||||
|
const isExpired = s.expiresAt && s.expiresAt < now;
|
||||||
|
const status = s.revoked
|
||||||
|
? '<span class="text-rose-400">Revoked</span>'
|
||||||
|
: isExpired
|
||||||
|
? '<span class="text-slate-500">Expired</span>'
|
||||||
|
: '<span class="text-emerald-400">Active</span>';
|
||||||
|
const canRevoke = !s.revoked && !isExpired;
|
||||||
|
const lastAct = s.lastActivity ? formatRelTime(s.lastActivity) : "—";
|
||||||
|
|
||||||
|
return `<tr>
|
||||||
|
<td class="px-3 py-2 font-mono text-xs text-slate-100">${escapeHtml(s.ip || "—")}</td>
|
||||||
|
<td class="px-3 py-2 text-slate-400 text-xs">${lastAct}</td>
|
||||||
|
<td class="px-3 py-2 text-xs">${status}</td>
|
||||||
|
<td class="px-3 py-2 text-right">
|
||||||
|
${canRevoke ? `<button class="admin-revoke-session-btn px-2 py-1 rounded bg-red-700/80 hover:bg-red-600 text-white text-xs" data-session-id="${escapeHtml(s.id)}"><i class="fa-solid fa-ban mr-1"></i>Revoke</button>` : ""}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="bg-slate-900 border border-slate-700 rounded-xl p-5 max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-100">Sessions — ${escapeHtml(username)}</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${sessions.some(s => !s.revoked) ? `<button id="revoke-all-sessions-btn" class="px-3 py-1.5 text-xs bg-red-700/80 hover:bg-red-600 text-white rounded border border-red-600"><i class="fa-solid fa-ban mr-1"></i>Revoke All</button>` : ""}
|
||||||
|
<button id="close-sessions-modal" class="px-3 py-1.5 text-sm bg-slate-700 hover:bg-slate-600 text-white rounded">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto border border-slate-800 rounded-lg">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-800/60 text-slate-300">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-3 py-2">IP</th>
|
||||||
|
<th class="text-left px-3 py-2">Last Active</th>
|
||||||
|
<th class="text-left px-3 py-2">Status</th>
|
||||||
|
<th class="text-right px-3 py-2">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800">${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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();
|
getCurrentUser();
|
||||||
loadUsers();
|
loadUsers();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fetchVoiceCapabilities } from "./data.js";
|
import { fetchVoiceCapabilities } from "./data.js";
|
||||||
|
import { checkFeatureAccess } from "./feature-gate.js";
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const clientId = params.get("clientId") || "";
|
const clientId = params.get("clientId") || "";
|
||||||
@@ -470,4 +471,10 @@ window.addEventListener("beforeunload", disconnectVoice);
|
|||||||
updateMuteButtons();
|
updateMuteButtons();
|
||||||
drawWave(localWaveCanvas, localWaveBuffer, localWaveWrite, "rgba(34, 211, 238, 0.95)");
|
drawWave(localWaveCanvas, localWaveBuffer, localWaveWrite, "rgba(34, 211, 238, 0.95)");
|
||||||
drawWave(remoteWaveCanvas, remoteWaveBuffer, remoteWaveWrite, "rgba(16, 185, 129, 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||||
|
import { checkFeatureAccess } from "./feature-gate.js";
|
||||||
|
|
||||||
(function () {
|
(async function () {
|
||||||
const clientId = new URLSearchParams(location.search).get("clientId");
|
const clientId = new URLSearchParams(location.search).get("clientId");
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
alert("Missing clientId");
|
alert("Missing clientId");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowed = await checkFeatureAccess("webcam", clientId);
|
||||||
|
if (!allowed) return;
|
||||||
|
|
||||||
const clientLabel = document.getElementById("clientLabel");
|
const clientLabel = document.getElementById("clientLabel");
|
||||||
const startBtn = document.getElementById("startBtn");
|
const startBtn = document.getElementById("startBtn");
|
||||||
const stopBtn = document.getElementById("stopBtn");
|
const stopBtn = document.getElementById("stopBtn");
|
||||||
|
|||||||
@@ -240,6 +240,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Active Sessions -->
|
||||||
|
<section class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
|
||||||
|
<i class="fa-solid fa-desktop text-emerald-400"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Active Sessions</h2>
|
||||||
|
<p class="text-sm text-slate-400">View and manage your active login sessions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="refresh-sessions-btn"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm transition-colors"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-rotate"></i>Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto border border-slate-800 rounded-lg">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-800/60 text-slate-300">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-3 py-2">IP</th>
|
||||||
|
<th class="text-left px-3 py-2">Browser / Client</th>
|
||||||
|
<th class="text-left px-3 py-2">Created</th>
|
||||||
|
<th class="text-left px-3 py-2">Last Active</th>
|
||||||
|
<th class="text-left px-3 py-2">Status</th>
|
||||||
|
<th class="text-right px-3 py-2">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sessions-table-body" class="divide-y divide-slate-800">
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-3 py-6 text-center text-slate-400">Loading...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="sessions-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Dashboard Preferences -->
|
<!-- Dashboard Preferences -->
|
||||||
<section class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
|
<section class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import {
|
|||||||
persistRevokedToken,
|
persistRevokedToken,
|
||||||
isTokenRevoked,
|
isTokenRevoked,
|
||||||
pruneExpiredRevokedTokens,
|
pruneExpiredRevokedTokens,
|
||||||
|
createSession,
|
||||||
|
hashTokenForSession,
|
||||||
|
revokeSessionByTokenHash,
|
||||||
|
updateSessionActivity,
|
||||||
|
pruneExpiredSessions,
|
||||||
} from "./db";
|
} from "./db";
|
||||||
|
|
||||||
const JWT_ISSUER = "overlord-server";
|
const JWT_ISSUER = "overlord-server";
|
||||||
@@ -52,7 +57,10 @@ export interface AuthenticatedUser {
|
|||||||
role: UserRole;
|
role: UserRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateToken(user: User): Promise<string> {
|
export async function generateToken(
|
||||||
|
user: User,
|
||||||
|
sessionMeta?: { ip?: string; userAgent?: string },
|
||||||
|
): Promise<string> {
|
||||||
const expiration = `${getSessionTtlHours()}h`;
|
const expiration = `${getSessionTtlHours()}h`;
|
||||||
const token = await new SignJWT({
|
const token = await new SignJWT({
|
||||||
sub: user.username,
|
sub: user.username,
|
||||||
@@ -66,6 +74,24 @@ export async function generateToken(user: User): Promise<string> {
|
|||||||
.setExpirationTime(expiration)
|
.setExpirationTime(expiration)
|
||||||
.sign(getSecretKey());
|
.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;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,11 +146,16 @@ export function revokeToken(token: string): void {
|
|||||||
|
|
||||||
persistRevokedToken(token, expiresAt);
|
persistRevokedToken(token, expiresAt);
|
||||||
tokenCache.delete(token);
|
tokenCache.delete(token);
|
||||||
|
|
||||||
|
const tokenHash = hashTokenForSession(token);
|
||||||
|
revokeSessionByTokenHash(tokenHash);
|
||||||
|
|
||||||
console.log("[auth] Token revoked (persisted)");
|
console.log("[auth] Token revoked (persisted)");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupBlacklist(): Promise<void> {
|
export async function cleanupBlacklist(): Promise<void> {
|
||||||
const pruned = pruneExpiredRevokedTokens();
|
const pruned = pruneExpiredRevokedTokens();
|
||||||
|
const prunedSessions = pruneExpiredSessions();
|
||||||
|
|
||||||
const cacheNow = Date.now();
|
const cacheNow = Date.now();
|
||||||
let cacheCleared = 0;
|
let cacheCleared = 0;
|
||||||
@@ -135,9 +166,16 @@ export async function cleanupBlacklist(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SESSION_ACTIVITY_THROTTLE = 60_000; // 60s
|
||||||
|
const lastActivityUpdate = new Map<string, number>();
|
||||||
|
|
||||||
export async function authenticateRequest(
|
export async function authenticateRequest(
|
||||||
req: Request,
|
req: Request,
|
||||||
): Promise<AuthenticatedUser | null> {
|
): Promise<AuthenticatedUser | null> {
|
||||||
@@ -203,6 +244,15 @@ export async function authenticateRequest(
|
|||||||
return null;
|
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 {
|
return {
|
||||||
username: payload.sub,
|
username: payload.sub,
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
|
|||||||
@@ -134,6 +134,24 @@ db.run(
|
|||||||
`CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at);`,
|
`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(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS builds (
|
CREATE TABLE IF NOT EXISTS builds (
|
||||||
id TEXT PRIMARY KEY,
|
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 {
|
export function isTokenRevoked(token: string): boolean {
|
||||||
const row = db.query<{ token_hash: string }>(
|
const row = db.query<{ token_hash: string }>(
|
||||||
`SELECT token_hash FROM revoked_tokens WHERE token_hash=?`,
|
`SELECT token_hash FROM revoked_tokens WHERE token_hash=?`,
|
||||||
@@ -634,6 +660,110 @@ export function pruneExpiredRevokedTokens(): number {
|
|||||||
return result.changes;
|
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<any>(
|
||||||
|
`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<any>(`SELECT * FROM sessions WHERE id=?`).get(id);
|
||||||
|
if (!row) return null;
|
||||||
|
return mapSessionRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listUserSessions(userId: number): SessionRecord[] {
|
||||||
|
const rows = db.query<any>(
|
||||||
|
`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() {
|
export function markAllClientsOffline() {
|
||||||
db.run(`UPDATE clients SET online=0`);
|
db.run(`UPDATE clients SET online=0`);
|
||||||
console.log("[db] marked all clients as offline");
|
console.log("[db] marked all clients as offline");
|
||||||
|
|||||||
@@ -8,13 +8,21 @@ import {
|
|||||||
revokeToken,
|
revokeToken,
|
||||||
} from "../../auth";
|
} from "../../auth";
|
||||||
import { AuditAction, logAudit } from "../../auditLog";
|
import { AuditAction, logAudit } from "../../auditLog";
|
||||||
|
import {
|
||||||
|
listUserSessions,
|
||||||
|
revokeSessionById,
|
||||||
|
revokeAllUserSessions,
|
||||||
|
hashTokenForSession,
|
||||||
|
persistRevokedTokenHash,
|
||||||
|
getSessionById,
|
||||||
|
} from "../../db";
|
||||||
import { logger } from "../../logger";
|
import { logger } from "../../logger";
|
||||||
import {
|
import {
|
||||||
isRateLimited,
|
isRateLimited,
|
||||||
recordFailedAttempt,
|
recordFailedAttempt,
|
||||||
recordSuccessfulAttempt,
|
recordSuccessfulAttempt,
|
||||||
} from "../../rateLimit";
|
} from "../../rateLimit";
|
||||||
import { getUserById } from "../../users";
|
import { getUserById, canUserAccessClient, canUserAccessFeature, type FeatureName, ALL_FEATURES } from "../../users";
|
||||||
import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie";
|
import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie";
|
||||||
|
|
||||||
type RequestIpProvider = {
|
type RequestIpProvider = {
|
||||||
@@ -63,7 +71,8 @@ export async function handleAuthRoutes(
|
|||||||
const user = await authenticateUser(username, password);
|
const user = await authenticateUser(username, password);
|
||||||
|
|
||||||
if (user) {
|
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();
|
const sessionTtlSeconds = getSessionTtlSeconds();
|
||||||
|
|
||||||
logger.info(
|
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") {
|
if (req.method === "GET" && url.pathname === "/api/auth/me") {
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { logger } from "../../logger";
|
|||||||
import { encodeMessage } from "../../protocol";
|
import { encodeMessage } from "../../protocol";
|
||||||
import { getConfig } from "../../config";
|
import { getConfig } from "../../config";
|
||||||
import { isAuthorizedAgentRequest } from "../agent-auth";
|
import { isAuthorizedAgentRequest } from "../agent-auth";
|
||||||
|
import { canUserAccessClient } from "../../users";
|
||||||
|
|
||||||
type RequestIpProvider = {
|
type RequestIpProvider = {
|
||||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||||
@@ -358,6 +359,10 @@ export async function handleFileDownloadRoutes(
|
|||||||
return new Response("Bad request", { status: 400 });
|
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", {
|
logger.debug("[filebrowser] http upload request", {
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
clientId,
|
clientId,
|
||||||
@@ -598,6 +603,10 @@ export async function handleFileDownloadRoutes(
|
|||||||
return new Response("Bad request", { status: 400 });
|
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);
|
const target = clientManager.getClient(clientId);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return new Response("Client offline", { status: 404 });
|
return new Response("Client offline", { status: 404 });
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import { requirePermission } from "../../rbac";
|
|||||||
import {
|
import {
|
||||||
type ClientAccessRuleKind,
|
type ClientAccessRuleKind,
|
||||||
type ClientAccessScope,
|
type ClientAccessScope,
|
||||||
|
type FeatureName,
|
||||||
|
ALL_FEATURES,
|
||||||
createUser,
|
createUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
getUserById,
|
getUserById,
|
||||||
getUserClientAccessScope,
|
getUserClientAccessScope,
|
||||||
|
getUserFeaturePermissions,
|
||||||
listUsers,
|
listUsers,
|
||||||
listUserClientAccessRules,
|
listUserClientAccessRules,
|
||||||
removeUserClientAccessRule,
|
removeUserClientAccessRule,
|
||||||
@@ -16,6 +19,8 @@ import {
|
|||||||
setUserClientAccessScope,
|
setUserClientAccessScope,
|
||||||
setUserCanBuild,
|
setUserCanBuild,
|
||||||
setUserCanUploadFiles,
|
setUserCanUploadFiles,
|
||||||
|
setUserFeaturePermissions,
|
||||||
|
resetUserFeaturePermissions,
|
||||||
updateUserPassword,
|
updateUserPassword,
|
||||||
updateUserRole,
|
updateUserRole,
|
||||||
} from "../../users";
|
} from "../../users";
|
||||||
@@ -123,7 +128,8 @@ export async function handleUsersRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (user.userId === userId && targetUser) {
|
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();
|
const sessionTtlSeconds = getSessionTtlSeconds();
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -382,6 +388,83 @@ export async function handleUsersRoutes(
|
|||||||
return Response.json({ success: true, canUploadFiles });
|
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<Record<FeatureName, boolean>> = {};
|
||||||
|
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 });
|
return new Response("Not found", { status: 404 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Response) {
|
if (error instanceof Response) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
import { logger } from "../../logger";
|
import { logger } from "../../logger";
|
||||||
import { isIpBanned } from "../../db";
|
import { isIpBanned } from "../../db";
|
||||||
|
import { canUserAccessClient, canUserAccessFeature } from "../../users";
|
||||||
|
import type { FeatureName } from "../../users";
|
||||||
|
|
||||||
type RequestServer = {
|
type RequestServer = {
|
||||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||||
@@ -40,6 +42,20 @@ function isWsRateLimited(ip: string): boolean {
|
|||||||
return false;
|
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(
|
export async function handleWsUpgradeRoutes(
|
||||||
req: Request,
|
req: Request,
|
||||||
url: URL,
|
url: URL,
|
||||||
@@ -62,9 +78,11 @@ export async function handleWsUpgradeRoutes(
|
|||||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||||
}
|
}
|
||||||
const clientId = consoleWsMatch[1];
|
const clientId = consoleWsMatch[1];
|
||||||
|
const denied = checkOperatorAccess(user, clientId, "console");
|
||||||
|
if (denied) return denied;
|
||||||
const sessionId = crypto.randomUUID();
|
const sessionId = crypto.randomUUID();
|
||||||
const ip = server.requestIP(req)?.address || "";
|
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();
|
||||||
}
|
}
|
||||||
return new Response("Upgrade failed", { status: 500 });
|
return new Response("Upgrade failed", { status: 500 });
|
||||||
@@ -77,7 +95,7 @@ export async function handleWsUpgradeRoutes(
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
const clientId = wsMatch[1];
|
const clientId = wsMatch[1];
|
||||||
const role = (url.searchParams.get("role") as any) || "viewer";
|
const role = "client";
|
||||||
const ip = server.requestIP(req)?.address || "";
|
const ip = server.requestIP(req)?.address || "";
|
||||||
if (ip && isIpBanned(ip)) {
|
if (ip && isIpBanned(ip)) {
|
||||||
logger.warn(`[auth] Rejected banned IP ${ip} for client ${clientId}`);
|
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 });
|
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||||
}
|
}
|
||||||
const clientId = rdMatch[1];
|
const clientId = rdMatch[1];
|
||||||
|
const denied = checkOperatorAccess(user, clientId, "remote_desktop");
|
||||||
|
if (denied) return denied;
|
||||||
const ip = server.requestIP(req)?.address || "";
|
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();
|
||||||
}
|
}
|
||||||
return new Response("Upgrade failed", { status: 500 });
|
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 });
|
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||||
}
|
}
|
||||||
const clientId = hvncMatch[1];
|
const clientId = hvncMatch[1];
|
||||||
|
const denied = checkOperatorAccess(user, clientId, "hvnc");
|
||||||
|
if (denied) return denied;
|
||||||
const ip = server.requestIP(req)?.address || "";
|
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();
|
||||||
}
|
}
|
||||||
return new Response("Upgrade failed", { status: 500 });
|
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 });
|
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||||
}
|
}
|
||||||
const clientId = webcamMatch[1];
|
const clientId = webcamMatch[1];
|
||||||
|
const denied = checkOperatorAccess(user, clientId, "webcam");
|
||||||
|
if (denied) return denied;
|
||||||
const ip = server.requestIP(req)?.address || "";
|
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();
|
||||||
}
|
}
|
||||||
return new Response("Upgrade failed", { status: 500 });
|
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 });
|
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||||
}
|
}
|
||||||
const clientId = fbMatch[1];
|
const clientId = fbMatch[1];
|
||||||
|
const denied = checkOperatorAccess(user, clientId, "file_browser");
|
||||||
|
if (denied) return denied;
|
||||||
const ip = server.requestIP(req)?.address || "";
|
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();
|
||||||
}
|
}
|
||||||
return new Response("Upgrade failed", { status: 500 });
|
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 });
|
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||||
}
|
}
|
||||||
const clientId = processMatch[1];
|
const clientId = processMatch[1];
|
||||||
|
const denied = checkOperatorAccess(user, clientId, "processes");
|
||||||
|
if (denied) return denied;
|
||||||
const ip = server.requestIP(req)?.address || "";
|
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();
|
||||||
}
|
}
|
||||||
return new Response("Upgrade failed", { status: 500 });
|
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 });
|
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||||
}
|
}
|
||||||
const clientId = keyloggerMatch[1];
|
const clientId = keyloggerMatch[1];
|
||||||
|
const denied = checkOperatorAccess(user, clientId, "keylogger");
|
||||||
|
if (denied) return denied;
|
||||||
const ip = server.requestIP(req)?.address || "";
|
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();
|
||||||
}
|
}
|
||||||
return new Response("Upgrade failed", { status: 500 });
|
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 });
|
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||||
}
|
}
|
||||||
const clientId = voiceMatch[1];
|
const clientId = voiceMatch[1];
|
||||||
|
const denied = checkOperatorAccess(user, clientId, "voice");
|
||||||
|
if (denied) return denied;
|
||||||
const ip = server.requestIP(req)?.address || "";
|
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();
|
||||||
}
|
}
|
||||||
return new Response("Upgrade failed", { status: 500 });
|
return new Response("Upgrade failed", { status: 500 });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { resolveRuntimeRoot } from "./runtime-paths";
|
|||||||
import * as sessionManager from "../sessions/sessionManager";
|
import * as sessionManager from "../sessions/sessionManager";
|
||||||
import type { ConsoleSession, RemoteDesktopViewer, SocketData } from "../sessions/types";
|
import type { ConsoleSession, RemoteDesktopViewer, SocketData } from "../sessions/types";
|
||||||
import type { ClientInfo } from "../types";
|
import type { ClientInfo } from "../types";
|
||||||
|
import { canUserAccessClient } from "../users";
|
||||||
|
|
||||||
let _cachedInjectionDll: Uint8Array | null = null;
|
let _cachedInjectionDll: Uint8Array | null = null;
|
||||||
let _dllCachePath: string | null = null;
|
let _dllCachePath: string | null = null;
|
||||||
@@ -248,7 +249,11 @@ export function notifyConsoleClosed(clientId: string, reason: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function handleConsoleViewerOpen(ws: ServerWebSocket<SocketData>) {
|
export function handleConsoleViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||||
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();
|
const effectiveSessionId = sessionId || uuidv4();
|
||||||
ws.data.sessionId = effectiveSessionId;
|
ws.data.sessionId = effectiveSessionId;
|
||||||
const target = clientManager.getClient(clientId);
|
const target = clientManager.getClient(clientId);
|
||||||
@@ -272,7 +277,11 @@ export function handleConsoleViewerOpen(ws: ServerWebSocket<SocketData>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function handleRemoteDesktopViewerOpen(ws: ServerWebSocket<SocketData>) {
|
export function handleRemoteDesktopViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||||
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 sessionId = uuidv4();
|
||||||
ws.data.sessionId = sessionId;
|
ws.data.sessionId = sessionId;
|
||||||
const target = clientManager.getClient(clientId);
|
const target = clientManager.getClient(clientId);
|
||||||
@@ -506,7 +515,11 @@ export const hvncStreamingState = new Map<string, { isStreaming: boolean; displa
|
|||||||
export const webcamStreamingState = new Map<string, { isStreaming: boolean; deviceIndex: number; fps: number; useMax: boolean; quality: number; codec: string }>();
|
export const webcamStreamingState = new Map<string, { isStreaming: boolean; deviceIndex: number; fps: number; useMax: boolean; quality: number; codec: string }>();
|
||||||
|
|
||||||
export function handleWebcamViewerOpen(ws: ServerWebSocket<SocketData>) {
|
export function handleWebcamViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||||
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 sessionId = uuidv4();
|
||||||
ws.data.sessionId = sessionId;
|
ws.data.sessionId = sessionId;
|
||||||
const target = clientManager.getClient(clientId);
|
const target = clientManager.getClient(clientId);
|
||||||
@@ -641,7 +654,11 @@ export function handleWebcamViewerMessage(ws: ServerWebSocket<SocketData>, raw:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function handleHVNCViewerOpen(ws: ServerWebSocket<SocketData>) {
|
export function handleHVNCViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||||
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 sessionId = uuidv4();
|
||||||
ws.data.sessionId = sessionId;
|
ws.data.sessionId = sessionId;
|
||||||
const target = clientManager.getClient(clientId);
|
const target = clientManager.getClient(clientId);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { encodeMessage } from "../protocol";
|
|||||||
import * as sessionManager from "../sessions/sessionManager";
|
import * as sessionManager from "../sessions/sessionManager";
|
||||||
import type { SocketData } from "../sessions/types";
|
import type { SocketData } from "../sessions/types";
|
||||||
import { normalizeFileUploadPayload } from "../fileTransfers";
|
import { normalizeFileUploadPayload } from "../fileTransfers";
|
||||||
|
import { canUserAccessClient } from "../users";
|
||||||
|
|
||||||
type FileBrowserViewer = {
|
type FileBrowserViewer = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -61,7 +62,11 @@ function safeSendViewer(ws: ServerWebSocket<SocketData>, payload: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function handleFileBrowserViewerOpen(ws: ServerWebSocket<SocketData>) {
|
export function handleFileBrowserViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||||
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 sessionId = uuidv4();
|
||||||
const target = clientManager.getClient(clientId);
|
const target = clientManager.getClient(clientId);
|
||||||
const session: FileBrowserViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
|
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<SocketData>) {
|
export function handleProcessViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||||
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 sessionId = uuidv4();
|
||||||
const target = clientManager.getClient(clientId);
|
const target = clientManager.getClient(clientId);
|
||||||
const session: ProcessViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
|
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<SocketData>) {
|
export function handleKeyloggerViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||||
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 sessionId = uuidv4();
|
||||||
const target = clientManager.getClient(clientId);
|
const target = clientManager.getClient(clientId);
|
||||||
const session = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
|
const session = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as clientManager from "../clientManager";
|
|||||||
import { encodeMessage } from "../protocol";
|
import { encodeMessage } from "../protocol";
|
||||||
import * as sessionManager from "../sessions/sessionManager";
|
import * as sessionManager from "../sessions/sessionManager";
|
||||||
import type { SocketData, VoiceViewer } from "../sessions/types";
|
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<string, unknown>) {
|
function sendVoiceCommand(clientId: string, commandType: "voice_session_start" | "voice_session_stop" | "voice_downlink", payload: Record<string, unknown>) {
|
||||||
const target = clientManager.getClient(clientId);
|
const target = clientManager.getClient(clientId);
|
||||||
@@ -32,7 +33,11 @@ function safeJson(ws: ServerWebSocket<SocketData>, payload: Record<string, unkno
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function handleVoiceViewerOpen(ws: ServerWebSocket<SocketData>) {
|
export function handleVoiceViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||||
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 sessionId = uuidv4();
|
||||||
ws.data.sessionId = sessionId;
|
ws.data.sessionId = sessionId;
|
||||||
const target = clientManager.getClient(clientId);
|
const target = clientManager.getClient(clientId);
|
||||||
|
|||||||
@@ -84,6 +84,16 @@ db.exec(
|
|||||||
`CREATE INDEX IF NOT EXISTS idx_user_client_access_rules_user ON user_client_access_rules(user_id)`,
|
`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 {
|
try {
|
||||||
db.exec(
|
db.exec(
|
||||||
`ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0`,
|
`ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0`,
|
||||||
@@ -397,6 +407,136 @@ export function canUserAccessClient(
|
|||||||
return false;
|
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<number, Map<string, boolean>>();
|
||||||
|
|
||||||
|
function getFeaturePermCacheEntry(userId: number): Map<string, boolean> {
|
||||||
|
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<string, boolean>();
|
||||||
|
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<FeatureName, boolean> {
|
||||||
|
const user = getUserById(userId);
|
||||||
|
if (!user) {
|
||||||
|
const result = {} as Record<FeatureName, boolean>;
|
||||||
|
for (const f of ALL_FEATURES) result[f] = false;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {} as Record<FeatureName, boolean>;
|
||||||
|
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<Record<FeatureName, boolean>>,
|
||||||
|
): { 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 {
|
function validatePasswordPolicy(password: string): string | null {
|
||||||
const security = getConfig().security;
|
const security = getConfig().security;
|
||||||
const minLength = Math.min(128, Math.max(6, Number(security.passwordMinLength) || 6));
|
const minLength = Math.min(128, Math.max(6, Number(security.passwordMinLength) || 6));
|
||||||
|
|||||||
Reference in New Issue
Block a user