Files
workflow-app/backend/ldapOperations.js

465 lines
15 KiB
JavaScript

const { Client, Attribute, Change } = require('ldapts');
const { transliterateUmlauts, toSamAccountName } = require('./umlauts');
/**
* LDAP Operations Module (ldapts)
*
* Provides functions for browsing the AD tree and creating users in Active Directory.
* Uses ldapts (maintained) instead of deprecated ldapjs.
* Punkt 1: Migrated from ldapjs to ldapts
* Punkt 7: Proper client cleanup with try/finally in all functions
*/
const LDAP_SERVER = process.env.LDAP_SERVER || '';
const LDAP_PORT = parseInt(process.env.LDAP_PORT) || 389;
const LDAP_SEARCH_BASE = process.env.LDAP_SEARCH_BASE || '';
const LDAP_DOMAIN = process.env.LDAP_DOMAIN || '';
const LDAP_IGNORE_CERT_ERRORS = (process.env.LDAP_IGNORE_CERT_ERRORS || 'false').toLowerCase() === 'true';
const LDAP_BIND_USER = process.env.LDAP_BIND_USER || '';
const LDAP_BIND_PASSWORD = process.env.LDAP_BIND_PASSWORD || '';
const LDAP_CREATE_OU = process.env.LDAP_CREATE_OU || '';
const LDAP_UPN_SUFFIX = process.env.LDAP_UPN_SUFFIX || '';
function isLDAPConfigured() {
return !!(LDAP_SERVER && LDAP_SEARCH_BASE && LDAP_BIND_USER && LDAP_BIND_PASSWORD);
}
/**
* Normalize ldapts attribute values.
* ldapts may return attributes as arrays; this helper extracts single values.
*/
function attr(entry, key) {
const val = entry[key];
if (Array.isArray(val)) return val[0] || '';
if (val !== undefined && val !== null) return val;
return '';
}
function attrArray(entry, key) {
const val = entry[key];
if (Array.isArray(val)) return val;
if (val !== undefined && val !== null) return [val];
return [];
}
/**
* Create and bind an LDAP client using ldapts.
* Punkt 7: Returns a bound client; caller must call client.unbind() in finally block.
*/
async function createClient() {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const useTLS = LDAP_PORT === 636;
const url = useTLS ? `ldaps://${LDAP_SERVER}:${LDAP_PORT}` : `ldap://${LDAP_SERVER}:${LDAP_PORT}`;
const client = new Client({
url,
tlsOptions: useTLS && LDAP_IGNORE_CERT_ERRORS ? { rejectUnauthorized: false } : undefined,
connectTimeout: 10000,
});
try {
await client.bind(LDAP_BIND_USER, LDAP_BIND_PASSWORD);
return client;
} catch (err) {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup on bind failure
throw new Error('LDAP Bind fehlgeschlagen: ' + (err.message || err));
}
}
/**
* Browse the AD tree and return OUs under the configured base or a given path.
* Returns a hierarchical tree structure.
*/
async function browseOUTree(searchBase) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
// H1: Validate searchBase - must be a DN under the configured LDAP_SEARCH_BASE
// (prevents arbitrary LDAP tree browsing outside the allowed scope)
const base = searchBase || LDAP_SEARCH_BASE;
if (base !== LDAP_SEARCH_BASE) {
const escapeDNRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const basePattern = new RegExp(',' + escapeDNRegex(LDAP_SEARCH_BASE) + '$', 'i');
if (!basePattern.test(base)) {
throw new Error('Ungültige Suchbasis: muss unterhalb von ' + LDAP_SEARCH_BASE + ' liegen.');
}
}
const client = await createClient();
try {
const { searchEntries } = await client.search(base, {
filter: '(objectClass=organizationalUnit)',
scope: 'one',
attributes: ['distinguishedName', 'name'],
sizeLimit: 500,
});
const ous = searchEntries.map(entry => ({
dn: attr(entry, 'distinguishedName') || '',
name: attr(entry, 'name') || '',
}));
// Recursively fetch children for each OU
const results = [];
for (const ou of ous) {
let children = [];
try {
children = await browseOUTree(ou.dn);
} catch (e) {
// Ignore errors for individual OU children
}
results.push({
dn: ou.dn,
name: ou.name,
children: children,
});
}
return results;
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
/**
* Escape special characters in LDAP distinguished names.
*/
function escapeLDAPDN(str) {
return str.replace(/[,+"\\<>;]/g, '\\$&');
}
/**
* Replace German umlauts and ß for sAMAccountName compatibility.
* (Delegiert an zentrale umlauts.js — Logik dort gepflegt.)
*/
function replaceUmlauts(str) {
return transliterateUmlauts(str);
}
/**
* Create a user in Active Directory.
* Punkt 7: Proper client cleanup with try/finally
*/
async function createADUser({ ou, vorname, nachname, email, username, password, department, telefon, titel, displayName, physicalDeliveryOfficeName, company, description, wWWHomePage, streetAddress, postOfficeBox, l, st, postalCode, c }) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
if (!ou || !username || !password) {
throw new Error('OU, Anmeldename und Passwort sind erforderlich.');
}
if (!vorname || !nachname) {
throw new Error('Vorname und Nachname sind erforderlich, um einen AD-Benutzer anzulegen.');
}
const client = await createClient();
try {
// CN format: Nachname, Vorname (as per AD convention)
const cnValue = nachname + ', ' + vorname;
const escapedCN = escapeLDAPDN(cnValue);
const dn = 'CN=' + escapedCN + ',' + ou;
// Build UPN — FIX: Username umlautfrei normalisieren (ä→ae etc.),
// damit kein ungültiger UPN wie müller@... entsteht.
const upnSuffix = LDAP_UPN_SUFFIX || LDAP_BIND_USER.split('@')[1] || LDAP_DOMAIN.toLowerCase() + '.intra';
const userPrincipalName = toSamAccountName(username) + '@' + upnSuffix;
// sAMAccountName: max 20 chars
let sAMAccountName = toSamAccountName(username);
if (vorname && nachname) {
sAMAccountName = toSamAccountName(nachname + vorname.charAt(0));
}
sAMAccountName = sAMAccountName.substring(0, 20);
// userAccountControl: 514 = NORMAL_ACCOUNT + ACCOUNTDISABLE
// ldapts requires attribute values as strings (numbers cause "The string argument must be of type string" error)
const userAccountControl = '514';
const effectiveDisplayName = displayName || (nachname + ', ' + vorname);
const entry = {
objectClass: ['top', 'person', 'organizationalPerson', 'user'],
cn: cnValue,
sn: nachname,
givenName: vorname,
displayName: effectiveDisplayName,
sAMAccountName: sAMAccountName,
userPrincipalName: userPrincipalName,
userAccountControl: userAccountControl,
};
if (email) entry.mail = email;
if (department) entry.department = department;
if (telefon) entry.telephoneNumber = telefon;
if (titel) entry.title = titel;
if (physicalDeliveryOfficeName) entry.physicalDeliveryOfficeName = physicalDeliveryOfficeName;
if (company) entry.company = company;
if (description) entry.description = description;
if (wWWHomePage) entry.wWWHomePage = wWWHomePage;
if (streetAddress) entry.streetAddress = streetAddress;
if (postOfficeBox) entry.postOfficeBox = postOfficeBox;
if (l) entry.l = l;
if (st) entry.st = st;
if (postalCode) entry.postalCode = postalCode;
// c (country) must be a 2-letter ISO-3166 code
if (c) {
const countryCode = String(c).trim().toUpperCase().substring(0, 2);
if (countryCode.length === 2 && /^[A-Z]{2}$/.test(countryCode)) {
entry.c = countryCode;
}
}
// Step 1: Create user as DISABLED
try {
await client.add(dn, entry);
} catch (err) {
if (err.message && err.message.includes('ENTRY_ALREADY_EXISTS')) {
throw new Error('Ein Benutzer mit diesem Namen existiert bereits an dieser Stelle im AD.');
}
if (err.message && err.message.includes('Constraint Violation')) {
console.error('[LDAP] Constraint Violation:', err.message, 'Entry:', JSON.stringify(entry, null, 2));
throw new Error('Constraint Violation: Ein Pflichtfeld fehlt oder enthält einen ungültigen Wert. Bitte Vorname, Nachname und Anmeldename prüfen. Das Land-Feld (c) muss ein 2-Buchstaben-Code sein (z.B. DE).');
}
throw new Error('Fehler beim Erstellen: ' + (err.message || err));
}
console.log('[LDAP] Benutzer erstellt (deaktiviert):', dn);
// Step 2: Set the password
const unicodePwd = Buffer.from('"' + password + '"', 'utf16le');
try {
await client.modify(dn, [
new Change({
operation: 'replace',
modification: new Attribute({
type: 'unicodePwd',
values: [unicodePwd],
}),
}),
]);
} catch (pwdErr) {
console.warn('[LDAP] Passwort konnte nicht gesetzt werden (Benutzer wurde deaktiviert erstellt):', pwdErr.message);
return {
dn: dn,
username: username,
warning: 'Benutzer erstellt (deaktiviert), aber Passwort konnte nicht gesetzt werden: ' + pwdErr.message,
};
}
console.log('[LDAP] Passwort gesetzt für:', dn);
// Step 3: Enable the account (userAccountControl: 512 = NORMAL_ACCOUNT, enabled)
// ldapts requires attribute values as strings
try {
await client.modify(dn, [
new Change({
operation: 'replace',
modification: new Attribute({
type: 'userAccountControl',
values: ['512'],
}),
}),
]);
} catch (enableErr) {
console.warn('[LDAP] Konto konnte nicht aktiviert werden (Benutzer wurde mit Passwort erstellt):', enableErr.message);
return {
dn: dn,
username: username,
warning: 'Benutzer erstellt und Passwort gesetzt, aber Konto konnte nicht aktiviert werden: ' + enableErr.message,
};
}
console.log('[LDAP] Konto aktiviert für:', dn);
// P2: Clear plaintext password from memory after use
password = null;
return { dn: dn, username: username };
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
/**
* Check if a user exists in AD by sAMAccountName.
* Punkt 7: Proper client cleanup
*/
async function checkADUserExists(username, sAMAccountName) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const client = await createClient();
try {
// P1: LDAP-Injection prevention - sanitize username and samName before building filter
const escapeLDAPFilter = (str) => String(str || '').replace(/[*()\\\x00]/g, '\\$&');
const safeSamName = escapeLDAPFilter(sAMAccountName || username);
const safeUsername = escapeLDAPFilter(username);
const { searchEntries } = await client.search(LDAP_SEARCH_BASE, {
filter: '(|(sAMAccountName=' + safeSamName + ')(userPrincipalName=' + safeUsername + '@*))',
scope: 'sub',
attributes: ['distinguishedName', 'sAMAccountName', 'displayName', 'userPrincipalName'],
sizeLimit: 100,
});
if (searchEntries.length > 0) {
const entry = searchEntries[0];
return {
distinguishedName: attr(entry, 'distinguishedName') || '',
sAMAccountName: attr(entry, 'sAMAccountName') || '',
displayName: attr(entry, 'displayName') || '',
userPrincipalName: attr(entry, 'userPrincipalName') || '',
};
}
return null;
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
/**
* Delete a user from Active Directory by DN.
* Punkt 7: Proper client cleanup
*/
async function deleteADUser(dn) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const client = await createClient();
try {
await client.del(dn);
console.log('[LDAP] Benutzer gelöscht (Rollback):', dn);
} catch (err) {
console.error('[LDAP] Fehler beim Löschen des Benutzers (Rollback):', err.message);
throw err;
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
/**
* Search for AD groups/security principals matching a query.
* Returns all groups (no GRP_ filter - used for security group search).
* Punkt 7: Proper client cleanup
*/
async function searchADGroups(query) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
// H2: Validate query - length limit + only safe characters (prevents LDAP injection & DoS)
const safeQuery = String(query || '').trim();
if (!safeQuery || safeQuery.length < 2 || safeQuery.length > 100) {
return [];
}
if (!/^[a-zA-Z0-9äöüÄÖÜß._\- ]+$/.test(safeQuery)) {
return [];
}
const client = await createClient();
try {
const escapedQuery = safeQuery.replace(/[()*\\]/g, '\\$&');
const { searchEntries } = await client.search(LDAP_SEARCH_BASE, {
filter: `(&(objectClass=group)(|(cn=*${escapedQuery}*)(displayName=*${escapedQuery}*)(sAMAccountName=*${escapedQuery}*)))`,
scope: 'sub',
attributes: ['distinguishedName', 'cn', 'displayName', 'sAMAccountName', 'description', 'groupType'],
sizeLimit: 100,
});
return searchEntries.map(entry => ({
dn: attr(entry, 'distinguishedName') || '',
cn: attr(entry, 'cn') || '',
displayName: attr(entry, 'displayName') || attr(entry, 'cn') || '',
sAMAccountName: attr(entry, 'sAMAccountName') || '',
description: attr(entry, 'description') || '',
}));
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
/**
* Add a user to one or more AD groups.
* Punkt 7: Proper client cleanup
*/
async function addUserToGroups(userDN, groupDNs) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const client = await createClient();
const results = [];
try {
for (const groupDN of groupDNs) {
try {
await client.modify(groupDN, [
new Change({
operation: 'add',
modification: new Attribute({
type: 'member',
values: [userDN],
}),
}),
]);
results.push({ dn: groupDN, status: 'added' });
} catch (err) {
if (err.message && err.message.includes('already exists')) {
results.push({ dn: groupDN, status: 'already_member' });
} else {
results.push({ dn: groupDN, status: 'error', error: err.message });
}
}
}
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
return results;
}
/**
* Browse all AD groups under the configured search base.
* Returns only GRP_ groups for static display.
* Punkt 7: Proper client cleanup
*/
async function browseADGroups() {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const client = await createClient();
try {
const { searchEntries } = await client.search(LDAP_SEARCH_BASE, {
filter: '(&(objectClass=group)(cn=GRP_*))',
scope: 'sub',
attributes: ['distinguishedName', 'cn', 'displayName', 'sAMAccountName', 'description', 'memberOf'],
sizeLimit: 500,
});
const groups = searchEntries.map(entry => ({
dn: attr(entry, 'distinguishedName') || '',
cn: attr(entry, 'cn') || '',
displayName: attr(entry, 'displayName') || attr(entry, 'cn') || '',
sAMAccountName: attr(entry, 'sAMAccountName') || '',
description: attr(entry, 'description') || '',
}));
// Sort groups by displayName/cn for easier browsing
groups.sort((a, b) => (a.displayName || a.cn).localeCompare(b.displayName || b.cn));
return groups;
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
module.exports = { isLDAPConfigured, browseOUTree, createADUser, checkADUserExists, deleteADUser, searchADGroups, addUserToGroups, browseADGroups };