278 lines
7.0 KiB
JavaScript
278 lines
7.0 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|
||
}
|