465 lines
15 KiB
JavaScript
465 lines
15 KiB
JavaScript
const { Client, Attribute, Change } = require('ldapts');
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
function replaceUmlauts(str) {
|
|
return str
|
|
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
|
|
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
|
|
.replace(/ß/g, 'ss');
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
const upnSuffix = LDAP_UPN_SUFFIX || LDAP_BIND_USER.split('@')[1] || LDAP_DOMAIN.toLowerCase() + '.intra';
|
|
const userPrincipalName = username + '@' + upnSuffix;
|
|
|
|
// sAMAccountName: max 20 chars
|
|
let sAMAccountName = username;
|
|
if (vorname && nachname) {
|
|
sAMAccountName = replaceUmlauts(nachname + vorname.charAt(0)).replace(/[^a-zA-Z0-9]/g, '');
|
|
}
|
|
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 }; |