ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Launcher live access и global logout
This commit is contained in:
parent
b221ccb83e
commit
a1fb2d0f83
|
|
@ -42,7 +42,7 @@
|
||||||
"avatarUrl": "/storage/uploads/1777901476392-03f10a36-2022-10-13-20-52-47-0287-2037248814-scale20.00-k_euler_a-0287.jpg",
|
"avatarUrl": "/storage/uploads/1777901476392-03f10a36-2022-10-13-20-52-47-0287-2037248814-scale20.00-k_euler_a-0287.jpg",
|
||||||
"globalStatus": "active",
|
"globalStatus": "active",
|
||||||
"createdAt": "2026-05-04T00:00:00.000Z",
|
"createdAt": "2026-05-04T00:00:00.000Z",
|
||||||
"updatedAt": "2026-05-04T14:15:10.555Z"
|
"updatedAt": "2026-05-04T15:26:08.500Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"memberships": [
|
"memberships": [
|
||||||
|
|
@ -275,8 +275,8 @@
|
||||||
"targetId": "user_silver_psih",
|
"targetId": "user_silver_psih",
|
||||||
"appRole": "member",
|
"appRole": "member",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"createdAt": "2026-05-04T14:15:10.296Z",
|
"createdAt": "2026-05-04T15:26:07.830Z",
|
||||||
"updatedAt": "2026-05-04T14:15:10.296Z"
|
"updatedAt": "2026-05-04T15:26:07.830Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"exceptions": [],
|
"exceptions": [],
|
||||||
|
|
@ -311,9 +311,9 @@
|
||||||
"objectType": "user",
|
"objectType": "user",
|
||||||
"target": "authentik",
|
"target": "authentik",
|
||||||
"state": "synced",
|
"state": "synced",
|
||||||
"lastSyncAt": "2026-05-04T14:15:10.555Z",
|
"lastSyncAt": "2026-05-04T15:26:08.500Z",
|
||||||
"error": null,
|
"error": null,
|
||||||
"updatedAt": "2026-05-04T14:15:10.555Z"
|
"updatedAt": "2026-05-04T15:26:08.500Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "sync_dctouch_groups_authentik",
|
"id": "sync_dctouch_groups_authentik",
|
||||||
|
|
@ -357,7 +357,7 @@
|
||||||
"state": "pending",
|
"state": "pending",
|
||||||
"lastSyncAt": null,
|
"lastSyncAt": null,
|
||||||
"error": null,
|
"error": null,
|
||||||
"updatedAt": "2026-05-04T14:15:10.297Z"
|
"updatedAt": "2026-05-04T15:26:07.830Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"auditEvents": [
|
"auditEvents": [
|
||||||
|
|
@ -1044,6 +1044,102 @@
|
||||||
"clientId": null,
|
"clientId": null,
|
||||||
"result": "success",
|
"result": "success",
|
||||||
"details": "Groups: nodedc:launcher:user, nodedc:taskmanager:user, service-digital-twin"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { createServer as createHttpServer } from "node:http";
|
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 { existsSync, readFileSync } from "node:fs";
|
||||||
import { mkdir, writeFile } from "node:fs/promises";
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import { dirname, extname, join, resolve } from "node:path";
|
import { dirname, extname, join, resolve } from "node:path";
|
||||||
|
|
@ -44,6 +44,7 @@ app.get("/healthz", (_req, res) => {
|
||||||
service: "nodedc-launcher-bff",
|
service: "nodedc-launcher-bff",
|
||||||
oidcConfigured: config.oidcConfigured,
|
oidcConfigured: config.oidcConfigured,
|
||||||
authentikApiConfigured: authentikSyncClient.isConfigured(),
|
authentikApiConfigured: authentikSyncClient.isConfigured(),
|
||||||
|
internalAccessApiConfigured: Boolean(config.internalAccessToken),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -152,7 +153,7 @@ app.get("/auth/logout", asyncRoute(async (req, res) => {
|
||||||
const endSessionEndpoint = discovery.end_session_endpoint;
|
const endSessionEndpoint = discovery.end_session_endpoint;
|
||||||
|
|
||||||
if (!endSessionEndpoint) {
|
if (!endSessionEndpoint) {
|
||||||
res.redirect(returnTo);
|
res.type("html").send(renderGlobalLogoutPage(getFrontchannelLogoutUrls(), new URL(returnTo, config.appBaseUrl).toString()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,7 +165,7 @@ app.get("/auth/logout", asyncRoute(async (req, res) => {
|
||||||
logoutUrl.searchParams.set("id_token_hint", session.tokenSet.idToken);
|
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) => {
|
app.get("/api/me", (req, res) => {
|
||||||
|
|
@ -182,7 +183,7 @@ app.get("/api/me", (req, res) => {
|
||||||
user: runtimeContext.user,
|
user: runtimeContext.user,
|
||||||
groups: runtimeContext.groups,
|
groups: runtimeContext.groups,
|
||||||
isSuperAdmin: runtimeContext.groups.includes("nodedc:superadmin"),
|
isSuperAdmin: runtimeContext.groups.includes("nodedc:superadmin"),
|
||||||
logoutUrl: "/auth/logout",
|
logoutUrl: "/auth/logout?global=1&returnTo=/",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -232,6 +233,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) => {
|
app.patch("/api/profile", requireSession, asyncRoute(async (req, res) => {
|
||||||
const { actor } = getLauncherProfileContext(req.nodedcSession);
|
const { actor } = getLauncherProfileContext(req.nodedcSession);
|
||||||
const result = await controlPlaneStore.updateUserProfile(actor.id, sanitizeSelfProfilePatch(req.body), req.nodedcSession.user);
|
const result = await controlPlaneStore.updateUserProfile(actor.id, sanitizeSelfProfilePatch(req.body), req.nodedcSession.user);
|
||||||
|
|
@ -506,6 +555,14 @@ function readConfig() {
|
||||||
process.env.AUTHENTIK_SERVICE_TOKEN ??
|
process.env.AUTHENTIK_SERVICE_TOKEN ??
|
||||||
process.env.AUTHENTIK_BOOTSTRAP_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 +888,68 @@ function getServiceUrl(service) {
|
||||||
return service.launchUrl || service.url || "#";
|
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() {
|
function readLauncherData() {
|
||||||
const dataPath = join(projectRoot, "public", "storage", "launcher-data.json");
|
const dataPath = join(projectRoot, "public", "storage", "launcher-data.json");
|
||||||
|
|
||||||
|
|
@ -1000,6 +1119,47 @@ function requireSession(req, res, next) {
|
||||||
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) {
|
function getLauncherProfileContext(session) {
|
||||||
const snapshot = controlPlaneStore.getSnapshot(session.user);
|
const snapshot = controlPlaneStore.getSnapshot(session.user);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue