diff --git a/Dockerfile b/Dockerfile index d71bca8..f2416a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,9 +6,8 @@ WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev -COPY server ./server -COPY public ./public -COPY database ./database +# Anwendungsdateien kopieren +COPY . . ENV NODE_ENV=production ENV PORT=8888 diff --git a/data/crm.db b/data/crm.db index 9745724..596f564 100644 Binary files a/data/crm.db and b/data/crm.db differ diff --git a/database/init.sql b/database/init.sql index e9e4af6..8cd558e 100644 --- a/database/init.sql +++ b/database/init.sql @@ -69,6 +69,8 @@ CREATE TABLE IF NOT EXISTS "users" ( "role" TEXT NOT NULL DEFAULT 'user' CHECK ("role" IN ('admin', 'user')), "source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')), "ldap_dn" TEXT, + "firstname" TEXT, + "lastname" TEXT, "active" INTEGER NOT NULL DEFAULT 1 CHECK ("active" IN (0, 1)), "created_at" TEXT NOT NULL DEFAULT (datetime('now')), "updated_at" TEXT NOT NULL DEFAULT (datetime('now')) diff --git a/docker-compose.yml b/docker-compose.yml index e155e96..9d1d246 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,14 +4,12 @@ services: image: sds-crm:latest restart: unless-stopped ports: - - "${PORT:-8888}:8888" + - "8888:8888" # Hauptserver environment: - PORT: ${PORT:-8888} # Persistente SQLite-Datei und Uploads auf dem Host (Volume unten) - SQLITE_PATH: /data/crm.db - UPLOAD_DIR: /data/uploads NODE_ENV: production SESSION_SECRET: ${SESSION_SECRET:-} volumes: - # Host-Verzeichnis: hier liegt die Datenbank (und uploads/) dauerhaft - - ${CRM_DATA_DIR:-./docker-data}:/data + # Datenbank: Verzeichnis mounten (nicht einzelne Datei – sonst erzeugt Docker ein Verzeichnis) + - ./data:/app/data + - ./uploads:/app/server/data/uploads diff --git a/public/css/style.css b/public/css/style.css index d67f41b..e48326f 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -88,6 +88,18 @@ a:hover { text-decoration: underline; } flex-wrap: wrap; align-items: center; gap: 0.75rem 1rem; + flex: 1; + min-width: 0; +} + +.nav-user-area { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.75rem; +} +.nav-user-area .nav-user { + white-space: nowrap; } .nav-user { diff --git a/public/js/core/layout.js b/public/js/core/layout.js index f45b3b2..5069214 100644 --- a/public/js/core/layout.js +++ b/public/js/core/layout.js @@ -1,6 +1,14 @@ import { apiPost } from '../api.js'; import { esc } from './utils.js'; +function navDisplayName(user) { + if (!user) return ''; + const a = [user.firstName, user.lastName].filter( + (x) => x != null && String(x).trim() !== '', + ); + return a.length ? a.map((x) => String(x).trim()).join(' ') : user.username; +} + /** * @param {string} [activeNav] 'start' | 'machines' | 'tickets' | 'options' | 'users' */ @@ -22,8 +30,10 @@ export function updateNav(st, activeNav = '') { ? `OptionenBenutzer` : '' } - ${esc(st.user.username)} - `; + `; const btn = document.getElementById('btn-logout'); if (btn) { btn.onclick = async () => { diff --git a/public/js/pages/options.js b/public/js/pages/options.js index 51d7b0d..ffe18fd 100644 --- a/public/js/pages/options.js +++ b/public/js/pages/options.js @@ -50,7 +50,7 @@ function applyIntegrationForm(data) { document.getElementById('ldap_lastNameAttribute').value = ldap.lastNameAttribute ?? ''; document.getElementById('ldap_syncIntervalMinutes').value = String( - ldap.syncIntervalMinutes ?? 1440, + ldap.syncIntervalMinutes ?? 0, ); const tv = data.teamviewer || {}; @@ -87,7 +87,12 @@ async function run() { const btn = document.getElementById('btn-ldap-sync-now'); btn.disabled = true; try { - await apiPost('/ldap/sync', {}); + const r = await apiPost('/ldap/sync', {}); + if (r.errors && r.errors.length) { + alert(r.errors.join('\n')); + } else if (r.ok === false && r.error) { + alert(r.error); + } location.reload(); } catch (err) { alert(err.message || String(err)); diff --git a/public/js/pages/users.js b/public/js/pages/users.js index d6d95e8..1c4450d 100644 --- a/public/js/pages/users.js +++ b/public/js/pages/users.js @@ -13,6 +13,11 @@ function showError(msg) { errEl.textContent = msg; } +function formatName(u) { + const a = [u.firstName, u.lastName].filter(Boolean); + return a.length ? a.map((x) => esc(String(x))).join(' ') : '—'; +} + function renderRows(users) { const tbody = document.getElementById('users-table-body'); tbody.innerHTML = users @@ -20,6 +25,7 @@ function renderRows(users) { (u) => ` ${esc(u.username)} + ${formatName(u)} ${u.role === 'admin' ? 'Admin' : 'Benutzer'} ${u.source === 'ldap' ? 'LDAP' : 'Lokal'} ${u.active ? 'Ja' : 'Nein'} diff --git a/public/users.html b/public/users.html index 2edd086..d9c8b40 100644 --- a/public/users.html +++ b/public/users.html @@ -43,6 +43,7 @@ Benutzer + Name Rolle Quelle Aktiv diff --git a/server/db.js b/server/db.js index 79ebe3a..f4e0023 100644 --- a/server/db.js +++ b/server/db.js @@ -131,6 +131,8 @@ if (!tbl) { "role" TEXT NOT NULL DEFAULT 'user' CHECK ("role" IN ('admin', 'user')), "source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')), "ldap_dn" TEXT, + "firstname" TEXT, + "lastname" TEXT, "active" INTEGER NOT NULL DEFAULT 1 CHECK ("active" IN (0, 1)), "created_at" TEXT NOT NULL DEFAULT (datetime('now')), "updated_at" TEXT NOT NULL DEFAULT (datetime('now')) @@ -139,6 +141,14 @@ if (!tbl) { `); } +const userCols = db.prepare('PRAGMA table_info(users)').all(); +if (!userCols.some((c) => c.name === 'firstname')) { + db.exec('ALTER TABLE users ADD COLUMN firstname TEXT'); +} +if (!userCols.some((c) => c.name === 'lastname')) { + db.exec('ALTER TABLE users ADD COLUMN lastname TEXT'); +} + const tblSet = db .prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'", diff --git a/server/index.js b/server/index.js index 36af725..cee3449 100644 --- a/server/index.js +++ b/server/index.js @@ -14,7 +14,7 @@ dotenv.config(); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); -const PORT = process.env.PORT || 3000; +const PORT = process.env.PORT || 8888; app.set('trust proxy', 1); app.use( diff --git a/server/integrations.js b/server/integrations.js index b0171c2..afa9217 100644 --- a/server/integrations.js +++ b/server/integrations.js @@ -12,7 +12,7 @@ const DEFAULT_INTEGRATIONS = { usernameAttribute: 'sAMAccountName', firstNameAttribute: 'givenName', lastNameAttribute: 'sn', - syncIntervalMinutes: 1440, + syncIntervalMinutes: 0, syncEnabled: false, syncNotes: '', }, @@ -50,20 +50,41 @@ export function saveIntegrations(obj) { ).run(json); } -let ldapSyncTimer = null; +let ldapSchedulerTimer = null; +/** Wie Stundenerfassung: alle 5 Minuten prüfen, ob eine Synchronisation fällig ist. */ export function restartLdapSyncScheduler() { - if (ldapSyncTimer) { - clearInterval(ldapSyncTimer); - ldapSyncTimer = null; + if (ldapSchedulerTimer) { + clearInterval(ldapSchedulerTimer); + ldapSchedulerTimer = null; } - const cfg = loadIntegrations().ldap; - if (!cfg.syncEnabled) return; - const m = Math.max(0, Number(cfg.syncIntervalMinutes) || 0); - if (m <= 0) return; - ldapSyncTimer = setInterval(() => { - performLdapSync(db, loadIntegrations, 'automatic').catch((err) => - console.error('LDAP auto-sync:', err), - ); - }, m * 60 * 1000); + ldapSchedulerTimer = setInterval(() => { + const cfg = loadIntegrations().ldap; + if (!cfg.syncEnabled) return; + const intervalMin = Math.max(0, Number(cfg.syncIntervalMinutes) || 0); + if (intervalMin <= 0) return; + + const row = db + .prepare("SELECT value FROM app_settings WHERE key = 'ldap_last_sync_at'") + .get(); + const lastSync = row?.value ? new Date(row.value) : null; + const now = new Date(); + const syncIntervalMs = intervalMin * 60 * 1000; + + if (!lastSync || now - lastSync >= syncIntervalMs) { + console.log('Starte automatische LDAP-Synchronisation…'); + performLdapSync(db, loadIntegrations, 'automatic') + .then((r) => { + if (r.skipped) return; + if (r.ok) { + console.log( + `Automatische LDAP-Synchronisation abgeschlossen: ${r.usersSynced} Benutzer synchronisiert`, + ); + } else { + console.error('LDAP auto-sync:', r.error || r.errors); + } + }) + .catch((err) => console.error('LDAP auto-sync:', err)); + } + }, 5 * 60 * 1000); } diff --git a/server/ldap-auth.js b/server/ldap-auth.js new file mode 100644 index 0000000..03cda38 --- /dev/null +++ b/server/ldap-auth.js @@ -0,0 +1,277 @@ +/** + * LDAP-Anmeldung analog zu Stundenerfassung (services/ldap-service.js): + * Service-Bind, Benutzersuche (exakt + Varianten ß/ss), Bind mit Nutzerpasswort. + */ +import ldap from 'ldapjs'; + +function escapeLDAPFilter(value) { + if (!value) return ''; + return String(value) + .replace(/\\/g, '\\5c') + .replace(/\*/g, '\\2a') + .replace(/\(/g, '\\28') + .replace(/\)/g, '\\29') + .replace(/\0/g, '\\00'); +} + +function getUsernameSearchVariants(usernameStr) { + const variants = new Set(); + variants.add(usernameStr); + variants.add(usernameStr.toLowerCase()); + variants.add(usernameStr.replace(/\u00df/g, 'ss')); + variants.add(usernameStr.replace(/ss/g, '\u00df')); + return [...variants]; +} + +function unescapeLdapDN(dn) { + if (!dn || typeof dn !== 'string') return dn; + let result = ''; + const bytes = []; + let i = 0; + while (i < dn.length) { + if (dn[i] === '\\' && i + 2 < dn.length && /^[0-9a-fA-F]{2}$/.test(dn.slice(i + 1, i + 3))) { + bytes.push(parseInt(dn.slice(i + 1, i + 3), 16)); + i += 3; + } else { + if (bytes.length > 0) { + result += Buffer.from(bytes).toString('utf8'); + bytes.length = 0; + } + result += dn[i]; + i++; + } + } + if (bytes.length > 0) { + result += Buffer.from(bytes).toString('utf8'); + } + return result; +} + +function getAttributeValue(entry, attributeName) { + const want = String(attributeName || '').toLowerCase(); + for (const attr of entry.attributes || []) { + if (attr.type.toLowerCase() === want) { + const value = Array.isArray(attr.values) ? attr.values[0] : attr.values; + return value != null ? String(value) : null; + } + } + return null; +} + +function entryUserDn(entry) { + try { + if (entry.dn != null) return entry.dn.toString(); + if (entry.objectName != null) return entry.objectName.toString(); + } catch { + /* ignore */ + } + return ''; +} + +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 searchWithEvents(client, baseDN, options) { + return new Promise((resolve, reject) => { + client.search(baseDN, options, (err, res) => { + if (err) return reject(err); + const entries = []; + res.on('searchEntry', (entry) => entries.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(entries); + }); + }); + }); +} + +/** + * @param {string} usernameInput Roher Benutzername (Trim, kein toLowerCase — für UTF-8/AD) + * @param {string} password + * @param {() => object} loadIntegrations + * @returns {Promise<{ ok: true, canonicalUsername: string } | { ok: false, skipLdap?: true, ldapAttempted?: true, message?: string }>} + */ +export async function authenticateLdap(usernameInput, password, loadIntegrations) { + const usernameStr = String(usernameInput || '').trim(); + if (!usernameStr) { + return { + ok: false, + ldapAttempted: true, + message: 'Benutzername darf nicht leer sein', + }; + } + + const raw = loadIntegrations().ldap || {}; + if (!raw.syncEnabled) { + return { ok: false, skipLdap: true }; + } + + const url = String(raw.serverUrl || '').trim(); + const baseDN = String(raw.searchBase || '').trim(); + if (!url || !baseDN) { + return { ok: false, skipLdap: true }; + } + + const usernameAttr = String(raw.usernameAttribute || 'sAMAccountName').trim(); + + const client = ldap.createClient({ + url, + timeout: 10000, + connectTimeout: 10000, + reconnect: false, + }); + client.on('error', () => {}); + + try { + await bindAsync( + client, + String(raw.bindDn || '').trim(), + raw.bindPassword != null ? String(raw.bindPassword) : '', + ); + } catch (e) { + await unbindAsync(client); + return { + ok: false, + ldapAttempted: true, + message: e && e.message ? String(e.message) : String(e), + }; + } + + const exactFilter = `(${usernameAttr}=${escapeLDAPFilter(usernameStr)})`; + let userDN = null; + let canonicalUsername = null; + + const applyEntries = (entries, fallbackName) => { + for (const entry of entries) { + const dn = entryUserDn(entry); + if (dn) { + userDN = dn; + canonicalUsername = + getAttributeValue(entry, usernameAttr) || fallbackName; + } + } + }; + + const runVariantSearch = async () => { + userDN = null; + canonicalUsername = null; + const variants = getUsernameSearchVariants(usernameStr); + const filterParts = variants.map( + (v) => `(${usernameAttr}=${escapeLDAPFilter(v)})`, + ); + const searchFilter = + filterParts.length === 1 ? filterParts[0] : `(|${filterParts.join('')})`; + const entries = await searchWithEvents(client, baseDN, { + filter: searchFilter, + scope: 'sub', + attributes: ['dn', usernameAttr], + }); + applyEntries(entries, usernameStr); + }; + + try { + const entries = await searchWithEvents(client, baseDN, { + filter: exactFilter, + scope: 'sub', + attributes: ['dn', usernameAttr], + }); + applyEntries(entries, usernameStr); + } catch { + try { + await runVariantSearch(); + } catch (e) { + await unbindAsync(client); + return { + ok: false, + ldapAttempted: true, + message: e && e.message ? String(e.message) : String(e), + }; + } + } + + if (!userDN) { + try { + await runVariantSearch(); + } catch (e) { + await unbindAsync(client); + return { + ok: false, + ldapAttempted: true, + message: e && e.message ? String(e.message) : String(e), + }; + } + } + + if (!userDN) { + await unbindAsync(client); + return { + ok: false, + ldapAttempted: true, + message: `Benutzer „${usernameStr}“ nicht gefunden. Prüfen Sie den Benutzernamen (z. B. UTF-8-Zeichen).`, + }; + } + + await unbindAsync(client); + + const bindDN = unescapeLdapDN(userDN); + const authClient = ldap.createClient({ + url, + timeout: 10000, + connectTimeout: 10000, + reconnect: false, + }); + + try { + await new Promise((resolve, reject) => { + authClient.on('error', (err) => { + try { + authClient.unbind(); + } catch { + /* ignore */ + } + reject(err); + }); + authClient.bind(bindDN, password, (err) => { + if (err) { + const errorMsg = err.message || String(err); + try { + authClient.unbind(); + } catch { + /* ignore */ + } + return reject( + new Error(`Ungültiges Passwort oder Authentifizierungsfehler: ${errorMsg}`), + ); + } + authClient.unbind(() => resolve()); + }); + }); + } catch (e) { + return { + ok: false, + ldapAttempted: true, + message: e && e.message ? String(e.message) : String(e), + }; + } + + return { + ok: true, + canonicalUsername: canonicalUsername || usernameStr, + }; +} diff --git a/server/ldap-sync.js b/server/ldap-sync.js index f358f6b..54ce48a 100644 --- a/server/ldap-sync.js +++ b/server/ldap-sync.js @@ -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, }; } diff --git a/server/lib/mappers.js b/server/lib/mappers.js index cde0844..839ab26 100644 --- a/server/lib/mappers.js +++ b/server/lib/mappers.js @@ -88,6 +88,8 @@ export function mapPublicUser(r) { return { id: r.id, username: r.username, + firstName: r.firstname ?? null, + lastName: r.lastname ?? null, role: r.role, active: Boolean(r.active), source: r.source, diff --git a/server/routes/admin/index.js b/server/routes/admin/index.js index 72d632e..ccdae33 100644 --- a/server/routes/admin/index.js +++ b/server/routes/admin/index.js @@ -19,7 +19,7 @@ export function createAdminRouter() { admin.get('/users', (_req, res) => { const rows = db .prepare( - 'SELECT id, username, role, source, active, ldap_dn, created_at, updated_at FROM users ORDER BY username ASC', + 'SELECT id, username, firstname, lastname, role, source, active, ldap_dn, created_at, updated_at FROM users ORDER BY username ASC', ) .all(); res.json(rows.map(mapPublicUser)); @@ -138,7 +138,7 @@ export function createAdminRouter() { } if (incoming.syncIntervalMinutes != null) { const n = Number(incoming.syncIntervalMinutes); - incoming.syncIntervalMinutes = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 1440; + incoming.syncIntervalMinutes = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0; } Object.assign(cur.ldap, incoming); if (b.ldap.userSearchFilter != null) { diff --git a/server/routes/auth.js b/server/routes/auth.js index 4f4c988..355d744 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -1,11 +1,24 @@ import { randomUUID } from 'crypto'; import { Router } from 'express'; import db from '../db.js'; +import { loadIntegrations } from '../integrations.js'; +import { authenticateLdap } from '../ldap-auth.js'; import { badRequest } from '../lib/http.js'; import { hashPassword, verifyPassword } from '../password.js'; const router = Router(); +function userPayload(row) { + if (!row) return null; + return { + id: row.id, + username: row.username, + role: row.role, + firstName: row.firstname ?? null, + lastName: row.lastname ?? null, + }; +} + router.get('/status', (req, res) => { const count = db.prepare('SELECT COUNT(*) AS c FROM users').get().c; const needsBootstrap = count === 0; @@ -16,7 +29,9 @@ router.get('/status', (req, res) => { return res.json({ needsBootstrap: false, loggedIn: false, user: null }); } const u = db - .prepare('SELECT id, username, role, active FROM users WHERE id = ?') + .prepare( + 'SELECT id, username, firstname, lastname, role, active FROM users WHERE id = ?', + ) .get(req.session.userId); if (!u || !u.active) { req.session.destroy(() => {}); @@ -25,7 +40,7 @@ router.get('/status', (req, res) => { res.json({ needsBootstrap: false, loggedIn: true, - user: { id: u.id, username: u.username, role: u.role }, + user: userPayload(u), }); }); @@ -51,25 +66,75 @@ router.post('/bootstrap', async (req, res) => { req.session.role = 'admin'; req.session.username = un; res.status(201).json({ - user: { id, username: un, role: 'admin' }, + user: userPayload( + db.prepare( + 'SELECT id, username, firstname, lastname, role FROM users WHERE id = ?', + ).get(id), + ), }); }); router.post('/login', async (req, res) => { const { username, password } = req.body || {}; - const un = String(username || '') - .trim() - .toLowerCase(); - if (!un || !password) { + const rawUsername = String(username || '').trim(); + const unLower = rawUsername.toLowerCase(); + if (!rawUsername || !password) { return badRequest(res, 'Benutzername und Passwort erforderlich.'); } - const u = db.prepare('SELECT * FROM users WHERE username = ?').get(un); + + const ldapCfg = loadIntegrations().ldap || {}; + const useLdap = + Boolean(ldapCfg.syncEnabled) && + String(ldapCfg.serverUrl || '').trim() !== '' && + String(ldapCfg.searchBase || '').trim() !== ''; + + if (useLdap) { + const ar = await authenticateLdap(rawUsername, password, loadIntegrations); + if (ar.ok) { + const lookup = ar.canonicalUsername || rawUsername; + const u = db + .prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE') + .get(lookup); + if (!u || !u.active) { + return res.status(401).json({ + message: + 'Benutzer nach LDAP nicht in der Anwendung gefunden. Bitte zuerst eine LDAP-Synchronisation ausführen.', + }); + } + req.session.userId = u.id; + req.session.role = u.role; + req.session.username = u.username; + return res.json({ + user: userPayload(u), + }); + } + if (!ar.skipLdap) { + const u = db + .prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE') + .get(unLower); + if (u && u.active && u.password_hash) { + const pwOk = await verifyPassword(password, u.password_hash); + if (pwOk) { + req.session.userId = u.id; + req.session.role = u.role; + req.session.username = u.username; + return res.json({ + user: userPayload(u), + }); + } + } + return res.status(401).json({ message: 'Ungültige Zugangsdaten.' }); + } + } + + const u = db.prepare('SELECT * FROM users WHERE username = ?').get(unLower); if (!u || !u.active) { return res.status(401).json({ message: 'Ungültige Zugangsdaten.' }); } if (!u.password_hash) { return res.status(401).json({ - message: 'Kein lokales Passwort (LDAP). Anmeldung folgt mit Verzeichnis-Sync.', + message: + 'Kein lokales Passwort. LDAP ist nicht konfiguriert oder die Anmeldung ist nur mit Verzeichnis möglich.', }); } const ok = await verifyPassword(password, u.password_hash); @@ -78,7 +143,7 @@ router.post('/login', async (req, res) => { req.session.role = u.role; req.session.username = u.username; res.json({ - user: { id: u.id, username: u.username, role: u.role }, + user: userPayload(u), }); });