LDAP sync

This commit is contained in:
2026-03-23 02:42:19 +01:00
parent 2934be0433
commit e75a2e5e20
17 changed files with 595 additions and 94 deletions

277
server/ldap-auth.js Normal file
View File

@@ -0,0 +1,277 @@
/**
* 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,
};
}