LDAP sync

This commit is contained in:
2026-03-23 02:42:19 +01:00
parent 2934be0433
commit e75a2e5e20
17 changed files with 595 additions and 94 deletions

View File

@@ -6,9 +6,8 @@ WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
COPY server ./server # Anwendungsdateien kopieren
COPY public ./public COPY . .
COPY database ./database
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=8888 ENV PORT=8888

Binary file not shown.

View File

@@ -69,6 +69,8 @@ CREATE TABLE IF NOT EXISTS "users" (
"role" TEXT NOT NULL DEFAULT 'user' CHECK ("role" IN ('admin', 'user')), "role" TEXT NOT NULL DEFAULT 'user' CHECK ("role" IN ('admin', 'user')),
"source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')), "source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')),
"ldap_dn" TEXT, "ldap_dn" TEXT,
"firstname" TEXT,
"lastname" TEXT,
"active" INTEGER NOT NULL DEFAULT 1 CHECK ("active" IN (0, 1)), "active" INTEGER NOT NULL DEFAULT 1 CHECK ("active" IN (0, 1)),
"created_at" TEXT NOT NULL DEFAULT (datetime('now')), "created_at" TEXT NOT NULL DEFAULT (datetime('now')),
"updated_at" TEXT NOT NULL DEFAULT (datetime('now')) "updated_at" TEXT NOT NULL DEFAULT (datetime('now'))

View File

@@ -4,14 +4,12 @@ services:
image: sds-crm:latest image: sds-crm:latest
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${PORT:-8888}:8888" - "8888:8888" # Hauptserver
environment: environment:
PORT: ${PORT:-8888}
# Persistente SQLite-Datei und Uploads auf dem Host (Volume unten) # Persistente SQLite-Datei und Uploads auf dem Host (Volume unten)
SQLITE_PATH: /data/crm.db
UPLOAD_DIR: /data/uploads
NODE_ENV: production NODE_ENV: production
SESSION_SECRET: ${SESSION_SECRET:-} SESSION_SECRET: ${SESSION_SECRET:-}
volumes: volumes:
# Host-Verzeichnis: hier liegt die Datenbank (und uploads/) dauerhaft # Datenbank: Verzeichnis mounten (nicht einzelne Datei sonst erzeugt Docker ein Verzeichnis)
- ${CRM_DATA_DIR:-./docker-data}:/data - ./data:/app/data
- ./uploads:/app/server/data/uploads

View File

@@ -88,6 +88,18 @@ a:hover { text-decoration: underline; }
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.75rem 1rem; 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 { .nav-user {

View File

@@ -1,6 +1,14 @@
import { apiPost } from '../api.js'; import { apiPost } from '../api.js';
import { esc } from './utils.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' * @param {string} [activeNav] 'start' | 'machines' | 'tickets' | 'options' | 'users'
*/ */
@@ -22,8 +30,10 @@ export function updateNav(st, activeNav = '') {
? `<a href="/options.html" class="${na('options')}">Optionen</a><a href="/users.html" class="${na('users')}">Benutzer</a>` ? `<a href="/options.html" class="${na('options')}">Optionen</a><a href="/users.html" class="${na('users')}">Benutzer</a>`
: '' : ''
} }
<span class="nav-user muted">${esc(st.user.username)}</span> <div class="nav-user-area">
<button type="button" class="secondary btn-nav-logout" id="btn-logout">Abmelden</button>`; <span class="nav-user muted">${esc(navDisplayName(st.user))}</span>
<button type="button" class="danger btn-nav-logout" id="btn-logout">Abmelden</button>
</div>`;
const btn = document.getElementById('btn-logout'); const btn = document.getElementById('btn-logout');
if (btn) { if (btn) {
btn.onclick = async () => { btn.onclick = async () => {

View File

@@ -50,7 +50,7 @@ function applyIntegrationForm(data) {
document.getElementById('ldap_lastNameAttribute').value = document.getElementById('ldap_lastNameAttribute').value =
ldap.lastNameAttribute ?? ''; ldap.lastNameAttribute ?? '';
document.getElementById('ldap_syncIntervalMinutes').value = String( document.getElementById('ldap_syncIntervalMinutes').value = String(
ldap.syncIntervalMinutes ?? 1440, ldap.syncIntervalMinutes ?? 0,
); );
const tv = data.teamviewer || {}; const tv = data.teamviewer || {};
@@ -87,7 +87,12 @@ async function run() {
const btn = document.getElementById('btn-ldap-sync-now'); const btn = document.getElementById('btn-ldap-sync-now');
btn.disabled = true; btn.disabled = true;
try { 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(); location.reload();
} catch (err) { } catch (err) {
alert(err.message || String(err)); alert(err.message || String(err));

View File

@@ -13,6 +13,11 @@ function showError(msg) {
errEl.textContent = 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) { function renderRows(users) {
const tbody = document.getElementById('users-table-body'); const tbody = document.getElementById('users-table-body');
tbody.innerHTML = users tbody.innerHTML = users
@@ -20,6 +25,7 @@ function renderRows(users) {
(u) => ` (u) => `
<tr data-id="${esc(u.id)}"> <tr data-id="${esc(u.id)}">
<td>${esc(u.username)}</td> <td>${esc(u.username)}</td>
<td class="muted">${formatName(u)}</td>
<td><span class="badge">${u.role === 'admin' ? 'Admin' : 'Benutzer'}</span></td> <td><span class="badge">${u.role === 'admin' ? 'Admin' : 'Benutzer'}</span></td>
<td class="muted">${u.source === 'ldap' ? 'LDAP' : 'Lokal'}</td> <td class="muted">${u.source === 'ldap' ? 'LDAP' : 'Lokal'}</td>
<td>${u.active ? 'Ja' : 'Nein'}</td> <td>${u.active ? 'Ja' : 'Nein'}</td>

View File

@@ -43,6 +43,7 @@
<thead> <thead>
<tr> <tr>
<th>Benutzer</th> <th>Benutzer</th>
<th>Name</th>
<th>Rolle</th> <th>Rolle</th>
<th>Quelle</th> <th>Quelle</th>
<th>Aktiv</th> <th>Aktiv</th>

View File

@@ -131,6 +131,8 @@ if (!tbl) {
"role" TEXT NOT NULL DEFAULT 'user' CHECK ("role" IN ('admin', 'user')), "role" TEXT NOT NULL DEFAULT 'user' CHECK ("role" IN ('admin', 'user')),
"source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')), "source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')),
"ldap_dn" TEXT, "ldap_dn" TEXT,
"firstname" TEXT,
"lastname" TEXT,
"active" INTEGER NOT NULL DEFAULT 1 CHECK ("active" IN (0, 1)), "active" INTEGER NOT NULL DEFAULT 1 CHECK ("active" IN (0, 1)),
"created_at" TEXT NOT NULL DEFAULT (datetime('now')), "created_at" TEXT NOT NULL DEFAULT (datetime('now')),
"updated_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 const tblSet = db
.prepare( .prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'", "SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'",

View File

@@ -14,7 +14,7 @@ dotenv.config();
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 8888;
app.set('trust proxy', 1); app.set('trust proxy', 1);
app.use( app.use(

View File

@@ -12,7 +12,7 @@ const DEFAULT_INTEGRATIONS = {
usernameAttribute: 'sAMAccountName', usernameAttribute: 'sAMAccountName',
firstNameAttribute: 'givenName', firstNameAttribute: 'givenName',
lastNameAttribute: 'sn', lastNameAttribute: 'sn',
syncIntervalMinutes: 1440, syncIntervalMinutes: 0,
syncEnabled: false, syncEnabled: false,
syncNotes: '', syncNotes: '',
}, },
@@ -50,20 +50,41 @@ export function saveIntegrations(obj) {
).run(json); ).run(json);
} }
let ldapSyncTimer = null; let ldapSchedulerTimer = null;
/** Wie Stundenerfassung: alle 5 Minuten prüfen, ob eine Synchronisation fällig ist. */
export function restartLdapSyncScheduler() { export function restartLdapSyncScheduler() {
if (ldapSyncTimer) { if (ldapSchedulerTimer) {
clearInterval(ldapSyncTimer); clearInterval(ldapSchedulerTimer);
ldapSyncTimer = null; ldapSchedulerTimer = null;
} }
ldapSchedulerTimer = setInterval(() => {
const cfg = loadIntegrations().ldap; const cfg = loadIntegrations().ldap;
if (!cfg.syncEnabled) return; if (!cfg.syncEnabled) return;
const m = Math.max(0, Number(cfg.syncIntervalMinutes) || 0); const intervalMin = Math.max(0, Number(cfg.syncIntervalMinutes) || 0);
if (m <= 0) return; if (intervalMin <= 0) return;
ldapSyncTimer = setInterval(() => {
performLdapSync(db, loadIntegrations, 'automatic').catch((err) => const row = db
console.error('LDAP auto-sync:', err), .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`,
); );
}, m * 60 * 1000); } else {
console.error('LDAP auto-sync:', r.error || r.errors);
}
})
.catch((err) => console.error('LDAP auto-sync:', err));
}
}, 5 * 60 * 1000);
} }

277
server/ldap-auth.js Normal file
View File

@@ -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,
};
}

View File

@@ -1,5 +1,6 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import ldap from 'ldapjs'; import ldap from 'ldapjs';
import { hashPassword } from './password.js';
let syncRunning = false; let syncRunning = false;
@@ -7,13 +8,23 @@ function getAttr(entry, name) {
const want = String(name || '').toLowerCase(); const want = String(name || '').toLowerCase();
for (const attr of entry.attributes) { for (const attr of entry.attributes) {
if (attr.type.toLowerCase() === want) { 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 v != null ? String(v).trim() : '';
} }
} }
return ''; 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) { function searchAsync(client, base, options) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const results = []; const results = [];
@@ -21,7 +32,14 @@ function searchAsync(client, base, options) {
if (err) return reject(err); if (err) return reject(err);
res.on('searchEntry', (entry) => results.push(entry)); res.on('searchEntry', (entry) => results.push(entry));
res.on('error', reject); 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) { function recordLdapLastSync(dbSync) {
db.prepare( 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) `INSERT INTO ldap_sync_log (id, started_at, finished_at, trigger_type, status, users_synced, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run( ).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 {() => object} loadIntegrations
* @param {'manual' | 'automatic'} trigger * @param {'manual' | 'automatic'} trigger
*/ */
export async function performLdapSync(db, loadIntegrations, trigger) { export async function performLdapSync(dbSync, loadIntegrations, trigger) {
if (syncRunning) { if (syncRunning) {
return { skipped: true, message: 'Synchronisation läuft bereits.' }; return { skipped: true, message: 'Synchronisation läuft bereits.' };
} }
@@ -71,27 +101,37 @@ export async function performLdapSync(db, loadIntegrations, trigger) {
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
let usersSynced = 0; let usersSynced = 0;
let errorMessage = null; let errorMessage = null;
/** @type {string[]} */
const rowErrors = [];
let client = null; let client = null;
try { try {
const config = loadIntegrations().ldap || {}; const config = loadIntegrations().ldap || {};
if (!config.syncEnabled) {
throw new Error('LDAP-Synchronisation ist nicht aktiviert');
}
const serverUrl = String(config.serverUrl || '').trim(); const serverUrl = String(config.serverUrl || '').trim();
const searchBase = String(config.searchBase || '').trim(); const searchBase = String(config.searchBase || '').trim();
const filter = String( const filterRaw = String(
config.userSearchFilter || config.userFilter || '', config.userSearchFilter || config.userFilter || '',
).trim(); ).trim();
const filter = filterRaw || '(objectClass=person)';
const usernameAttr = String( const usernameAttr = String(
config.usernameAttribute || 'sAMAccountName', config.usernameAttribute || 'sAMAccountName',
).trim(); ).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 (!serverUrl) throw new Error('LDAP-Server URL fehlt.');
if (!searchBase) throw new Error('Base DN fehlt.'); if (!searchBase) throw new Error('Base DN fehlt.');
if (!filter) throw new Error('User Search Filter fehlt.');
client = ldap.createClient({ client = ldap.createClient({
url: serverUrl, url: serverUrl,
timeout: 120000, timeout: 10000,
connectTimeout: 20000, connectTimeout: 10000,
reconnect: false, reconnect: false,
}); });
@@ -106,53 +146,90 @@ export async function performLdapSync(db, loadIntegrations, trigger) {
const entries = await searchAsync(client, searchBase, { const entries = await searchAsync(client, searchBase, {
scope: 'sub', scope: 'sub',
filter, filter,
attributes: [usernameAttr], attributes: [usernameAttr, firstAttr, lastAttr],
}); });
await unbindAsync(client); await unbindAsync(client);
client = null; client = null;
db.exec('BEGIN'); const defaultPwHash = await hashPassword('changeme123');
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 existing = db /** @type {{ username: string, firstname: string, lastname: string, dn: string }[]} */
.prepare('SELECT id, source FROM users WHERE username = ?') const ldapUsers = [];
.get(un); 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) {
if (existing.source === 'local') continue; if (existing.source === 'ldap') {
db.prepare( dbSync
`UPDATE users SET ldap_dn = ?, updated_at = datetime('now') WHERE id = ?`, .prepare(
).run(dn, existing.id); `UPDATE users SET firstname = ?, lastname = ?, ldap_dn = ?, updated_at = datetime('now')
n += 1; 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 { } else {
const id = randomUUID(); const id = randomUUID();
db.prepare( dbSync
`INSERT INTO users (id, username, password_hash, role, source, ldap_dn, active, updated_at) .prepare(
VALUES (?, ?, NULL, 'user', 'ldap', ?, 1, datetime('now'))`, `INSERT INTO users (id, username, password_hash, role, source, ldap_dn, firstname, lastname, active, updated_at)
).run(id, un, dn); VALUES (?, ?, ?, 'user', 'ldap', ?, ?, ?, 1, datetime('now'))`,
n += 1; )
.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'); dbSync.exec('COMMIT');
usersSynced = n; recordLdapLastSync(dbSync);
} catch (e) { } catch (e) {
try { try {
db.exec('ROLLBACK'); dbSync.exec('ROLLBACK');
} catch { } catch {
/* ignore */ /* ignore */
} }
throw e; throw e;
} }
if (rowErrors.length > 0) {
errorMessage = rowErrors.join('; ');
}
} catch (e) { } catch (e) {
errorMessage = e && e.message ? String(e.message) : String(e); errorMessage = e && e.message ? String(e.message) : String(e);
if (client) { if (client) {
@@ -166,44 +243,60 @@ export async function performLdapSync(db, loadIntegrations, trigger) {
} finally { } finally {
try { try {
const finishedAt = new Date().toISOString(); const finishedAt = new Date().toISOString();
const status = errorMessage ? 'error' : 'success'; const hasRowErr = rowErrors.length > 0;
insertLog(db, { const status = errorMessage || hasRowErr ? 'error' : 'success';
const logErr =
errorMessage || (hasRowErr ? rowErrors.join('; ') : null);
insertLog(dbSync, {
id: logId, id: logId,
startedAt, startedAt,
finishedAt, finishedAt,
triggerType: trigger, triggerType: trigger,
status, status,
usersSynced: errorMessage ? 0 : usersSynced, usersSynced,
errorMessage, errorMessage: logErr,
}); });
} finally { } finally {
syncRunning = false; syncRunning = false;
} }
} }
if (errorMessage) { const hasRowErr = rowErrors.length > 0;
return { ok: false, usersSynced: 0, error: errorMessage }; 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) { export function getSyncStatus(dbSync) {
const last = db const lastSetting = dbSync
.prepare("SELECT value FROM app_settings WHERE key = 'ldap_last_sync_at'")
.get();
const lastLog = dbSync
.prepare( .prepare(
`SELECT finished_at FROM ldap_sync_log ORDER BY finished_at DESC LIMIT 1`, `SELECT finished_at FROM ldap_sync_log ORDER BY finished_at DESC LIMIT 1`,
) )
.get(); .get();
const entries = db const lastSyncAt = lastSetting?.value ?? lastLog?.finished_at ?? null;
const entries = dbSync
.prepare( .prepare(
`SELECT finished_at AS finishedAt, trigger_type AS triggerType, status, users_synced AS usersSynced, error_message AS errorMessage `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`, FROM ldap_sync_log ORDER BY finished_at DESC LIMIT 10`,
) )
.all(); .all();
return { return {
lastSyncAt: last?.finished_at ?? null, lastSyncAt,
entries, entries,
}; };
} }

View File

@@ -88,6 +88,8 @@ export function mapPublicUser(r) {
return { return {
id: r.id, id: r.id,
username: r.username, username: r.username,
firstName: r.firstname ?? null,
lastName: r.lastname ?? null,
role: r.role, role: r.role,
active: Boolean(r.active), active: Boolean(r.active),
source: r.source, source: r.source,

View File

@@ -19,7 +19,7 @@ export function createAdminRouter() {
admin.get('/users', (_req, res) => { admin.get('/users', (_req, res) => {
const rows = db const rows = db
.prepare( .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(); .all();
res.json(rows.map(mapPublicUser)); res.json(rows.map(mapPublicUser));
@@ -138,7 +138,7 @@ export function createAdminRouter() {
} }
if (incoming.syncIntervalMinutes != null) { if (incoming.syncIntervalMinutes != null) {
const n = Number(incoming.syncIntervalMinutes); 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); Object.assign(cur.ldap, incoming);
if (b.ldap.userSearchFilter != null) { if (b.ldap.userSearchFilter != null) {

View File

@@ -1,11 +1,24 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { Router } from 'express'; import { Router } from 'express';
import db from '../db.js'; import db from '../db.js';
import { loadIntegrations } from '../integrations.js';
import { authenticateLdap } from '../ldap-auth.js';
import { badRequest } from '../lib/http.js'; import { badRequest } from '../lib/http.js';
import { hashPassword, verifyPassword } from '../password.js'; import { hashPassword, verifyPassword } from '../password.js';
const router = Router(); 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) => { router.get('/status', (req, res) => {
const count = db.prepare('SELECT COUNT(*) AS c FROM users').get().c; const count = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
const needsBootstrap = count === 0; const needsBootstrap = count === 0;
@@ -16,7 +29,9 @@ router.get('/status', (req, res) => {
return res.json({ needsBootstrap: false, loggedIn: false, user: null }); return res.json({ needsBootstrap: false, loggedIn: false, user: null });
} }
const u = db 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); .get(req.session.userId);
if (!u || !u.active) { if (!u || !u.active) {
req.session.destroy(() => {}); req.session.destroy(() => {});
@@ -25,7 +40,7 @@ router.get('/status', (req, res) => {
res.json({ res.json({
needsBootstrap: false, needsBootstrap: false,
loggedIn: true, 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.role = 'admin';
req.session.username = un; req.session.username = un;
res.status(201).json({ 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) => { router.post('/login', async (req, res) => {
const { username, password } = req.body || {}; const { username, password } = req.body || {};
const un = String(username || '') const rawUsername = String(username || '').trim();
.trim() const unLower = rawUsername.toLowerCase();
.toLowerCase(); if (!rawUsername || !password) {
if (!un || !password) {
return badRequest(res, 'Benutzername und Passwort erforderlich.'); 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) { if (!u || !u.active) {
return res.status(401).json({ message: 'Ungültige Zugangsdaten.' }); return res.status(401).json({ message: 'Ungültige Zugangsdaten.' });
} }
if (!u.password_hash) { if (!u.password_hash) {
return res.status(401).json({ 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); 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.role = u.role;
req.session.username = u.username; req.session.username = u.username;
res.json({ res.json({
user: { id: u.id, username: u.username, role: u.role }, user: userPayload(u),
}); });
}); });