Files
SDS-CRM/server/ldap-sync.js
2026-03-23 02:42:19 +01:00

303 lines
8.8 KiB
JavaScript

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 (?, ?, ?, 'user', '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,
};
}