547 lines
24 KiB
JavaScript
547 lines
24 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 User = require("@saltcorn/data/models/user");
|
|
|
|
const { escapeHtml } = require("./web");
|
|
const { getEnv } = require("./env");
|
|
const { issuerForReq } = require("./oidc/discovery");
|
|
|
|
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)}">`;
|
|
};
|
|
|
|
|
|
const layout = (title, body) => {
|
|
return `<!doctype html>
|
|
<html lang="en"><head>
|
|
<meta charset="utf-8">
|
|
<title>${escapeHtml(title)}</title>
|
|
<style>
|
|
body { font-family: system-ui, -apple-system, sans-serif; margin: 1.5rem; max-width: 900px; }
|
|
h1 { font-size: 1.4rem; }
|
|
h2 { font-size: 1.1rem; margin-top: 1.5rem; }
|
|
table { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
|
|
th, td { border: 1px solid #ddd; padding: 0.35rem 0.6rem; text-align: left; vertical-align: top; }
|
|
th { background: #f5f5f5; }
|
|
code { font-family: ui-monospace, Menlo, Consolas, monospace; }
|
|
.muted { color: #888; }
|
|
nav { margin-bottom: 1rem; }
|
|
nav a { margin-right: 1rem; }
|
|
form.inline { display: inline; }
|
|
input[type=text] { padding: 0.2rem; }
|
|
button { padding: 0.2rem 0.5rem; cursor: pointer; }
|
|
</style>
|
|
</head><body>
|
|
<nav>
|
|
<a href="${escapeHtml(constants.ADMIN_BASE_PATH)}">Dashboard</a>
|
|
<a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients">Clients</a>
|
|
<a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups">Groups</a>
|
|
<a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/saml-sps">SAML SPs</a>
|
|
<a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/ldap">LDAP</a>
|
|
</nav>
|
|
${body}
|
|
<p class="muted">${escapeHtml(constants.PLUGIN_NAME)} v${escapeHtml(constants.PLUGIN_VERSION)}</p>
|
|
</body></html>`;
|
|
};
|
|
|
|
|
|
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 body = `
|
|
<table>
|
|
<tr><th>Env ID</th><td><code>${escapeHtml(env ? env.env_id : "?")}</code></td></tr>
|
|
<tr><th>Bootstrapped at</th><td>${escapeHtml(env ? env.bootstrapped_at : "")}</td></tr>
|
|
<tr><th>Issuer</th><td><code>${escapeHtml(issuer)}</code></td></tr>
|
|
<tr><th>Active signing key (kid)</th><td><code>${escapeHtml(active ? active.kid : "(none)")}</code></td></tr>
|
|
<tr><th>Active key status</th><td>${escapeHtml(active ? constants.KEY_STATUS.ACTIVE : "(none)")}</td></tr>
|
|
<tr><th>Active key created at</th><td>${escapeHtml(active ? active.created_at : "")}</td></tr>
|
|
<tr><th>Signing alg</th><td>${escapeHtml(active ? active.alg : "")}</td></tr>
|
|
<tr><th>Published JWKS keys</th><td>${escapeHtml(jwks.keys.length)}</td></tr>
|
|
<tr><th>Discovery</th><td><a href="${escapeHtml(constants.WELL_KNOWN_OPENID)}">${escapeHtml(constants.WELL_KNOWN_OPENID)}</a></td></tr>
|
|
<tr><th>JWKS</th><td><a href="${escapeHtml(constants.JWKS_PATH)}">${escapeHtml(constants.JWKS_PATH)}</a></td></tr>
|
|
</table>
|
|
<h2>Signing key rotation</h2>
|
|
<p class="muted">Generates a new active signing key (new kid). The previous key keeps verifying issued tokens (stays in JWKS as <code>retiring</code>) until its grace window elapses, then drops out.</p>
|
|
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/rotate-key">${csrfField(req)}<button>rotate signing key</button></form>`;
|
|
res.type("text/html").send(layout("saltcorn-idp dashboard", body));
|
|
} 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();
|
|
let rows = "";
|
|
for (const g of all) {
|
|
const members = await groups.membersOf(g.id);
|
|
let memberHtml = "";
|
|
for (const member of members) {
|
|
const u = await User.findOne({ id: member.user_id });
|
|
const label = u ? u.email : ("user#" + member.user_id);
|
|
memberHtml += `<form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/removemember">${csrfField(req)}<input type="hidden" name="group_id" value="${escapeHtml(g.id)}"><input type="hidden" name="user_id" value="${escapeHtml(member.user_id)}"><code>${escapeHtml(label)}</code> <button>x</button></form><br>`;
|
|
}
|
|
rows += `<tr>
|
|
<td><code>${escapeHtml(g.name)}</code></td>
|
|
<td>${memberHtml || '<span class="muted">(none)</span>'}
|
|
<form class="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" name="email" placeholder="user email"> <button>add</button></form>
|
|
</td>
|
|
<td><form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/delete">${csrfField(req)}<input type="hidden" name="id" value="${escapeHtml(g.id)}"><button>delete</button></form></td>
|
|
</tr>`;
|
|
}
|
|
const body = `
|
|
<h1>Groups</h1>
|
|
<p class="muted">The OIDC <code>groups</code> claim = each user's Saltcorn role (as <code>role:<name></code>) plus these custom groups (as <code>group:<name></code>).</p>
|
|
<table><tr><th>Group</th><th>Members</th><th></th></tr>${rows || '<tr><td colspan="3" class="muted">no groups yet</td></tr>'}</table>
|
|
<h2>Create group</h2>
|
|
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/create">${csrfField(req)}
|
|
<input type="text" name="name" placeholder="group name" required> <button>create</button>
|
|
</form>`;
|
|
res.type("text/html").send(layout("saltcorn-idp groups", body));
|
|
} 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) {
|
|
const u = await User.findOne({ email: email });
|
|
if (u) {
|
|
await groups.addMember(groupId, u.id);
|
|
}
|
|
}
|
|
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 userId = parseInt(req.body && req.body.user_id, 10);
|
|
if (Number.isFinite(groupId) && Number.isFinite(userId)) {
|
|
await groups.removeMember(groupId, userId);
|
|
}
|
|
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();
|
|
let rows = "";
|
|
for (const c of all) {
|
|
const uris = JSON.parse(c.redirect_uris).map((u) => `<code>${escapeHtml(u)}</code>`).join("<br>");
|
|
rows += `<tr>
|
|
<td><code>${escapeHtml(c.client_id)}</code></td>
|
|
<td>${escapeHtml(c.label || "")}</td>
|
|
<td>${escapeHtml(c.token_auth_method)}</td>
|
|
<td>${uris}</td>
|
|
<td>${escapeHtml(c.scope || "")}</td>
|
|
<td><form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients/delete">${csrfField(req)}<input type="hidden" name="client_id" value="${escapeHtml(c.client_id)}"><button>delete</button></form></td>
|
|
</tr>`;
|
|
}
|
|
const body = `
|
|
<h1>Clients (relying parties)</h1>
|
|
<table><tr><th>client_id</th><th>label</th><th>auth</th><th>redirect URIs</th><th>scope</th><th></th></tr>${rows || '<tr><td colspan="6" class="muted">no clients yet</td></tr>'}</table>
|
|
<h2>Register client</h2>
|
|
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients/create">${csrfField(req)}
|
|
<p>client_id <input type="text" name="client_id" required></p>
|
|
<p>label <input type="text" name="label"></p>
|
|
<p>redirect URIs (one per line)<br><textarea name="redirect_uris" rows="3" cols="50"></textarea></p>
|
|
<p>auth method
|
|
<select name="auth_method">
|
|
<option value="none">none (public + PKCE)</option>
|
|
<option value="client_secret_basic">client_secret_basic (confidential)</option>
|
|
<option value="client_secret_post">client_secret_post (confidential)</option>
|
|
</select>
|
|
</p>
|
|
<p>scope <input type="text" name="scope" value="openid email profile groups"></p>
|
|
<button>register</button>
|
|
</form>`;
|
|
res.type("text/html").send(layout("saltcorn-idp clients", body));
|
|
} 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) {
|
|
const body = `
|
|
<h1>Client registered</h1>
|
|
<p>client_id: <code>${escapeHtml(created.client_id)}</code></p>
|
|
<p>Client secret (shown once - copy it now):</p>
|
|
<p><code>${escapeHtml(created.secret)}</code></p>
|
|
<p><a href="${escapeHtml(constants.ADMIN_BASE_PATH)}/clients">Back to clients</a></p>`;
|
|
res.type("text/html").send(layout("client secret", body));
|
|
} 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();
|
|
let rows = "";
|
|
for (const s of all) {
|
|
const urls = samlSps.acsUrls(s).map((u) => `<code>${escapeHtml(u)}</code>`).join("<br>");
|
|
rows += `<tr>
|
|
<td><code>${escapeHtml(s.entity_id)}</code></td>
|
|
<td>${escapeHtml(s.label || "")}</td>
|
|
<td>${urls}</td>
|
|
<td>${s.want_authn_requests_signed ? "yes" : "no"}</td>
|
|
<td>${s.signing_cert ? "yes" : "no"}</td>
|
|
<td><form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/saml-sps/delete">${csrfField(req)}<input type="hidden" name="entity_id" value="${escapeHtml(s.entity_id)}"><button>delete</button></form></td>
|
|
</tr>`;
|
|
}
|
|
const body = `
|
|
<h1>SAML service providers</h1>
|
|
<p class="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.</p>
|
|
<table><tr><th>entityID</th><th>label</th><th>ACS URLs</th><th>req signed</th><th>cert</th><th></th></tr>${rows || '<tr><td colspan="6" class="muted">no SPs yet</td></tr>'}</table>
|
|
<h2>Register SP</h2>
|
|
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/saml-sps/create">${csrfField(req)}
|
|
<p>entityID <input type="text" name="entity_id" required></p>
|
|
<p>label <input type="text" name="label"></p>
|
|
<p>ACS URLs (one per line)<br><textarea name="acs_urls" rows="3" cols="60"></textarea></p>
|
|
<p>signing cert (PEM, optional)<br><textarea name="signing_cert" rows="4" cols="60"></textarea></p>
|
|
<p><label><input type="checkbox" name="want_signed" value="1"> require signed AuthnRequests</label></p>
|
|
<button>register</button>
|
|
</form>`;
|
|
res.type("text/html").send(layout("saltcorn-idp saml sps", body));
|
|
} 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");
|
|
};
|
|
|
|
|
|
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 body = `
|
|
<h1>LDAP service account</h1>
|
|
<p class="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>
|
|
<table><tr><th>Configured service DN</th><td>${dn ? `<code>${escapeHtml(dn)}</code>` : '<span class="muted">(none)</span>'}</td></tr></table>
|
|
<h2>Set service account</h2>
|
|
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/ldap/service">${csrfField(req)}
|
|
<p>service DN <input type="text" name="dn" placeholder="cn=svc,ou=people,dc=saltcorn,dc=local" size="55"></p>
|
|
<p>password <input type="password" name="password"></p>
|
|
<button>save</button>
|
|
</form>
|
|
<h2>Clear</h2>
|
|
<form method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/ldap/service/clear">${csrfField(req)}<button>clear service account</button></form>`;
|
|
res.type("text/html").send(layout("saltcorn-idp ldap", body));
|
|
} 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 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 }
|
|
];
|
|
|
|
|
|
module.exports = {
|
|
adminRoutes,
|
|
isAdmin,
|
|
escapeHtml
|
|
};
|