Compare commits

...

2 Commits

4 changed files with 313 additions and 20 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
*.tsbuildinfo
public/storage/backups/

View File

@ -42,7 +42,7 @@
"avatarUrl": "/storage/uploads/1777901476392-03f10a36-2022-10-13-20-52-47-0287-2037248814-scale20.00-k_euler_a-0287.jpg",
"globalStatus": "active",
"createdAt": "2026-05-04T00:00:00.000Z",
"updatedAt": "2026-05-04T14:15:10.555Z"
"updatedAt": "2026-05-04T15:26:08.500Z"
}
],
"memberships": [
@ -275,8 +275,8 @@
"targetId": "user_silver_psih",
"appRole": "member",
"status": "active",
"createdAt": "2026-05-04T14:15:10.296Z",
"updatedAt": "2026-05-04T14:15:10.296Z"
"createdAt": "2026-05-04T15:26:07.830Z",
"updatedAt": "2026-05-04T15:26:07.830Z"
}
],
"exceptions": [],
@ -311,9 +311,9 @@
"objectType": "user",
"target": "authentik",
"state": "synced",
"lastSyncAt": "2026-05-04T14:15:10.555Z",
"lastSyncAt": "2026-05-04T15:26:08.500Z",
"error": null,
"updatedAt": "2026-05-04T14:15:10.555Z"
"updatedAt": "2026-05-04T15:26:08.500Z"
},
{
"id": "sync_dctouch_groups_authentik",
@ -357,7 +357,7 @@
"state": "pending",
"lastSyncAt": null,
"error": null,
"updatedAt": "2026-05-04T14:15:10.297Z"
"updatedAt": "2026-05-04T15:26:07.830Z"
}
],
"auditEvents": [
@ -1044,6 +1044,102 @@
"clientId": null,
"result": "success",
"details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin"
},
{
"id": "audit_silver_psih_yahoo_com_task_manager_15",
"at": "2026-05-04T14:50:51.260Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Обновлён доступ пользователя к сервису",
"objectType": "grant",
"objectName": "silver_psih@yahoo.com / task-manager",
"clientId": null,
"result": "success",
"details": "Value: deny"
},
{
"id": "audit_silver_psih_yahoo_com_39",
"at": "2026-05-04T14:50:51.574Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Пользователь синхронизирован в Authentik",
"objectType": "user",
"objectName": "silver_psih@yahoo.com",
"clientId": null,
"result": "success",
"details": "Groups: nodedc:launcher:user, service-digital-twin"
},
{
"id": "audit_silver_psih_yahoo_com_task_manager_16",
"at": "2026-05-04T14:51:46.812Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Обновлён доступ пользователя к сервису",
"objectType": "grant",
"objectName": "silver_psih@yahoo.com / task-manager",
"clientId": null,
"result": "success",
"details": "Value: member"
},
{
"id": "audit_silver_psih_yahoo_com_40",
"at": "2026-05-04T14:51:47.092Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Пользователь синхронизирован в Authentik",
"objectType": "user",
"objectName": "silver_psih@yahoo.com",
"clientId": null,
"result": "success",
"details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin"
},
{
"id": "audit_silver_psih_yahoo_com_task_manager_17",
"at": "2026-05-04T15:25:51.639Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Обновлён доступ пользователя к сервису",
"objectType": "grant",
"objectName": "silver_psih@yahoo.com / task-manager",
"clientId": null,
"result": "success",
"details": "Value: deny"
},
{
"id": "audit_silver_psih_yahoo_com_41",
"at": "2026-05-04T15:25:51.952Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Пользователь синхронизирован в Authentik",
"objectType": "user",
"objectName": "silver_psih@yahoo.com",
"clientId": null,
"result": "success",
"details": "Groups: nodedc:launcher:user, service-digital-twin"
},
{
"id": "audit_silver_psih_yahoo_com_task_manager_18",
"at": "2026-05-04T15:26:07.830Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Обновлён доступ пользователя к сервису",
"objectType": "grant",
"objectName": "silver_psih@yahoo.com / task-manager",
"clientId": null,
"result": "success",
"details": "Value: member"
},
{
"id": "audit_silver_psih_yahoo_com_42",
"at": "2026-05-04T15:26:08.500Z",
"actorUserId": "user_root",
"actorName": "DC SUDO",
"action": "Пользователь синхронизирован в Authentik",
"objectType": "user",
"objectName": "silver_psih@yahoo.com",
"clientId": null,
"result": "success",
"details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin"
}
]
}

View File

@ -1,6 +1,6 @@
import express from "express";
import { createServer as createHttpServer } from "node:http";
import { randomBytes, randomUUID, createHash } from "node:crypto";
import { randomBytes, randomUUID, createHash, timingSafeEqual } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, extname, join, resolve } from "node:path";
@ -16,6 +16,7 @@ const maxStorageJsonBodyBytes = "260mb";
const pendingLoginTtlMs = 10 * 60 * 1000;
const sessionTtlMs = 12 * 60 * 60 * 1000;
const oidcStateCookieName = "nodedc_oidc_state";
const maxOidcStateCookieEntries = 8;
const sessionCookieName = "nodedc_session";
loadEnvFiles([
@ -44,6 +45,7 @@ app.get("/healthz", (_req, res) => {
service: "nodedc-launcher-bff",
oidcConfigured: config.oidcConfigured,
authentikApiConfigured: authentikSyncClient.isConfigured(),
internalAccessApiConfigured: Boolean(config.internalAccessToken),
});
});
@ -65,7 +67,7 @@ app.get("/auth/login", asyncRoute(async (req, res) => {
expiresAt: Date.now() + pendingLoginTtlMs,
});
res.cookie(oidcStateCookieName, state, cookieOptions(pendingLoginTtlMs));
setOidcStateCookie(res, [state, ...getValidOidcCookieStates(req)].slice(0, maxOidcStateCookieEntries));
const authorizationUrl = new URL(discovery.authorization_endpoint);
authorizationUrl.searchParams.set("response_type", "code");
@ -96,15 +98,17 @@ app.get("/auth/callback", asyncRoute(async (req, res) => {
const code = typeof req.query.code === "string" ? req.query.code : null;
const state = typeof req.query.state === "string" ? req.query.state : null;
const cookieState = parseCookies(req.headers.cookie)[oidcStateCookieName];
const cookieStates = getValidOidcCookieStates(req);
if (!code || !state || state !== cookieState) {
throw new Error("OIDC callback state validation failed");
if (!code || !state || !cookieStates.includes(state)) {
res.clearCookie(oidcStateCookieName, clearCookieOptions());
res.redirect("/auth/login?returnTo=/");
return;
}
const pendingLogin = pendingLogins.get(state);
pendingLogins.delete(state);
res.clearCookie(oidcStateCookieName, clearCookieOptions());
setOidcStateCookie(res, cookieStates.filter((cookieState) => cookieState !== state));
if (!pendingLogin || pendingLogin.expiresAt < Date.now()) {
throw new Error("OIDC login state expired");
@ -152,7 +156,7 @@ app.get("/auth/logout", asyncRoute(async (req, res) => {
const endSessionEndpoint = discovery.end_session_endpoint;
if (!endSessionEndpoint) {
res.redirect(returnTo);
res.type("html").send(renderGlobalLogoutPage(getFrontchannelLogoutUrls(), new URL(returnTo, config.appBaseUrl).toString()));
return;
}
@ -164,7 +168,7 @@ app.get("/auth/logout", asyncRoute(async (req, res) => {
logoutUrl.searchParams.set("id_token_hint", session.tokenSet.idToken);
}
res.redirect(logoutUrl.toString());
res.type("html").send(renderGlobalLogoutPage(getFrontchannelLogoutUrls(), logoutUrl.toString()));
}));
app.get("/api/me", (req, res) => {
@ -182,7 +186,7 @@ app.get("/api/me", (req, res) => {
user: runtimeContext.user,
groups: runtimeContext.groups,
isSuperAdmin: runtimeContext.groups.includes("nodedc:superadmin"),
logoutUrl: "/auth/logout",
logoutUrl: "/auth/logout?global=1&returnTo=/",
});
});
@ -232,6 +236,54 @@ app.get("/api/events", requireSession, (req, res) => {
});
});
app.post("/api/internal/access/check", (req, res) => {
if (!isInternalRequestAuthorized(req)) {
res.status(config.internalAccessToken ? 401 : 503).json({
ok: false,
error: config.internalAccessToken ? "internal_access_unauthorized" : "internal_access_not_configured",
});
return;
}
const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC internal access check" });
const user = findInternalAccessUser(snapshot.data, req.body);
const serviceSlug = sanitizeServiceSlug(req.body?.serviceSlug);
if (!user) {
res.json({
ok: true,
allowed: false,
reason: "user_not_found",
serviceSlug,
groups: [],
matchedGroups: [],
user: null,
});
return;
}
const groups = resolveRequiredGroups(snapshot.data, user);
const app = getAppsForUser(groups).find((candidate) => candidate.slug === serviceSlug);
const allowed = Boolean(app?.hasAccess);
res.json({
ok: true,
allowed,
reason: allowed ? "access_confirmed" : "access_denied",
serviceSlug,
groups,
matchedGroups: app?.matchedGroups ?? [],
user: {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatarUrl ?? null,
authentikUserId: user.authentikUserId ?? null,
globalStatus: user.globalStatus,
},
});
});
app.patch("/api/profile", requireSession, asyncRoute(async (req, res) => {
const { actor } = getLauncherProfileContext(req.nodedcSession);
const result = await controlPlaneStore.updateUserProfile(actor.id, sanitizeSelfProfilePatch(req.body), req.nodedcSession.user);
@ -506,6 +558,14 @@ function readConfig() {
process.env.AUTHENTIK_SERVICE_TOKEN ??
process.env.AUTHENTIK_BOOTSTRAP_TOKEN ??
"",
internalAccessToken:
process.env.NODEDC_INTERNAL_ACCESS_TOKEN ??
process.env.NODEDC_PLATFORM_SERVICE_TOKEN ??
process.env.PLANE_OIDC_CLIENT_SECRET ??
"",
taskLogoutUrl:
process.env.TASK_LOGOUT_URL ??
`${(process.env.TASK_BASE_URL ?? `http://${process.env.TASK_DOMAIN ?? "task.local.nodedc"}`).replace(/\/$/, "")}/logout`,
};
}
@ -831,6 +891,68 @@ function getServiceUrl(service) {
return service.launchUrl || service.url || "#";
}
function getFrontchannelLogoutUrls() {
const urls = [config.taskLogoutUrl];
const launcherData = readLauncherData();
const services = Array.isArray(launcherData?.services) ? launcherData.services : [];
for (const service of services) {
if (typeof service.logoutUrl === "string" && service.logoutUrl.trim()) {
urls.push(service.logoutUrl.trim());
}
}
return [...new Set(urls.map(normalizeLogoutUrl).filter(Boolean))];
}
function normalizeLogoutUrl(value) {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return url.toString();
} catch {
return null;
}
}
function renderGlobalLogoutPage(frontchannelLogoutUrls, finalRedirectUrl) {
const logoutUrlsJson = JSON.stringify(frontchannelLogoutUrls);
const redirectUrlJson = JSON.stringify(finalRedirectUrl);
return `<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Выход из NODE.DC</title>
<style>
html,body{height:100%;margin:0;background:#050606;color:#f4f4f4;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
body{display:grid;place-items:center}
main{max-width:28rem;padding:2rem;text-align:center}
h1{margin:0 0 .75rem;font-size:1.5rem}
p{margin:0;color:#a6a6a6;line-height:1.5}
</style>
</head>
<body>
<main>
<h1>Выходим из NODE.DC</h1>
<p>Закрываем сессии подключённых приложений и платформенный вход.</p>
</main>
<script>
const logoutUrls = ${logoutUrlsJson};
const finalRedirectUrl = ${redirectUrlJson};
for (const logoutUrl of logoutUrls) {
fetch(logoutUrl, { mode: "no-cors", credentials: "include", keepalive: true }).catch(() => undefined);
const image = new Image();
image.referrerPolicy = "no-referrer";
image.src = logoutUrl;
}
window.setTimeout(() => window.location.replace(finalRedirectUrl), 700);
</script>
</body>
</html>`;
}
function readLauncherData() {
const dataPath = join(projectRoot, "public", "storage", "launcher-data.json");
@ -954,6 +1076,36 @@ function pruneExpiredState() {
}
}
function getValidOidcCookieStates(req) {
const rawValue = parseCookies(req.headers.cookie)[oidcStateCookieName];
if (!rawValue) return [];
const seen = new Set();
return rawValue
.split(".")
.filter((state) => /^[A-Za-z0-9_-]{32,256}$/.test(state))
.filter((state) => {
if (seen.has(state)) return false;
seen.add(state);
return true;
})
.filter((state) => {
const pendingLogin = pendingLogins.get(state);
return Boolean(pendingLogin && pendingLogin.expiresAt >= Date.now());
});
}
function setOidcStateCookie(res, states) {
if (!states.length) {
res.clearCookie(oidcStateCookieName, clearCookieOptions());
return;
}
res.cookie(oidcStateCookieName, states.join("."), cookieOptions(pendingLoginTtlMs));
}
function parseCookies(cookieHeader) {
if (!cookieHeader) return {};
@ -1000,6 +1152,47 @@ function requireSession(req, res, next) {
next();
}
function isInternalRequestAuthorized(req) {
if (!config.internalAccessToken) {
return false;
}
const authorization = typeof req.headers.authorization === "string" ? req.headers.authorization : "";
const bearerToken = authorization.match(/^Bearer\s+(.+)$/i)?.[1] ?? "";
const headerToken = typeof req.headers["x-nodedc-internal-token"] === "string" ? req.headers["x-nodedc-internal-token"] : "";
const requestToken = bearerToken || headerToken;
return safeTokenEquals(requestToken, config.internalAccessToken);
}
function safeTokenEquals(actual, expected) {
if (!actual || !expected) {
return false;
}
const actualBuffer = Buffer.from(String(actual));
const expectedBuffer = Buffer.from(String(expected));
return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
}
function findInternalAccessUser(data, payload) {
const subject = typeof payload?.subject === "string" ? payload.subject : "";
const email = typeof payload?.email === "string" ? payload.email.toLowerCase() : "";
const userId = typeof payload?.userId === "string" ? payload.userId : "";
return (
data.users.find((user) => userId && user.id === userId) ??
data.users.find((user) => subject && user.authentikUserId === subject) ??
data.users.find((user) => email && user.email.toLowerCase() === email) ??
null
);
}
function sanitizeServiceSlug(value) {
return typeof value === "string" && value ? value : "task-manager";
}
function getLauncherProfileContext(session) {
const snapshot = controlPlaneStore.getSnapshot(session.user);

View File

@ -62,7 +62,6 @@ export function LauncherApp() {
const [adminOpen, setAdminOpen] = useState(false);
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
@ -160,7 +159,6 @@ export function LauncherApp() {
if (!isMounted) return;
setAuthSession(session);
setAuthError(null);
if (!session.authenticated) {
setAuthApps([]);
@ -178,7 +176,7 @@ export function LauncherApp() {
setAuthSession({ authenticated: false, loginUrl: "/auth/login" });
setAuthApps([]);
setAuthError(error instanceof Error ? error.message : "Не удалось проверить сессию платформы");
console.warn(error instanceof Error ? error.message : "Не удалось проверить сессию платформы");
});
return () => {
@ -186,6 +184,12 @@ export function LauncherApp() {
};
}, []);
useEffect(() => {
if (!authSession || authSession.authenticated) return;
window.location.replace(authSession.loginUrl || "/auth/login");
}, [authSession]);
useEffect(() => {
if (!authSession?.authenticated) return;
@ -247,7 +251,6 @@ export function LauncherApp() {
if (!isMounted) return;
setAuthSession(nextSession);
setAuthError(null);
if (!nextSession.authenticated) {
setAuthApps([]);
@ -473,7 +476,7 @@ export function LauncherApp() {
}
if (!authSession.authenticated) {
return <AuthStateScreen title="Вход на платформу NODE.DC" description="Войдите, чтобы открыть рабочую область и доступы." error={authError} loginUrl={authSession.loginUrl} />;
return null;
}
return (