sc-idp/lib/ldap/bind.js
2026-06-01 16:40:54 -05:00

91 lines
3.1 KiB
JavaScript

// LDAP simple-bind handler. Verifies the typed password against the user's
// stored bcrypt hash via Saltcorn's User.checkPassword (never stores/echoes
// plaintext), or against the configured service account (constant-time compare,
// for the search-then-bind flow). Denies anonymous, oversized, and cross-tenant
// binds, and rate-limits failures per IP. The tenant is derived from the bind
// DN base (dc=<tenant>,...) and all lookups run inside runWithTenant.
const nodeCrypto = require("crypto");
const User = require("@saltcorn/data/models/user");
const vendor = require("./vendor");
const harden = require("./harden");
const tenant = require("./tenant");
const serviceAccount = require("./serviceAccount");
const { uidFromDn } = require("./dn");
const MAX_CRED_LEN = 1024;
const ipOf = (req) => {
return (req.connection && req.connection.remoteAddress) || "unknown";
};
const dnEquals = (a, b) => {
return String(a).replace(/\s+/g, "").toLowerCase() === String(b).replace(/\s+/g, "").toLowerCase();
};
const constantTimeEqual = (a, b) => {
const ba = Buffer.from(String(a));
const bb = Buffer.from(String(b));
if (ba.length !== bb.length) {
return false;
}
return nodeCrypto.timingSafeEqual(ba, bb);
};
const handler = async (req, res, next) => {
const ip = ipOf(req);
if (harden.isLocked(ip)) {
return next(new vendor.InvalidCredentialsError());
}
try {
const creds = req.credentials || "";
// Deny anonymous (no DN / empty password) and absurdly long credentials.
if (!req.dn || req.dn.length === 0 || creds === "" || creds.length > MAX_CRED_LEN) {
harden.recordFail(ip);
return next(new vendor.InvalidCredentialsError());
}
const t = tenant.resolveTenant(tenant.tenantFromDn(req.dn));
if (t.deny) {
harden.recordFail(ip);
return next(new vendor.InvalidCredentialsError());
}
const dnStr = req.dn.toString();
const authed = await tenant.withTenant(t.tenant, async () => {
// Service-account bind (search-then-bind binder).
const svc = await serviceAccount.getServiceAccount();
if (svc && svc.dn && dnEquals(dnStr, svc.dn)) {
return constantTimeEqual(creds, svc.password);
}
// User bind.
const email = uidFromDn(req.dn);
const user = email ? await User.findOne({ email: email }) : null;
if (!user || user.disabled || !user.checkPassword(creds)) {
return false;
}
return true;
});
if (!authed) {
harden.recordFail(ip);
return next(new vendor.InvalidCredentialsError());
}
harden.recordSuccess(ip);
res.end();
return next();
} catch (e) {
// eslint-disable-next-line no-console
console.error("[saltcorn-idp] ldap bind error:", e);
harden.recordFail(ip);
return next(new vendor.InvalidCredentialsError());
}
};
module.exports = {
handler
};