import { randomUUID } from 'crypto'; import ldap from 'ldapjs'; import { hashPassword } from './password.js'; let syncRunning = false; function getAttr(entry, name) { const want = String(name || '').toLowerCase(); for (const attr of entry.attributes) { if (attr.type.toLowerCase() === want) { const v = Array.isArray(attr.values) ? attr.values[0] : attr.values; return v != null ? String(v).trim() : ''; } } return ''; } function entryDn(entry) { try { if (entry.objectName != null) return entry.objectName.toString(); if (entry.dn != null) return entry.dn.toString(); } catch { /* ignore */ } return ''; } function searchAsync(client, base, options) { return new Promise((resolve, reject) => { const results = []; client.search(base, options, (err, res) => { if (err) return reject(err); res.on('searchEntry', (entry) => results.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(results); }); }); }); } 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 recordLdapLastSync(dbSync) { const iso = new Date().toISOString(); dbSync.prepare( `INSERT INTO app_settings (key, value) VALUES ('ldap_last_sync_at', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, ).run(iso); } function insertLog(dbSync, row) { dbSync.prepare( `INSERT INTO ldap_sync_log (id, started_at, finished_at, trigger_type, status, users_synced, error_message) VALUES (?, ?, ?, ?, ?, ?, ?)`, ).run( row.id, row.startedAt, row.finishedAt, row.triggerType, row.status, row.usersSynced, row.errorMessage, ); } /** * Entspricht der Synchronisation in Stundenerfassung (ldap-service.js): * Vor-/Nachname aus LDAP, nur Einträge mit Benutzername + Vor- + Nachname, * case-insensitive Abgleich, Standard-Passwort-Hash für neu angelegte Konten. * * @param {import('node:sqlite').DatabaseSync} dbSync * @param {() => object} loadIntegrations * @param {'manual' | 'automatic'} trigger */ export async function performLdapSync(dbSync, loadIntegrations, trigger) { if (syncRunning) { return { skipped: true, message: 'Synchronisation läuft bereits.' }; } syncRunning = true; const logId = randomUUID(); const startedAt = new Date().toISOString(); let usersSynced = 0; let errorMessage = null; /** @type {string[]} */ const rowErrors = []; let client = null; try { const config = loadIntegrations().ldap || {}; if (!config.syncEnabled) { throw new Error('LDAP-Synchronisation ist nicht aktiviert'); } const serverUrl = String(config.serverUrl || '').trim(); const searchBase = String(config.searchBase || '').trim(); const filterRaw = String( config.userSearchFilter || config.userFilter || '', ).trim(); const filter = filterRaw || '(objectClass=person)'; const usernameAttr = String( config.usernameAttribute || 'sAMAccountName', ).trim(); const firstAttr = String( config.firstNameAttribute || 'givenName', ).trim(); const lastAttr = String(config.lastNameAttribute || 'sn').trim(); if (!serverUrl) throw new Error('LDAP-Server URL fehlt.'); if (!searchBase) throw new Error('Base DN fehlt.'); client = ldap.createClient({ url: serverUrl, timeout: 10000, connectTimeout: 10000, reconnect: false, }); client.on('error', () => {}); const bindDn = String(config.bindDn || '').trim(); const bindPassword = config.bindPassword != null ? String(config.bindPassword) : ''; await bindAsync(client, bindDn, bindPassword); const entries = await searchAsync(client, searchBase, { scope: 'sub', filter, attributes: [usernameAttr, firstAttr, lastAttr], }); await unbindAsync(client); client = null; const defaultPwHash = await hashPassword('changeme123'); /** @type {{ username: string, firstname: string, lastname: string, dn: string }[]} */ const ldapUsers = []; for (const entry of entries) { const dn = entryDn(entry); const rawUser = getAttr(entry, usernameAttr); const firstname = getAttr(entry, firstAttr); const lastname = getAttr(entry, lastAttr); if (!rawUser || !firstname || !lastname) continue; const username = String(rawUser).trim().toLowerCase(); if (!username) continue; ldapUsers.push({ username, firstname, lastname, dn }); } dbSync.exec('BEGIN'); try { for (const u of ldapUsers) { try { const existing = dbSync .prepare( 'SELECT id, source FROM users WHERE username = ? COLLATE NOCASE', ) .get(u.username); if (existing) { if (existing.source === 'ldap') { dbSync .prepare( `UPDATE users SET firstname = ?, lastname = ?, ldap_dn = ?, updated_at = datetime('now') WHERE id = ?`, ) .run(u.firstname, u.lastname, u.dn || null, existing.id); } else { dbSync .prepare( `UPDATE users SET firstname = ?, lastname = ?, updated_at = datetime('now') WHERE id = ?`, ) .run(u.firstname, u.lastname, existing.id); } usersSynced += 1; } else { const id = randomUUID(); dbSync .prepare( `INSERT INTO users (id, username, password_hash, role, source, ldap_dn, firstname, lastname, active, updated_at) VALUES (?, ?, ?, 'after_sales', 'ldap', ?, ?, ?, 1, datetime('now'))`, ) .run( id, u.username, defaultPwHash, u.dn || null, u.firstname, u.lastname, ); usersSynced += 1; } } catch (e) { const msg = e && e.message ? String(e.message) : String(e); rowErrors.push(`Fehler bei ${u.username}: ${msg}`); } } dbSync.exec('COMMIT'); recordLdapLastSync(dbSync); } catch (e) { try { dbSync.exec('ROLLBACK'); } catch { /* ignore */ } throw e; } if (rowErrors.length > 0) { errorMessage = rowErrors.join('; '); } } catch (e) { errorMessage = e && e.message ? String(e.message) : String(e); if (client) { try { await unbindAsync(client); } catch { /* ignore */ } client = null; } } finally { try { const finishedAt = new Date().toISOString(); const hasRowErr = rowErrors.length > 0; const status = errorMessage || hasRowErr ? 'error' : 'success'; const logErr = errorMessage || (hasRowErr ? rowErrors.join('; ') : null); insertLog(dbSync, { id: logId, startedAt, finishedAt, triggerType: trigger, status, usersSynced, errorMessage: logErr, }); } finally { syncRunning = false; } } const hasRowErr = rowErrors.length > 0; if (errorMessage && !hasRowErr) { return { ok: false, usersSynced: 0, error: errorMessage, errors: [] }; } if (hasRowErr) { return { ok: false, usersSynced, error: errorMessage || rowErrors.join('; '), errors: rowErrors, }; } return { ok: true, usersSynced, errors: [] }; } /** * @param {import('node:sqlite').DatabaseSync} dbSync */ export function getSyncStatus(dbSync) { const lastSetting = dbSync .prepare("SELECT value FROM app_settings WHERE key = 'ldap_last_sync_at'") .get(); const lastLog = dbSync .prepare( `SELECT finished_at FROM ldap_sync_log ORDER BY finished_at DESC LIMIT 1`, ) .get(); const lastSyncAt = lastSetting?.value ?? lastLog?.finished_at ?? null; const entries = dbSync .prepare( `SELECT finished_at AS finishedAt, trigger_type AS triggerType, status, users_synced AS usersSynced, error_message AS errorMessage FROM ldap_sync_log ORDER BY finished_at DESC LIMIT 10`, ) .all(); return { lastSyncAt, entries, }; }