Files
SDSStundenerfassung/services/ldap-service.js
2026-02-03 23:25:37 +01:00

576 lines
21 KiB
JavaScript

const ldap = require('ldapjs');
const { db } = require('../database');
const bcrypt = require('bcryptjs');
/**
* LDAP-Service für Benutzer-Synchronisation
*/
class LDAPService {
/**
* LDAP-Konfiguration aus der Datenbank abrufen
*/
static getConfig(callback) {
db.get('SELECT * FROM ldap_config WHERE id = 1', (err, config) => {
if (err) {
return callback(err, null);
}
callback(null, config);
});
}
/**
* LDAP-Verbindung herstellen
*/
static connect(config, callback) {
if (!config || !config.enabled || !config.url) {
return callback(new Error('LDAP ist nicht konfiguriert oder deaktiviert'));
}
const client = ldap.createClient({
url: config.url,
timeout: 10000,
connectTimeout: 10000
});
// Fehlerbehandlung
client.on('error', (err) => {
callback(err, null);
});
// Bind mit Credentials
const bindDN = config.bind_dn || '';
const bindPassword = config.bind_password || '';
// Hinweis: Passwort wird im Klartext gespeichert
// In einer produktiven Umgebung sollte man eine Verschlüsselung mit einem Master-Key verwenden
client.bind(bindDN, bindPassword, (err) => {
if (err) {
client.unbind();
return callback(err, null);
}
callback(null, client);
});
}
/**
* Benutzer aus LDAP abrufen
*/
static searchUsers(client, config, callback) {
const baseDN = config.base_dn || '';
const searchFilter = config.user_search_filter || '(objectClass=person)';
const searchOptions = {
filter: searchFilter,
scope: 'sub',
attributes: [
config.username_attribute || 'sAMAccountName',
config.firstname_attribute || 'givenName',
config.lastname_attribute || 'sn'
]
};
const users = [];
client.search(baseDN, searchOptions, (err, res) => {
if (err) {
return callback(err, null);
}
res.on('searchEntry', (entry) => {
const user = {
username: this.getAttributeValue(entry, config.username_attribute || 'sAMAccountName'),
firstname: this.getAttributeValue(entry, config.firstname_attribute || 'givenName'),
lastname: this.getAttributeValue(entry, config.lastname_attribute || 'sn')
};
// Nur Benutzer mit allen erforderlichen Feldern hinzufügen
if (user.username && user.firstname && user.lastname) {
users.push(user);
}
});
res.on('error', (err) => {
callback(err, null);
});
res.on('end', (result) => {
if (result && result.status !== 0) {
return callback(new Error(`LDAP-Suche fehlgeschlagen: ${result.status}`), null);
}
callback(null, users);
});
});
}
/**
* Wert eines LDAP-Attributs extrahieren
*
* Die ldapjs-Bibliothek behandelt UTF-8-Zeichen automatisch korrekt.
* Diese Funktion stellt sicher, dass UTF-8-Zeichen wie ß, ä, ö, ü korrekt zurückgegeben werden.
*/
static getAttributeValue(entry, attributeName) {
const attr = entry.attributes.find(a => a.type === attributeName);
if (!attr) {
return null;
}
const value = Array.isArray(attr.values) ? attr.values[0] : attr.values;
// Stelle sicher, dass der Wert als String zurückgegeben wird (UTF-8 wird automatisch korrekt behandelt)
return value != null ? String(value) : null;
}
/**
* Escaped einen Wert für LDAP-Filter (verhindert LDAP-Injection)
*
* WICHTIG: UTF-8-Zeichen wie ß, ä, ö, ü müssen NICHT escaped werden.
* LDAP-Filter unterstützen UTF-8 direkt nach RFC 4515.
* Nur die speziellen LDAP-Filter-Zeichen werden escaped.
*/
static escapeLDAPFilter(value, debugLabel = '') {
if (!value) return '';
// Stelle sicher, dass der Wert als String behandelt wird
const str = String(value);
// Debug-Logging
if (debugLabel) {
console.log(`[escapeLDAPFilter ${debugLabel}] Input:`, str);
const utf8Bytes = Buffer.from(str, 'utf8');
console.log(`[escapeLDAPFilter ${debugLabel}] UTF-8 Bytes:`, Array.from(utf8Bytes));
}
// Escape nur die speziellen LDAP-Filter-Zeichen
// UTF-8-Zeichen wie ß, ä, ö, ü werden direkt verwendet
const escaped = str
.replace(/\\/g, '\\5c') // Backslash
.replace(/\*/g, '\\2a') // Stern
.replace(/\(/g, '\\28') // Öffnende Klammer
.replace(/\)/g, '\\29') // Schließende Klammer
.replace(/\0/g, '\\00'); // Null-Byte
if (debugLabel) {
console.log(`[escapeLDAPFilter ${debugLabel}] Output:`, escaped);
const escapedBytes = Buffer.from(escaped, 'utf8');
console.log(`[escapeLDAPFilter ${debugLabel}] Escaped UTF-8 Bytes:`, Array.from(escapedBytes));
}
return escaped;
}
/**
* Benutzer in SQLite synchronisieren
*/
static syncUsers(ldapUsers, callback) {
let syncedCount = 0;
let errorCount = 0;
const errors = [];
if (!ldapUsers || ldapUsers.length === 0) {
return callback(null, { synced: 0, errors: [] });
}
// Verarbeite jeden Benutzer
const processUser = (index) => {
if (index >= ldapUsers.length) {
return callback(null, { synced: syncedCount, errors: errors });
}
const ldapUser = ldapUsers[index];
// .trim() behält UTF-8-Zeichen wie ß, ä, ö, ü korrekt bei
// Stelle sicher, dass Werte als String behandelt werden
const username = String(ldapUser.username || '').trim();
const firstname = String(ldapUser.firstname || '').trim();
const lastname = String(ldapUser.lastname || '').trim();
// Prüfe ob Benutzer bereits existiert (case-insensitive)
db.get('SELECT id, role FROM users WHERE username = ? COLLATE NOCASE', [username], (err, existingUser) => {
if (err) {
errors.push(`Fehler beim Prüfen von ${username}: ${err.message}`);
errorCount++;
return processUser(index + 1);
}
if (existingUser) {
// Benutzer existiert - aktualisiere nur Name, behalte Rolle (case-insensitive)
db.run(
'UPDATE users SET firstname = ?, lastname = ? WHERE username = ? COLLATE NOCASE',
[firstname, lastname, username],
(err) => {
if (err) {
errors.push(`Fehler beim Aktualisieren von ${username}: ${err.message}`);
errorCount++;
} else {
syncedCount++;
}
processUser(index + 1);
}
);
} else {
// Neuer Benutzer - erstelle mit Standard-Rolle
// Generiere ein zufälliges Passwort (Benutzer muss es beim ersten Login ändern)
const defaultPassword = bcrypt.hashSync('changeme123', 10);
db.run(
'INSERT INTO users (username, password, firstname, lastname, role) VALUES (?, ?, ?, ?, ?)',
[username, defaultPassword, firstname, lastname, 'mitarbeiter'],
(err) => {
if (err) {
errors.push(`Fehler beim Erstellen von ${username}: ${err.message}`);
errorCount++;
} else {
syncedCount++;
}
processUser(index + 1);
}
);
}
});
};
processUser(0);
}
/**
* Sync-Log-Eintrag erstellen
*/
static createSyncLog(syncType, status, usersSynced, errorMessage, callback) {
const startedAt = new Date().toISOString();
const completedAt = new Date().toISOString();
db.run(
`INSERT INTO ldap_sync_log (sync_type, status, users_synced, error_message, sync_started_at, sync_completed_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[syncType, status, usersSynced, errorMessage || null, startedAt, completedAt],
(err) => {
if (callback) {
callback(err);
}
}
);
}
/**
* Letzte Synchronisation aktualisieren
*/
static updateLastSync(callback) {
db.run(
'UPDATE ldap_config SET last_sync = CURRENT_TIMESTAMP WHERE id = 1',
(err) => {
if (callback) {
callback(err);
}
}
);
}
/**
* Suchvarianten für Benutzername (case-insensitive, ß/ss) für LDAP-Filter.
* So findet der Login auch "GeißlerJ", wenn LDAP "geißlerj" oder "GeisslerJ" speichert.
*/
static getUsernameSearchVariants(usernameStr) {
const variants = new Set();
variants.add(usernameStr);
variants.add(usernameStr.toLowerCase());
variants.add(usernameStr.replace(/\u00df/g, 'ss')); // ß -> ss
variants.add(usernameStr.replace(/ss/g, '\u00df')); // ss -> ß (z. B. Geissler -> Geißler)
return [...variants];
}
/**
* Benutzer gegen LDAP authentifizieren
*
* Unterstützt UTF-8-Zeichen wie ß, ä, ö, ü in Usernamen.
* Sucht mit mehreren Varianten (Groß-/Kleinschreibung, ß/ss), damit z. B. "GeißlerJ"
* auch gefunden wird, wenn LDAP "geißlerj" oder "GeisslerJ" speichert.
* Gibt bei Erfolg den kanonischen LDAP-Benutzernamen zurück (für DB-Lookup).
*/
static authenticate(username, password, callback) {
// Stelle sicher, dass Username als String behandelt wird (UTF-8 wird korrekt unterstützt)
const usernameStr = String(username || '').trim();
console.log('\n========== LDAP AUTHENTICATE - START ==========');
console.log('[LDAP] Username input:', usernameStr);
const utf8Bytes = Buffer.from(usernameStr, 'utf8');
console.log('[LDAP] Username UTF-8 Bytes:', Array.from(utf8Bytes));
console.log('[LDAP] Username UTF-8 Bytes (Hex):', Array.from(utf8Bytes).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' '));
console.log('[LDAP] Username length:', usernameStr.length);
// Character-by-character analysis
console.log('[LDAP] Character analysis:');
for (let i = 0; i < usernameStr.length; i++) {
const char = usernameStr[i];
const codePoint = char.codePointAt(0);
const charBytes = Buffer.from(char, 'utf8');
console.log(` [${i}] "${char}" | U+${codePoint.toString(16).toUpperCase().padStart(4, '0')} (${codePoint}) | Bytes: [${Array.from(charBytes).join(', ')}]`);
}
if (!usernameStr) {
return callback(new Error('Benutzername darf nicht leer sein'), false);
}
// Konfiguration abrufen
this.getConfig((err, config) => {
if (err || !config || !config.enabled) {
return callback(new Error('LDAP ist nicht aktiviert'), false);
}
console.log('[LDAP] Config loaded - baseDN:', config.base_dn);
console.log('[LDAP] Config - usernameAttr:', config.username_attribute || 'sAMAccountName');
// LDAP-Verbindung herstellen (mit Service-Account)
this.connect(config, (err, client) => {
if (err) {
console.error('[LDAP] Connection error:', err);
return callback(err, false);
}
console.log('[LDAP] Connected successfully');
const baseDN = config.base_dn || '';
const usernameAttr = config.username_attribute || 'sAMAccountName';
// Primär: Exakte Übereinstimmung mit sAMAccountName (wie in AD gespeichert)
// Varianten werden als Fallback verwendet, falls exakte Suche fehlschlägt
console.log('[LDAP] Using exact sAMAccountName match (as stored in AD)');
const exactEscaped = this.escapeLDAPFilter(usernameStr, 'exact');
const exactFilter = `(${usernameAttr}=${exactEscaped})`;
console.log('[LDAP] Exact LDAP Search Filter:', exactFilter);
console.log('[LDAP] Exact Filter UTF-8 Bytes:', Array.from(Buffer.from(exactFilter, 'utf8')));
const exactSearchOptions = {
filter: exactFilter,
scope: 'sub',
attributes: ['dn', usernameAttr]
};
console.log('[LDAP] Starting LDAP search with exact match...');
console.log('[LDAP] Search options:', JSON.stringify(exactSearchOptions, null, 2));
let userDN = null;
let canonicalUsername = null;
let searchAttempted = false;
// Zuerst exakte Suche versuchen
client.search(baseDN, exactSearchOptions, (err, res) => {
if (err) {
console.error('[LDAP] Exact search error:', err);
// Bei Fehler: Varianten als Fallback versuchen
return this.tryVariantSearch(client, baseDN, usernameAttr, usernameStr, password, config, callback);
}
searchAttempted = true;
res.on('searchEntry', (entry) => {
userDN = entry.dn.toString();
canonicalUsername = this.getAttributeValue(entry, usernameAttr) || usernameStr;
console.log('[LDAP] Exact search entry found!');
console.log('[LDAP] userDN:', userDN);
console.log('[LDAP] canonicalUsername:', canonicalUsername);
const canonicalBytes = Buffer.from(canonicalUsername, 'utf8');
console.log('[LDAP] canonicalUsername UTF-8 Bytes:', Array.from(canonicalBytes));
console.log('[LDAP] canonicalUsername UTF-8 Bytes (Hex):', Array.from(canonicalBytes).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' '));
});
res.on('error', (err) => {
const errorMsg = err.message || String(err);
console.error('[LDAP] Exact search error:', errorMsg);
console.error('[LDAP] Error details:', err);
// Bei Fehler: Varianten als Fallback versuchen
if (!userDN) {
return this.tryVariantSearch(client, baseDN, usernameAttr, usernameStr, password, config, callback);
}
client.unbind();
callback(new Error(`LDAP-Suchfehler: ${errorMsg}`), false);
});
res.on('end', (result) => {
console.log('[LDAP] Exact search ended. Status:', result ? result.status : 'unknown');
console.log('[LDAP] userDN found:', userDN ? 'YES' : 'NO');
if (!userDN) {
console.log('[LDAP] Exact match not found, trying variants as fallback...');
// Exakte Suche fehlgeschlagen, Varianten als Fallback versuchen
return this.tryVariantSearch(client, baseDN, usernameAttr, usernameStr, password, config, callback);
}
// Exakte Suche erfolgreich - mit Bind fortfahren
this.performBind(config, userDN, password, canonicalUsername, callback);
});
});
});
});
}
/**
* Varianten-Suche als Fallback (wenn exakte Suche fehlschlägt)
*/
static tryVariantSearch(client, baseDN, usernameAttr, usernameStr, password, config, callback) {
console.log('[LDAP] Starting variant search as fallback...');
// OR-Filter mit mehreren Varianten (lowercase, ß/ss), damit Login trotz unterschiedlicher Schreibweise funktioniert
const variants = this.getUsernameSearchVariants(usernameStr);
console.log('[LDAP] Username variants generated:', variants);
console.log('[LDAP] Number of variants:', variants.length);
const filterParts = variants.map((v, idx) => {
const escaped = this.escapeLDAPFilter(v, `variant-${idx}`);
return `(${usernameAttr}=${escaped})`;
});
const searchFilter = filterParts.length === 1 ? filterParts[0] : `(|${filterParts.join('')})`;
console.log('[LDAP] Variant LDAP Search Filter:', searchFilter);
console.log('[LDAP] Variant Filter UTF-8 Bytes:', Array.from(Buffer.from(searchFilter, 'utf8')));
const searchOptions = {
filter: searchFilter,
scope: 'sub',
attributes: ['dn', usernameAttr]
};
let userDN = null;
let canonicalUsername = null;
client.search(baseDN, searchOptions, (err, res) => {
if (err) {
client.unbind();
const errorMsg = err.message || String(err);
return callback(new Error(`LDAP-Suche fehlgeschlagen: ${errorMsg}. Hinweis: Prüfen Sie, ob der Benutzername UTF-8-Zeichen (wie ß, ä, ö, ü) korrekt enthält.`), false);
}
res.on('searchEntry', (entry) => {
userDN = entry.dn.toString();
// Kanonischen Benutzernamen aus LDAP verwenden (für DB-Lookup nach Sync)
canonicalUsername = this.getAttributeValue(entry, usernameAttr) || usernameStr;
console.log('[LDAP] Search entry found!');
console.log('[LDAP] userDN:', userDN);
console.log('[LDAP] canonicalUsername:', canonicalUsername);
const canonicalBytes = Buffer.from(canonicalUsername, 'utf8');
console.log('[LDAP] canonicalUsername UTF-8 Bytes:', Array.from(canonicalBytes));
console.log('[LDAP] canonicalUsername UTF-8 Bytes (Hex):', Array.from(canonicalBytes).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' '));
});
res.on('error', (err) => {
client.unbind();
const errorMsg = err.message || String(err);
console.error('[LDAP] Search error:', errorMsg);
console.error('[LDAP] Error details:', err);
callback(new Error(`LDAP-Suchfehler: ${errorMsg}`), false);
});
res.on('end', (result) => {
client.unbind();
console.log('[LDAP] Search ended. Status:', result ? result.status : 'unknown');
console.log('[LDAP] userDN found:', userDN ? 'YES' : 'NO');
console.log('[LDAP] canonicalUsername:', canonicalUsername);
if (!userDN) {
console.error('[LDAP] User not found in LDAP (even with variants)!');
console.error('[LDAP] Searched for:', usernameStr);
console.error('[LDAP] Used filter:', searchFilter);
return callback(new Error(`Benutzer "${usernameStr}" nicht gefunden. Hinweis: Prüfen Sie, ob der Benutzername korrekt ist und UTF-8-Zeichen (wie ß, ä, ö, ü) korrekt geschrieben sind.`), false);
}
// Varianten-Suche erfolgreich - mit Bind fortfahren
this.performBind(config, userDN, password, canonicalUsername, callback);
});
});
}
/**
* LDAP Bind durchführen (Passwort-Authentifizierung)
*/
static performBind(config, userDN, password, canonicalUsername, callback) {
console.log('[LDAP] Attempting bind with userDN:', userDN);
const authClient = ldap.createClient({
url: config.url,
timeout: 10000,
connectTimeout: 10000
});
authClient.on('error', (err) => {
authClient.unbind();
console.error('[LDAP] Bind client error:', err);
callback(err, false);
});
authClient.bind(userDN, password, (err) => {
authClient.unbind();
if (err) {
const errorMsg = err.message || String(err);
console.error('[LDAP] Bind failed:', errorMsg);
console.error('[LDAP] Bind error details:', err);
return callback(new Error(`Ungültiges Passwort oder Authentifizierungsfehler: ${errorMsg}`), false);
}
// Erfolg: kanonischen Benutzernamen mitgeben, damit die DB-Lookup mit dem Sync-Benutzernamen funktioniert
console.log('[LDAP] Bind successful!');
console.log('[LDAP] Returning canonicalUsername:', canonicalUsername);
console.log('========== LDAP AUTHENTICATE - SUCCESS ==========\n');
callback(null, true, { username: canonicalUsername });
});
}
/**
* Vollständige Synchronisation durchführen
*/
static performSync(syncType, callback) {
const startedAt = new Date();
// Konfiguration abrufen
this.getConfig((err, config) => {
if (err) {
this.createSyncLog(syncType, 'error', 0, `Fehler beim Abrufen der Konfiguration: ${err.message}`, () => {});
return callback(err);
}
if (!config || !config.enabled) {
const errorMsg = 'LDAP-Synchronisation ist nicht aktiviert';
this.createSyncLog(syncType, 'error', 0, errorMsg, () => {});
return callback(new Error(errorMsg));
}
// LDAP-Verbindung herstellen
this.connect(config, (err, client) => {
if (err) {
this.createSyncLog(syncType, 'error', 0, `LDAP-Verbindungsfehler: ${err.message}`, () => {});
return callback(err);
}
// Benutzer aus LDAP abrufen
this.searchUsers(client, config, (err, ldapUsers) => {
// Verbindung schließen
client.unbind();
if (err) {
this.createSyncLog(syncType, 'error', 0, `LDAP-Suchfehler: ${err.message}`, () => {});
return callback(err);
}
// Benutzer synchronisieren
this.syncUsers(ldapUsers, (err, result) => {
if (err) {
this.createSyncLog(syncType, 'error', result.synced, `Sync-Fehler: ${err.message}`, () => {});
return callback(err);
}
// Letzte Synchronisation aktualisieren
this.updateLastSync(() => {
const status = result.errors.length > 0 ? 'error' : 'success';
const errorMsg = result.errors.length > 0 ? result.errors.join('; ') : null;
this.createSyncLog(syncType, status, result.synced, errorMsg, () => {
callback(null, result);
});
});
});
});
});
});
}
}
module.exports = LDAPService;