V1.0
This commit is contained in:
417
services/ldap-service.js
Normal file
417
services/ldap-service.js
Normal file
@@ -0,0 +1,417 @@
|
||||
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) {
|
||||
if (!value) return '';
|
||||
|
||||
// Stelle sicher, dass der Wert als String behandelt wird
|
||||
const str = String(value);
|
||||
|
||||
// Escape nur die speziellen LDAP-Filter-Zeichen
|
||||
// UTF-8-Zeichen wie ß, ä, ö, ü werden direkt verwendet
|
||||
return 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// LDAP-Verbindung herstellen (mit Service-Account)
|
||||
this.connect(config, (err, client) => {
|
||||
if (err) {
|
||||
return callback(err, false);
|
||||
}
|
||||
|
||||
const baseDN = config.base_dn || '';
|
||||
const usernameAttr = config.username_attribute || 'sAMAccountName';
|
||||
// OR-Filter mit mehreren Varianten (exakt, lowercase, ß/ss), damit Login trotz unterschiedlicher Schreibweise funktioniert
|
||||
const variants = this.getUsernameSearchVariants(usernameStr);
|
||||
const filterParts = variants.map(v => `(${usernameAttr}=${this.escapeLDAPFilter(v)})`);
|
||||
const searchFilter = filterParts.length === 1 ? filterParts[0] : `(|${filterParts.join('')})`;
|
||||
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;
|
||||
});
|
||||
|
||||
res.on('error', (err) => {
|
||||
client.unbind();
|
||||
const errorMsg = err.message || String(err);
|
||||
callback(new Error(`LDAP-Suchfehler: ${errorMsg}`), false);
|
||||
});
|
||||
|
||||
res.on('end', (result) => {
|
||||
client.unbind();
|
||||
|
||||
if (!userDN) {
|
||||
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);
|
||||
}
|
||||
|
||||
const authClient = ldap.createClient({
|
||||
url: config.url,
|
||||
timeout: 10000,
|
||||
connectTimeout: 10000
|
||||
});
|
||||
|
||||
authClient.on('error', (err) => {
|
||||
authClient.unbind();
|
||||
callback(err, false);
|
||||
});
|
||||
|
||||
authClient.bind(userDN, password, (err) => {
|
||||
authClient.unbind();
|
||||
if (err) {
|
||||
const errorMsg = err.message || String(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
|
||||
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;
|
||||
Reference in New Issue
Block a user