675 lines
31 KiB
JavaScript
675 lines
31 KiB
JavaScript
// Admin UI for saltcorn-idp: a read-only dashboard plus group management.
|
|
// All pages are admin-gated (role_id 1) and live under /admin/idp (CSRF-protected
|
|
// browser forms — note these are NOT under the CSRF-exempt /idp namespace).
|
|
|
|
const crypto = require("crypto");
|
|
|
|
const constants = require("./constants");
|
|
const keys = require("./keys");
|
|
const groups = require("./groups");
|
|
const clients = require("./clients");
|
|
const samlSps = require("./saml/sps");
|
|
const serviceAccount = require("./ldap/serviceAccount");
|
|
const ldapSettings = require("./ldap/settings");
|
|
const db = require("@saltcorn/data/db");
|
|
const User = require("@saltcorn/data/models/user");
|
|
|
|
const { escapeHtml } = require("./web");
|
|
const { getEnv } = require("./env");
|
|
const { issuerForReq } = require("./oidc/discovery");
|
|
|
|
// Saltcorn native markup primitives -- the same ones core admin pages use, so
|
|
// these pages inherit the active theme by construction.
|
|
const { mkTable, post_btn, renderForm, link, alert, tags } = require("@saltcorn/markup");
|
|
const { p, code, pre, div, span, strong } = tags;
|
|
const Form = require("@saltcorn/data/models/form");
|
|
|
|
const ADMIN_ROLE_ID = 1;
|
|
|
|
|
|
const isAdmin = (req) => {
|
|
return !!(req && req.user && req.user.role_id === ADMIN_ROLE_ID);
|
|
};
|
|
|
|
|
|
// URL-scheme guards for admin-supplied URLs. A SAML ACS is always an http(s)
|
|
// web endpoint, so require that (a "javascript:" ACS would otherwise become a
|
|
// form action). OIDC redirect_uris may use native/custom schemes, so for those
|
|
// only reject the dangerous executable schemes.
|
|
const DANGEROUS_SCHEME = /^\s*(javascript|data|vbscript|file)\s*:/i;
|
|
|
|
const isHttpUrl = (u) => {
|
|
try {
|
|
const p = new URL(String(u)).protocol;
|
|
return p === "http:" || p === "https:";
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// True if a PEM X.509 cert parses and is currently within its validity window.
|
|
// A null/empty cert is "ok" here (means "no cert supplied"); callers decide.
|
|
const certCurrentlyValid = (pem) => {
|
|
if (!pem) {
|
|
return true;
|
|
}
|
|
try {
|
|
const x = new crypto.X509Certificate(pem);
|
|
const now = Date.now();
|
|
const nb = Date.parse(x.validFrom);
|
|
const na = Date.parse(x.validTo);
|
|
return Number.isFinite(nb) && Number.isFinite(na) && now >= nb && now <= na;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
|
|
// Lightweight per-session rate limit on admin MUTATIONS (defence-in-depth on top
|
|
// of the role gate). In-memory sliding window keyed by session id (falls back to
|
|
// IP). Generous enough never to trip normal admin use or the gates.
|
|
const adminHits = new Map();
|
|
|
|
const adminRateOk = (req) => {
|
|
const key = (req.sessionID || (req.session && req.session.id) || (req.connection && req.connection.remoteAddress) || "anon");
|
|
const now = Date.now();
|
|
const window = constants.ADMIN_RATE_WINDOW_MS;
|
|
const hits = (adminHits.get(key) || []).filter((t) => now - t < window);
|
|
if (hits.length >= constants.ADMIN_RATE_MAX) {
|
|
adminHits.set(key, hits);
|
|
return false;
|
|
}
|
|
hits.push(now);
|
|
adminHits.set(key, hits);
|
|
return true;
|
|
};
|
|
|
|
|
|
const csrfField = (req) => {
|
|
const token = req.csrfToken ? req.csrfToken() : "";
|
|
return `<input type="hidden" name="_csrf" value="${escapeHtml(token)}">`;
|
|
};
|
|
|
|
|
|
// Bootstrap nav-pills with the current page marked active. The active link is the
|
|
// one whose href is the LONGEST boundary-safe prefix of the request path, so a
|
|
// sub-page (e.g. /clients/<id>/secret) still highlights its parent tab.
|
|
const navPills = (req, links) => {
|
|
const cur = String((req && (req.originalUrl || req.path)) || "").split("?")[0].replace(/\/+$/, "");
|
|
let activeHref = "";
|
|
for (const [href] of links) {
|
|
const h = href.replace(/\/+$/, "");
|
|
if ((cur === h || cur.startsWith(h + "/")) && h.length > activeHref.length) {
|
|
activeHref = h;
|
|
}
|
|
}
|
|
const items = links.map(([href, label]) => {
|
|
const on = href.replace(/\/+$/, "") === activeHref;
|
|
return ` <li class="nav-item"><a class="nav-link${on ? " active" : ""}"${on ? ' aria-current="page"' : ""} href="${escapeHtml(href)}">${escapeHtml(label)}</a></li>`;
|
|
}).join("\n");
|
|
return `<ul class="nav nav-pills mb-3">\n${items}\n</ul>`;
|
|
};
|
|
|
|
|
|
const IDP_NAV = [
|
|
[constants.ADMIN_BASE_PATH, "Dashboard"],
|
|
[constants.ADMIN_BASE_PATH + "/clients", "Clients"],
|
|
[constants.ADMIN_BASE_PATH + "/groups", "Groups"],
|
|
[constants.ADMIN_BASE_PATH + "/saml-sps", "SAML SPs"],
|
|
[constants.ADMIN_BASE_PATH + "/ldap", "LDAP"],
|
|
];
|
|
|
|
|
|
// Render an admin page in the ACTIVE Saltcorn theme: breadcrumbs + sub-nav +
|
|
// content segments (cards or strings), via res.sendWrap. The one rendering path;
|
|
// pages build content with mkTable / renderForm / post_btn (no raw page HTML).
|
|
const adminPage = (req, res, title, ...segments) => {
|
|
const above = [
|
|
{ type: "breadcrumbs", crumbs: [{ text: "Settings", href: "/settings" }, { text: "saltcorn-idp" }, { text: title }] },
|
|
navPills(req, IDP_NAV),
|
|
];
|
|
res.sendWrap(`saltcorn-idp ${title}`, { above: above.concat(segments) });
|
|
};
|
|
|
|
|
|
const dashboard = async (req, res) => {
|
|
if (!isAdmin(req)) {
|
|
res.status(403).type("text/plain").send("admin only");
|
|
return;
|
|
}
|
|
try {
|
|
// Opportunistically sweep RETIRING keys whose grace window has elapsed to
|
|
// RETIRED (drops them from JWKS) so admins don't need a separate cron.
|
|
await keys.retireExpiredKeys();
|
|
const env = await getEnv();
|
|
const jwks = await keys.getJwks();
|
|
const active = await keys.getActiveKeyMeta();
|
|
const issuer = issuerForReq(req);
|
|
const kvRows = [
|
|
{ k: "Env ID", v: code(escapeHtml(env ? env.env_id : "?")) },
|
|
{ k: "Bootstrapped at", v: escapeHtml(env ? env.bootstrapped_at : "") },
|
|
{ k: "Issuer", v: code(escapeHtml(issuer)) },
|
|
{ k: "Active signing key (kid)", v: code(escapeHtml(active ? active.kid : "(none)")) },
|
|
{ k: "Active key status", v: escapeHtml(active ? constants.KEY_STATUS.ACTIVE : "(none)") },
|
|
{ k: "Active key created at", v: escapeHtml(active ? active.created_at : "") },
|
|
{ k: "Signing alg", v: escapeHtml(active ? active.alg : "") },
|
|
{ k: "Published JWKS keys", v: escapeHtml(jwks.keys.length) },
|
|
{ k: "Discovery", v: link(constants.WELL_KNOWN_OPENID, constants.WELL_KNOWN_OPENID) },
|
|
{ k: "JWKS", v: link(constants.JWKS_PATH, constants.JWKS_PATH) },
|
|
];
|
|
const kv = mkTable([{ label: "Setting", key: "k" }, { label: "Value", key: (r) => r.v }], kvRows);
|
|
const rotate = p({ class: "text-muted" }, "Generates a new active signing key (new kid). The previous key keeps verifying issued tokens (stays in JWKS as ", code("retiring"), ") until its grace window elapses, then drops out.") +
|
|
post_btn(constants.ADMIN_BASE_PATH + "/rotate-key", "Rotate signing key", req.csrfToken(), { req, btnClass: "btn-warning", confirm: true });
|
|
adminPage(req, res, "dashboard",
|
|
{ type: "card", title: "Identity provider", contents: kv },
|
|
{ type: "card", title: "Signing key rotation", contents: rotate }
|
|
);
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[${constants.PLUGIN_NAME}] dashboard failed:`, e);
|
|
res.status(500).type("text/plain").send("dashboard unavailable");
|
|
}
|
|
};
|
|
|
|
|
|
const rotateKeyHandler = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
try {
|
|
await keys.rotateActiveKey();
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[${constants.PLUGIN_NAME}] key rotation failed:`, e);
|
|
}
|
|
res.redirect(constants.ADMIN_BASE_PATH);
|
|
};
|
|
|
|
|
|
const groupsPage = async (req, res) => {
|
|
if (!isAdmin(req)) {
|
|
res.status(403).type("text/plain").send("admin only");
|
|
return;
|
|
}
|
|
try {
|
|
const all = await groups.listGroups();
|
|
const groupRows = [];
|
|
for (const g of all) {
|
|
const members = await groups.membersOf(g.id);
|
|
const memberList = members.map((member) =>
|
|
span({ class: "d-inline-block me-2 mb-1" },
|
|
code(escapeHtml(member.user_email)) + " " +
|
|
post_btn(constants.ADMIN_BASE_PATH + "/groups/removemember", "x", req.csrfToken(), { req, btnClass: "btn-outline-danger", small: true, formClass: "d-inline", body: { group_id: g.id, user_email: member.user_email } }))
|
|
).join("");
|
|
const addMember = `<form class="d-inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/addmember">${csrfField(req)}<input type="hidden" name="group_id" value="${escapeHtml(g.id)}"><input type="text" class="form-control form-control-sm d-inline-block w-auto me-1" name="email" placeholder="user email"><button class="btn btn-primary btn-sm">add</button></form>`;
|
|
groupRows.push({
|
|
name: code(escapeHtml(g.name)),
|
|
members: (memberList || span({ class: "text-muted" }, "(none)")) + "<br>" + addMember,
|
|
actions: post_btn(constants.ADMIN_BASE_PATH + "/groups/delete", "delete", req.csrfToken(), { req, btnClass: "btn-danger", small: true, body: { id: g.id }, confirm: true }),
|
|
});
|
|
}
|
|
const groupsTable = groupRows.length === 0
|
|
? p({ class: "text-muted" }, "no groups yet")
|
|
: mkTable([{ label: "Group", key: (r) => r.name }, { label: "Members", key: (r) => r.members }, { label: "", key: (r) => r.actions }], groupRows);
|
|
const createForm = new Form({ action: constants.ADMIN_BASE_PATH + "/groups/create", submitLabel: "Create", fields: [{ name: "name", label: "Group name", type: "String", required: true }] });
|
|
adminPage(req, res, "groups",
|
|
{ type: "card", title: "Groups", contents:
|
|
p({ class: "text-muted" }, "The OIDC ", code("groups"), " claim = each user's Saltcorn role (as ", code("role:<name>"), ") plus these custom groups (as ", code("group:<name>"), ").") + groupsTable },
|
|
{ type: "card", title: "Create group", contents: renderForm(createForm, req.csrfToken()) }
|
|
);
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[${constants.PLUGIN_NAME}] groups page failed:`, e);
|
|
res.status(500).type("text/plain").send("groups unavailable");
|
|
}
|
|
};
|
|
|
|
|
|
const requireAdmin = (req, res) => {
|
|
if (!isAdmin(req)) {
|
|
res.status(403).type("text/plain").send("admin only");
|
|
return false;
|
|
}
|
|
// requireAdmin gates the mutating handlers; throttle POSTs per session.
|
|
if (req.method === "POST" && !adminRateOk(req)) {
|
|
res.status(429).type("text/plain").send("too many requests");
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
|
|
const createGroupHandler = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
const name = String((req.body && req.body.name) || "").trim();
|
|
if (name) {
|
|
try {
|
|
await groups.createGroup(name);
|
|
} catch (e) {
|
|
// unique-name violation or similar; ignore and re-render
|
|
}
|
|
}
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/groups");
|
|
};
|
|
|
|
|
|
const deleteGroupHandler = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
const id = parseInt(req.body && req.body.id, 10);
|
|
if (Number.isFinite(id)) {
|
|
await groups.deleteGroup(id);
|
|
}
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/groups");
|
|
};
|
|
|
|
|
|
const addMemberHandler = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
const groupId = parseInt(req.body && req.body.group_id, 10);
|
|
const email = String((req.body && req.body.email) || "").trim();
|
|
if (Number.isFinite(groupId) && email) {
|
|
// Validate the user exists and store its CANONICAL email (matches how
|
|
// users.email is stored), so the membership key stays consistent.
|
|
const u = await User.findOne({ email: email });
|
|
if (u) {
|
|
await groups.addMember(groupId, u.email);
|
|
}
|
|
}
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/groups");
|
|
};
|
|
|
|
|
|
const removeMemberHandler = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
const groupId = parseInt(req.body && req.body.group_id, 10);
|
|
const email = String((req.body && req.body.user_email) || "").trim();
|
|
if (Number.isFinite(groupId) && email) {
|
|
await groups.removeMember(groupId, email);
|
|
}
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/groups");
|
|
};
|
|
|
|
|
|
const clientsPage = async (req, res) => {
|
|
if (!isAdmin(req)) {
|
|
res.status(403).type("text/plain").send("admin only");
|
|
return;
|
|
}
|
|
try {
|
|
const all = await clients.listClients();
|
|
const clientsTable = all.length === 0
|
|
? p({ class: "text-muted" }, "no clients yet")
|
|
: mkTable([
|
|
{ label: "client_id", key: (c) => code(escapeHtml(c.client_id)) },
|
|
{ label: "label", key: (c) => escapeHtml(c.label || "") },
|
|
{ label: "auth", key: (c) => escapeHtml(c.token_auth_method) },
|
|
{ label: "redirect URIs", key: (c) => JSON.parse(c.redirect_uris).map((u) => code(escapeHtml(u))).join("<br>") },
|
|
{ label: "scope", key: (c) => escapeHtml(c.scope || "") },
|
|
{ label: "", key: (c) => post_btn(constants.ADMIN_BASE_PATH + "/clients/delete", "delete", req.csrfToken(), { req, btnClass: "btn-danger", small: true, body: { client_id: c.client_id }, confirm: true }) },
|
|
], all);
|
|
const regForm = new Form({
|
|
action: constants.ADMIN_BASE_PATH + "/clients/create",
|
|
submitLabel: "Register",
|
|
values: { auth_method: "none", scope: "openid email profile groups" },
|
|
fields: [
|
|
{ name: "client_id", label: "client_id", type: "String", required: true },
|
|
{ name: "label", label: "label", type: "String" },
|
|
{ name: "redirect_uris", label: "redirect URIs (one per line)", type: "String", fieldview: "textarea" },
|
|
{ name: "auth_method", label: "auth method", input_type: "select", options: [
|
|
{ label: "none (public + PKCE)", value: "none" },
|
|
{ label: "client_secret_basic (confidential)", value: "client_secret_basic" },
|
|
{ label: "client_secret_post (confidential)", value: "client_secret_post" },
|
|
] },
|
|
{ name: "scope", label: "scope", type: "String" },
|
|
],
|
|
});
|
|
adminPage(req, res, "clients",
|
|
{ type: "card", title: "Clients (relying parties)", contents: clientsTable },
|
|
{ type: "card", title: "Register client", contents: renderForm(regForm, req.csrfToken()) }
|
|
);
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[${constants.PLUGIN_NAME}] clients page failed:`, e);
|
|
res.status(500).type("text/plain").send("clients unavailable");
|
|
}
|
|
};
|
|
|
|
|
|
const parseUris = (text) => {
|
|
return String(text || "").split(/[\r\n]+/).map((s) => s.trim()).filter(Boolean);
|
|
};
|
|
|
|
|
|
const createClientHandler = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
const clientId = String((req.body && req.body.client_id) || "").trim();
|
|
const redirectUris = parseUris(req.body && req.body.redirect_uris);
|
|
// Reject executable-scheme redirect URIs (javascript:/data:/...). Custom
|
|
// native-app schemes + loopback are allowed (oidc-provider validates the rest).
|
|
if (!clientId || redirectUris.some((u) => DANGEROUS_SCHEME.test(u))) {
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/clients");
|
|
return;
|
|
}
|
|
let created = null;
|
|
try {
|
|
created = await clients.createClient({
|
|
clientId: clientId,
|
|
label: String((req.body && req.body.label) || "").trim(),
|
|
redirectUris: redirectUris,
|
|
authMethod: String((req.body && req.body.auth_method) || "none"),
|
|
scope: String((req.body && req.body.scope) || "").trim()
|
|
});
|
|
} catch (e) {
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/clients");
|
|
return;
|
|
}
|
|
if (created && created.secret) {
|
|
adminPage(req, res, "client secret",
|
|
{ type: "card", title: "Client registered", contents:
|
|
p("client_id: ", code(escapeHtml(created.client_id))) +
|
|
p("Client secret (shown once - copy it now):") +
|
|
pre({ class: "user-select-all p-2 bg-light text-dark border rounded", style: "white-space:pre-wrap;word-break:break-all" }, escapeHtml(created.secret)) +
|
|
p(link(constants.ADMIN_BASE_PATH + "/clients", "Back to clients")) }
|
|
);
|
|
} else {
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/clients");
|
|
}
|
|
};
|
|
|
|
|
|
const deleteClientHandler = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
const clientId = String((req.body && req.body.client_id) || "").trim();
|
|
if (clientId) {
|
|
await clients.deleteClient(clientId);
|
|
}
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/clients");
|
|
};
|
|
|
|
|
|
const samlSpsPage = async (req, res) => {
|
|
if (!isAdmin(req)) {
|
|
res.status(403).type("text/plain").send("admin only");
|
|
return;
|
|
}
|
|
try {
|
|
const all = await samlSps.listSps();
|
|
const spsTable = all.length === 0
|
|
? p({ class: "text-muted" }, "no SPs yet")
|
|
: mkTable([
|
|
{ label: "entityID", key: (s) => code(escapeHtml(s.entity_id)) },
|
|
{ label: "label", key: (s) => escapeHtml(s.label || "") },
|
|
{ label: "ACS URLs", key: (s) => samlSps.acsUrls(s).map((u) => code(escapeHtml(u))).join("<br>") },
|
|
{ label: "req signed", key: (s) => s.want_authn_requests_signed ? "yes" : "no" },
|
|
{ label: "cert", key: (s) => s.signing_cert ? "yes" : "no" },
|
|
{ label: "", key: (s) => post_btn(constants.ADMIN_BASE_PATH + "/saml-sps/delete", "delete", req.csrfToken(), { req, btnClass: "btn-danger", small: true, body: { entity_id: s.entity_id }, confirm: true }) },
|
|
], all);
|
|
const regForm = new Form({
|
|
action: constants.ADMIN_BASE_PATH + "/saml-sps/create",
|
|
submitLabel: "Register",
|
|
fields: [
|
|
{ name: "entity_id", label: "entityID", type: "String", required: true },
|
|
{ name: "label", label: "label", type: "String" },
|
|
{ name: "acs_urls", label: "ACS URLs (one per line)", type: "String", fieldview: "textarea" },
|
|
{ name: "signing_cert", label: "signing cert (PEM, optional)", type: "String", fieldview: "textarea" },
|
|
{ name: "want_signed", label: "require signed AuthnRequests", type: "Bool" },
|
|
],
|
|
});
|
|
adminPage(req, res, "saml sps",
|
|
{ type: "card", title: "SAML service providers", contents:
|
|
p({ class: "text-muted" }, "Only registered SPs receive assertions, and only at an allow-listed ACS URL. A signing cert enables (and \"require signed\" enforces) AuthnRequest signature verification.") + spsTable },
|
|
{ type: "card", title: "Register SP", contents: renderForm(regForm, req.csrfToken()) }
|
|
);
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[${constants.PLUGIN_NAME}] saml sps page failed:`, e);
|
|
res.status(500).type("text/plain").send("saml sps unavailable");
|
|
}
|
|
};
|
|
|
|
|
|
const createSamlSpHandler = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
const entityId = String((req.body && req.body.entity_id) || "").trim();
|
|
const acsUrls = parseUris(req.body && req.body.acs_urls);
|
|
// An SP with no ACS URL is unusable and a hazard: the SSO/SLO handlers would
|
|
// have nothing to validate a request-supplied ACS against. Reject it here so
|
|
// a no-ACS SP never reaches the registry (defence-in-depth over the request
|
|
// handlers, which also reject an empty allow-list).
|
|
const signingCert = String((req.body && req.body.signing_cert) || "").trim() || null;
|
|
// Reject: no entityId, no/empty ACS list, any ACS that is not an http(s) URL
|
|
// (a SAML ACS is always a web endpoint; this blocks a "javascript:" ACS from
|
|
// becoming the auto-POST form action), or an expired/unparseable signing cert.
|
|
if (!entityId || acsUrls.length === 0 || !acsUrls.every(isHttpUrl) || !certCurrentlyValid(signingCert)) {
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/saml-sps");
|
|
return;
|
|
}
|
|
try {
|
|
await samlSps.createSp({
|
|
entityId: entityId,
|
|
label: String((req.body && req.body.label) || "").trim(),
|
|
acsUrls: acsUrls,
|
|
signingCert: signingCert,
|
|
wantSigned: !!(req.body && req.body.want_signed)
|
|
});
|
|
} catch (e) {
|
|
// duplicate entity_id or similar; fall through to re-render
|
|
}
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/saml-sps");
|
|
};
|
|
|
|
|
|
const deleteSamlSpHandler = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
const entityId = String((req.body && req.body.entity_id) || "").trim();
|
|
if (entityId) {
|
|
await samlSps.deleteSp(entityId);
|
|
}
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/saml-sps");
|
|
};
|
|
|
|
|
|
// The LDAP listener is process-global (one per instance), so its host/port/enable
|
|
// settings are editable only on the root/public site; tenant admins see them
|
|
// read-only. isRootTenant() is true on a single-tenant (SQLite) deployment.
|
|
const isRootTenant = () => {
|
|
if (!db.is_it_multi_tenant || !db.is_it_multi_tenant()) {
|
|
return true;
|
|
}
|
|
const def = String((db.connectObj && db.connectObj.default_schema) || "public").toLowerCase();
|
|
const cur = String((db.getTenantSchema && db.getTenantSchema()) || "").toLowerCase();
|
|
return cur === def;
|
|
};
|
|
|
|
|
|
// Whether the running listener (applied) differs from the resolved desired
|
|
// settings (runtime) -- i.e. a restart is needed for an edit to take effect.
|
|
const ldapAppliedDiffers = (applied, runtime) => {
|
|
const a = applied || { enabled: false };
|
|
if (!a.enabled && !runtime.enabled) {
|
|
return false;
|
|
}
|
|
if (!!a.enabled !== !!runtime.enabled) {
|
|
return true;
|
|
}
|
|
return a.host !== runtime.host || a.port !== runtime.port;
|
|
};
|
|
|
|
|
|
const LDAP_SETTINGS_ERRORS = {
|
|
port: "Port must be an integer between 1 and 65535.",
|
|
host: "Bind host contains invalid characters.",
|
|
noport: "Enabling the LDAP listener requires a valid port."
|
|
};
|
|
|
|
|
|
const ldapListenerSection = async (req) => {
|
|
const runtime = await ldapSettings.resolveRuntime();
|
|
const applied = await ldapSettings.getApplied();
|
|
const effective = runtime.enabled ? `${escapeHtml(runtime.host)}:${escapeHtml(String(runtime.port))}` : "disabled";
|
|
const running = (applied && applied.enabled) ? `${escapeHtml(applied.host)}:${escapeHtml(String(applied.port))}` : "disabled";
|
|
const statusTable = `<table class="table table-sm table-bordered">
|
|
<tr><th>Currently running</th><td>${running}</td></tr>
|
|
<tr><th>Effective after restart</th><td>${effective}</td></tr>
|
|
</table>`;
|
|
if (!isRootTenant()) {
|
|
return `<h2>LDAP listener</h2>
|
|
<p class="text-muted">One LDAPS listener serves all tenants on this Saltcorn instance; it is configured on the <strong>public site</strong> (read-only here).</p>
|
|
${statusTable}`;
|
|
}
|
|
const stored = await ldapSettings.getStoredConfig();
|
|
const errCode = String((req.query && req.query.err) || "");
|
|
const errBanner = (errCode && LDAP_SETTINGS_ERRORS[errCode])
|
|
? `<p style="color:#b00"><strong>${escapeHtml(LDAP_SETTINGS_ERRORS[errCode])}</strong></p>` : "";
|
|
const restartBanner = ldapAppliedDiffers(applied, runtime)
|
|
? `<p style="color:#b00"><strong>⚠ LDAP settings changed — restart Saltcorn for them to take effect.</strong></p>` : "";
|
|
const envNote = (runtime.portFromEnv || runtime.hostFromEnv)
|
|
? `<p class="text-muted">An LDAP environment variable (${escapeHtml(constants.LDAP_PORT_ENV)} / ${escapeHtml(constants.LDAP_HOST_ENV)}) is set and currently OVERRIDES the values below until it is removed.</p>` : "";
|
|
const exposureWarn = (stored.host && !ldapSettings.isLoopbackHost(stored.host))
|
|
? `<p style="color:#b00">Warning: bind host <code>${escapeHtml(stored.host)}</code> is beyond loopback — the listener will be reachable from the network. Ensure this is intended and firewalled.</p>` : "";
|
|
return `<h2>LDAP listener (this server)</h2>
|
|
<p class="text-muted">One LDAPS listener serves all tenants on this Saltcorn instance. Changes apply on the next Saltcorn restart.</p>
|
|
${errBanner}${restartBanner}${envNote}
|
|
${statusTable}
|
|
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/ldap/settings">${csrfField(req)}
|
|
<p><label><input type="checkbox" class="form-check-input me-1" name="enabled" value="on" ${stored.enabled ? "checked" : ""}> Enable LDAP listener</label></p>
|
|
<p>Bind host <input type="text" class="form-control form-control-sm d-inline-block w-auto" name="host" value="${escapeHtml(stored.host || "")}" placeholder="${escapeHtml(constants.LDAP_DEFAULT_HOST)}" size="22"></p>
|
|
<p>Port <input type="number" class="form-control form-control-sm d-inline-block w-auto" name="port" value="${stored.port !== null ? escapeHtml(String(stored.port)) : ""}" min="${escapeHtml(String(constants.LDAP_PORT_MIN))}" max="${escapeHtml(String(constants.LDAP_PORT_MAX))}"></p>
|
|
${exposureWarn}
|
|
<button class="btn btn-primary btn-sm">save listener settings</button>
|
|
</form>`;
|
|
};
|
|
|
|
|
|
const ldapServicePage = async (req, res) => {
|
|
if (!isAdmin(req)) {
|
|
res.status(403).type("text/plain").send("admin only");
|
|
return;
|
|
}
|
|
try {
|
|
const dn = await serviceAccount.getServiceDn();
|
|
const listener = await ldapListenerSection(req);
|
|
const setForm = new Form({
|
|
action: constants.ADMIN_BASE_PATH + "/ldap/service",
|
|
submitLabel: "Save",
|
|
fields: [
|
|
{ name: "dn", label: "service DN", type: "String", attributes: { placeholder: "cn=svc,ou=people,dc=saltcorn,dc=local" } },
|
|
{ name: "password", label: "password", type: "String", input_type: "password" },
|
|
],
|
|
});
|
|
adminPage(req, res, "ldap",
|
|
{ type: "card", title: "LDAP listener", contents: listener },
|
|
{ type: "card", title: "LDAP service account", contents:
|
|
p({ class: "text-muted" }, "A service DN + password for the search-then-bind flow (an application binds as this DN, searches for a user, then re-binds as that user to validate the password). The password is sealed at rest and never displayed.") +
|
|
p("Configured service DN: ", dn ? code(escapeHtml(dn)) : span({ class: "text-muted" }, "(none)")) +
|
|
renderForm(setForm, req.csrfToken()) },
|
|
{ type: "card", title: "Clear", contents:
|
|
post_btn(constants.ADMIN_BASE_PATH + "/ldap/service/clear", "Clear service account", req.csrfToken(), { req, btnClass: "btn-outline-danger", confirm: true }) }
|
|
);
|
|
} catch (e) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[${constants.PLUGIN_NAME}] ldap service page failed:`, e);
|
|
res.status(500).type("text/plain").send("ldap admin unavailable");
|
|
}
|
|
};
|
|
|
|
|
|
const setLdapServiceHandler = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
const dn = String((req.body && req.body.dn) || "").trim();
|
|
const password = String((req.body && req.body.password) || "");
|
|
if (dn && password) {
|
|
await serviceAccount.setServiceAccount(dn, password);
|
|
}
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/ldap");
|
|
};
|
|
|
|
|
|
const clearLdapServiceHandler = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
await serviceAccount.setServiceAccount(null, null);
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/ldap");
|
|
};
|
|
|
|
|
|
const setLdapSettingsHandler = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
// The listener is process-global; only the public/root site may change it.
|
|
if (!isRootTenant()) {
|
|
res.status(403).type("text/plain").send("LDAP listener settings are managed on the public site");
|
|
return;
|
|
}
|
|
const enabled = !!(req.body && (req.body.enabled === "on" || req.body.enabled === "true" || req.body.enabled === true));
|
|
const host = String((req.body && req.body.host) || "").trim();
|
|
const portRaw = String((req.body && req.body.port) || "").trim();
|
|
if (host && !/^[A-Za-z0-9_.:-]+$/.test(host)) {
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/ldap?err=host");
|
|
return;
|
|
}
|
|
let port = null;
|
|
if (portRaw !== "") {
|
|
const n = parseInt(portRaw, 10);
|
|
if (!ldapSettings.validatePort(n)) {
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/ldap?err=port");
|
|
return;
|
|
}
|
|
port = n;
|
|
}
|
|
if (enabled && port === null) {
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/ldap?err=noport");
|
|
return;
|
|
}
|
|
await ldapSettings.setStoredConfig({ enabled: enabled, host: host, port: port });
|
|
res.redirect(constants.ADMIN_BASE_PATH + "/ldap");
|
|
};
|
|
|
|
|
|
const adminRoutes = [
|
|
{ url: constants.ADMIN_BASE_PATH, method: "get", callback: dashboard },
|
|
{ url: constants.ADMIN_BASE_PATH + "/", method: "get", callback: dashboard },
|
|
{ url: constants.ADMIN_BASE_PATH + "/rotate-key", method: "post", callback: rotateKeyHandler },
|
|
{ url: constants.ADMIN_BASE_PATH + "/clients", method: "get", callback: clientsPage },
|
|
{ url: constants.ADMIN_BASE_PATH + "/clients/create", method: "post", callback: createClientHandler },
|
|
{ url: constants.ADMIN_BASE_PATH + "/clients/delete", method: "post", callback: deleteClientHandler },
|
|
{ url: constants.ADMIN_BASE_PATH + "/groups", method: "get", callback: groupsPage },
|
|
{ url: constants.ADMIN_BASE_PATH + "/groups/create", method: "post", callback: createGroupHandler },
|
|
{ url: constants.ADMIN_BASE_PATH + "/groups/delete", method: "post", callback: deleteGroupHandler },
|
|
{ url: constants.ADMIN_BASE_PATH + "/groups/addmember", method: "post", callback: addMemberHandler },
|
|
{ url: constants.ADMIN_BASE_PATH + "/groups/removemember", method: "post", callback: removeMemberHandler },
|
|
{ url: constants.ADMIN_BASE_PATH + "/saml-sps", method: "get", callback: samlSpsPage },
|
|
{ url: constants.ADMIN_BASE_PATH + "/saml-sps/create", method: "post", callback: createSamlSpHandler },
|
|
{ url: constants.ADMIN_BASE_PATH + "/saml-sps/delete", method: "post", callback: deleteSamlSpHandler },
|
|
{ url: constants.ADMIN_BASE_PATH + "/ldap", method: "get", callback: ldapServicePage },
|
|
{ url: constants.ADMIN_BASE_PATH + "/ldap/service", method: "post", callback: setLdapServiceHandler },
|
|
{ url: constants.ADMIN_BASE_PATH + "/ldap/service/clear", method: "post", callback: clearLdapServiceHandler },
|
|
{ url: constants.ADMIN_BASE_PATH + "/ldap/settings", method: "post", callback: setLdapSettingsHandler }
|
|
];
|
|
|
|
|
|
module.exports = {
|
|
adminRoutes,
|
|
isAdmin,
|
|
escapeHtml
|
|
};
|