Files
SDS-CRM/server/ldap-auth.js
2026-03-23 02:42:19 +01:00

278 lines
7.0 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* LDAP-Anmeldung analog zu Stundenerfassung (services/ldap-service.js):
* Service-Bind, Benutzersuche (exakt + Varianten ß/ss), Bind mit Nutzerpasswort.
*/
import ldap from 'ldapjs';
function escapeLDAPFilter(value) {
if (!value) return '';
return String(value)
.replace(/\\/g, '\\5c')
.replace(/\*/g, '\\2a')
.replace(/\(/g, '\\28')
.replace(/\)/g, '\\29')
.replace(/\0/g, '\\00');
}
function getUsernameSearchVariants(usernameStr) {
const variants = new Set();
variants.add(usernameStr);
variants.add(usernameStr.toLowerCase());
variants.add(usernameStr.replace(/\u00df/g, 'ss'));
variants.add(usernameStr.replace(/ss/g, '\u00df'));
return [...variants];
}
function unescapeLdapDN(dn) {
if (!dn || typeof dn !== 'string') return dn;
let result = '';
const bytes = [];
let i = 0;
while (i < dn.length) {
if (dn[i] === '\\' && i + 2 < dn.length && /^[0-9a-fA-F]{2}$/.test(dn.slice(i + 1, i + 3))) {
bytes.push(parseInt(dn.slice(i + 1, i + 3), 16));
i += 3;
} else {
if (bytes.length > 0) {
result += Buffer.from(bytes).toString('utf8');
bytes.length = 0;
}
result += dn[i];
i++;
}
}
if (bytes.length > 0) {
result += Buffer.from(bytes).toString('utf8');
}
return result;
}
function getAttributeValue(entry, attributeName) {
const want = String(attributeName || '').toLowerCase();
for (const attr of entry.attributes || []) {
if (attr.type.toLowerCase() === want) {
const value = Array.isArray(attr.values) ? attr.values[0] : attr.values;
return value != null ? String(value) : null;
}
}
return null;
}
function entryUserDn(entry) {
try {
if (entry.dn != null) return entry.dn.toString();
if (entry.objectName != null) return entry.objectName.toString();
} catch {
/* ignore */
}
return '';
}
function bindAsync(client, dn, password) {
return new Promise((resolve, reject) => {
client.bind(dn || '', password ?? '', (err) => (err ? reject(err) : resolve()));
});
}
function unbindAsync(client) {
return new Promise((resolve) => {
try {
client.unbind(() => resolve());
} catch {
resolve();
}
});
}
function searchWithEvents(client, baseDN, options) {
return new Promise((resolve, reject) => {
client.search(baseDN, options, (err, res) => {
if (err) return reject(err);
const entries = [];
res.on('searchEntry', (entry) => entries.push(entry));
res.on('error', reject);
res.on('end', (result) => {
if (result && result.status !== 0) {
return reject(new Error(`LDAP-Suche fehlgeschlagen: ${result.status}`));
}
resolve(entries);
});
});
});
}
/**
* @param {string} usernameInput Roher Benutzername (Trim, kein toLowerCase — für UTF-8/AD)
* @param {string} password
* @param {() => object} loadIntegrations
* @returns {Promise<{ ok: true, canonicalUsername: string } | { ok: false, skipLdap?: true, ldapAttempted?: true, message?: string }>}
*/
export async function authenticateLdap(usernameInput, password, loadIntegrations) {
const usernameStr = String(usernameInput || '').trim();
if (!usernameStr) {
return {
ok: false,
ldapAttempted: true,
message: 'Benutzername darf nicht leer sein',
};
}
const raw = loadIntegrations().ldap || {};
if (!raw.syncEnabled) {
return { ok: false, skipLdap: true };
}
const url = String(raw.serverUrl || '').trim();
const baseDN = String(raw.searchBase || '').trim();
if (!url || !baseDN) {
return { ok: false, skipLdap: true };
}
const usernameAttr = String(raw.usernameAttribute || 'sAMAccountName').trim();
const client = ldap.createClient({
url,
timeout: 10000,
connectTimeout: 10000,
reconnect: false,
});
client.on('error', () => {});
try {
await bindAsync(
client,
String(raw.bindDn || '').trim(),
raw.bindPassword != null ? String(raw.bindPassword) : '',
);
} catch (e) {
await unbindAsync(client);
return {
ok: false,
ldapAttempted: true,
message: e && e.message ? String(e.message) : String(e),
};
}
const exactFilter = `(${usernameAttr}=${escapeLDAPFilter(usernameStr)})`;
let userDN = null;
let canonicalUsername = null;
const applyEntries = (entries, fallbackName) => {
for (const entry of entries) {
const dn = entryUserDn(entry);
if (dn) {
userDN = dn;
canonicalUsername =
getAttributeValue(entry, usernameAttr) || fallbackName;
}
}
};
const runVariantSearch = async () => {
userDN = null;
canonicalUsername = null;
const variants = getUsernameSearchVariants(usernameStr);
const filterParts = variants.map(
(v) => `(${usernameAttr}=${escapeLDAPFilter(v)})`,
);
const searchFilter =
filterParts.length === 1 ? filterParts[0] : `(|${filterParts.join('')})`;
const entries = await searchWithEvents(client, baseDN, {
filter: searchFilter,
scope: 'sub',
attributes: ['dn', usernameAttr],
});
applyEntries(entries, usernameStr);
};
try {
const entries = await searchWithEvents(client, baseDN, {
filter: exactFilter,
scope: 'sub',
attributes: ['dn', usernameAttr],
});
applyEntries(entries, usernameStr);
} catch {
try {
await runVariantSearch();
} catch (e) {
await unbindAsync(client);
return {
ok: false,
ldapAttempted: true,
message: e && e.message ? String(e.message) : String(e),
};
}
}
if (!userDN) {
try {
await runVariantSearch();
} catch (e) {
await unbindAsync(client);
return {
ok: false,
ldapAttempted: true,
message: e && e.message ? String(e.message) : String(e),
};
}
}
if (!userDN) {
await unbindAsync(client);
return {
ok: false,
ldapAttempted: true,
message: `Benutzer „${usernameStr}“ nicht gefunden. Prüfen Sie den Benutzernamen (z.B. UTF-8-Zeichen).`,
};
}
await unbindAsync(client);
const bindDN = unescapeLdapDN(userDN);
const authClient = ldap.createClient({
url,
timeout: 10000,
connectTimeout: 10000,
reconnect: false,
});
try {
await new Promise((resolve, reject) => {
authClient.on('error', (err) => {
try {
authClient.unbind();
} catch {
/* ignore */
}
reject(err);
});
authClient.bind(bindDN, password, (err) => {
if (err) {
const errorMsg = err.message || String(err);
try {
authClient.unbind();
} catch {
/* ignore */
}
return reject(
new Error(`Ungültiges Passwort oder Authentifizierungsfehler: ${errorMsg}`),
);
}
authClient.unbind(() => resolve());
});
});
} catch (e) {
return {
ok: false,
ldapAttempted: true,
message: e && e.message ? String(e.message) : String(e),
};
}
return {
ok: true,
canonicalUsername: canonicalUsername || usernameStr,
};
}