huge update to RBAC
This commit is contained in:
@@ -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<string> {
|
||||
export async function generateToken(
|
||||
user: User,
|
||||
sessionMeta?: { ip?: string; userAgent?: string },
|
||||
): Promise<string> {
|
||||
const expiration = `${getSessionTtlHours()}h`;
|
||||
const token = await new SignJWT({
|
||||
sub: user.username,
|
||||
@@ -66,6 +74,24 @@ export async function generateToken(user: User): Promise<string> {
|
||||
.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<void> {
|
||||
const pruned = pruneExpiredRevokedTokens();
|
||||
const prunedSessions = pruneExpiredSessions();
|
||||
|
||||
const cacheNow = Date.now();
|
||||
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(
|
||||
`[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<string, number>();
|
||||
|
||||
export async function authenticateRequest(
|
||||
req: Request,
|
||||
): Promise<AuthenticatedUser | null> {
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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() {
|
||||
db.run(`UPDATE clients SET online=0`);
|
||||
console.log("[db] marked all clients as offline");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<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 });
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<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();
|
||||
ws.data.sessionId = effectiveSessionId;
|
||||
const target = clientManager.getClient(clientId);
|
||||
@@ -272,7 +277,11 @@ export function handleConsoleViewerOpen(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();
|
||||
ws.data.sessionId = sessionId;
|
||||
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 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();
|
||||
ws.data.sessionId = sessionId;
|
||||
const target = clientManager.getClient(clientId);
|
||||
@@ -641,7 +654,11 @@ export function handleWebcamViewerMessage(ws: ServerWebSocket<SocketData>, raw:
|
||||
}
|
||||
|
||||
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();
|
||||
ws.data.sessionId = sessionId;
|
||||
const target = clientManager.getClient(clientId);
|
||||
|
||||
@@ -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<SocketData>, payload: unknown) {
|
||||
}
|
||||
|
||||
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 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<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 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<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 target = clientManager.getClient(clientId);
|
||||
const session = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
const target = clientManager.getClient(clientId);
|
||||
@@ -32,7 +33,11 @@ function safeJson(ws: ServerWebSocket<SocketData>, payload: Record<string, unkno
|
||||
}
|
||||
|
||||
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();
|
||||
ws.data.sessionId = sessionId;
|
||||
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)`,
|
||||
);
|
||||
|
||||
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<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 {
|
||||
const security = getConfig().security;
|
||||
const minLength = Math.min(128, Math.max(6, Number(security.passwordMinLength) || 6));
|
||||
|
||||
Reference in New Issue
Block a user