DEV1.0: Initial commit - Workflow Portal with security fixes
- Backend: Express.js + PostgreSQL/SQLite with LDAP/AD integration - Frontend: React 18 + Vite + TailwindCSS/DaisyUI - Security fixes applied (2026-07 + 2026-08): - LDAP injection prevention, CSRF protection, HttpOnly cookies - Session hashing (SHA-256), account lockout, rate limiting - Input validation (zod), file upload security, CSP/HSTS headers - V3: express-rate-limit updated (ip-address SSRF fix) - V4: postcss updated (nanoid DoS fix) - V5: Rate-limit on /health endpoint - V6: Session rotation on login (session fixation prevention) - V9: Task values array limit (DoS prevention) - V10: Frontend XSS audit completed - Docker: Multi-stage build, non-root user, PostgreSQL + backup service
This commit is contained in:
444
backend/ldapOperations.js
Normal file
444
backend/ldapOperations.js
Normal file
@@ -0,0 +1,444 @@
|
||||
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.');
|
||||
}
|
||||
|
||||
const client = await createClient();
|
||||
const base = searchBase || LDAP_SEARCH_BASE;
|
||||
|
||||
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
|
||||
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)
|
||||
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.');
|
||||
}
|
||||
|
||||
const client = await createClient();
|
||||
|
||||
try {
|
||||
const escapedQuery = query.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 };
|
||||
Reference in New Issue
Block a user