LDAP sync
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import ldap from 'ldapjs';
|
||||
import { hashPassword } from './password.js';
|
||||
|
||||
let syncRunning = false;
|
||||
|
||||
@@ -7,13 +8,23 @@ function getAttr(entry, name) {
|
||||
const want = String(name || '').toLowerCase();
|
||||
for (const attr of entry.attributes) {
|
||||
if (attr.type.toLowerCase() === want) {
|
||||
const v = attr.values[0];
|
||||
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 = [];
|
||||
@@ -21,7 +32,14 @@ function searchAsync(client, base, options) {
|
||||
if (err) return reject(err);
|
||||
res.on('searchEntry', (entry) => results.push(entry));
|
||||
res.on('error', reject);
|
||||
res.on('end', () => resolve(results));
|
||||
res.on('end', (result) => {
|
||||
if (result && result.status !== 0) {
|
||||
return reject(
|
||||
new Error(`LDAP-Suche fehlgeschlagen: ${result.status}`),
|
||||
);
|
||||
}
|
||||
resolve(results);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -42,8 +60,16 @@ function unbindAsync(client) {
|
||||
});
|
||||
}
|
||||
|
||||
function insertLog(db, row) {
|
||||
db.prepare(
|
||||
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(
|
||||
@@ -58,11 +84,15 @@ function insertLog(db, row) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('node:sqlite').DatabaseSync} db
|
||||
* 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(db, loadIntegrations, trigger) {
|
||||
export async function performLdapSync(dbSync, loadIntegrations, trigger) {
|
||||
if (syncRunning) {
|
||||
return { skipped: true, message: 'Synchronisation läuft bereits.' };
|
||||
}
|
||||
@@ -71,27 +101,37 @@ export async function performLdapSync(db, loadIntegrations, trigger) {
|
||||
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 filter = String(
|
||||
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.');
|
||||
if (!filter) throw new Error('User Search Filter fehlt.');
|
||||
|
||||
client = ldap.createClient({
|
||||
url: serverUrl,
|
||||
timeout: 120000,
|
||||
connectTimeout: 20000,
|
||||
timeout: 10000,
|
||||
connectTimeout: 10000,
|
||||
reconnect: false,
|
||||
});
|
||||
|
||||
@@ -106,53 +146,90 @@ export async function performLdapSync(db, loadIntegrations, trigger) {
|
||||
const entries = await searchAsync(client, searchBase, {
|
||||
scope: 'sub',
|
||||
filter,
|
||||
attributes: [usernameAttr],
|
||||
attributes: [usernameAttr, firstAttr, lastAttr],
|
||||
});
|
||||
|
||||
await unbindAsync(client);
|
||||
client = null;
|
||||
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
let n = 0;
|
||||
for (const entry of entries) {
|
||||
let dn;
|
||||
try {
|
||||
dn = entry.objectName.toString();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const un = getAttr(entry, usernameAttr).toLowerCase().trim();
|
||||
if (!un) continue;
|
||||
const defaultPwHash = await hashPassword('changeme123');
|
||||
|
||||
const existing = db
|
||||
.prepare('SELECT id, source FROM users WHERE username = ?')
|
||||
.get(un);
|
||||
if (existing) {
|
||||
if (existing.source === 'local') continue;
|
||||
db.prepare(
|
||||
`UPDATE users SET ldap_dn = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||
).run(dn, existing.id);
|
||||
n += 1;
|
||||
} else {
|
||||
const id = randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, role, source, ldap_dn, active, updated_at)
|
||||
VALUES (?, ?, NULL, 'user', 'ldap', ?, 1, datetime('now'))`,
|
||||
).run(id, un, dn);
|
||||
n += 1;
|
||||
/** @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}`);
|
||||
}
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
usersSynced = n;
|
||||
dbSync.exec('COMMIT');
|
||||
recordLdapLastSync(dbSync);
|
||||
} catch (e) {
|
||||
try {
|
||||
db.exec('ROLLBACK');
|
||||
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) {
|
||||
@@ -166,44 +243,60 @@ export async function performLdapSync(db, loadIntegrations, trigger) {
|
||||
} finally {
|
||||
try {
|
||||
const finishedAt = new Date().toISOString();
|
||||
const status = errorMessage ? 'error' : 'success';
|
||||
insertLog(db, {
|
||||
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 ? 0 : usersSynced,
|
||||
errorMessage,
|
||||
usersSynced,
|
||||
errorMessage: logErr,
|
||||
});
|
||||
} finally {
|
||||
syncRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return { ok: false, usersSynced: 0, error: errorMessage };
|
||||
const hasRowErr = rowErrors.length > 0;
|
||||
if (errorMessage && !hasRowErr) {
|
||||
return { ok: false, usersSynced: 0, error: errorMessage, errors: [] };
|
||||
}
|
||||
return { ok: true, usersSynced };
|
||||
if (hasRowErr) {
|
||||
return {
|
||||
ok: false,
|
||||
usersSynced,
|
||||
error: errorMessage || rowErrors.join('; '),
|
||||
errors: rowErrors,
|
||||
};
|
||||
}
|
||||
return { ok: true, usersSynced, errors: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('node:sqlite').DatabaseSync} db
|
||||
* @param {import('node:sqlite').DatabaseSync} dbSync
|
||||
*/
|
||||
export function getSyncStatus(db) {
|
||||
const last = db
|
||||
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 entries = db
|
||||
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: last?.finished_at ?? null,
|
||||
lastSyncAt,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user