LDAP sync
This commit is contained in:
@@ -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
|
||||||
|
|||||||
BIN
data/crm.db
BIN
data/crm.db
Binary file not shown.
@@ -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'))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
10
server/db.js
10
server/db.js
@@ -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'",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
const cfg = loadIntegrations().ldap;
|
ldapSchedulerTimer = setInterval(() => {
|
||||||
if (!cfg.syncEnabled) return;
|
const cfg = loadIntegrations().ldap;
|
||||||
const m = Math.max(0, Number(cfg.syncIntervalMinutes) || 0);
|
if (!cfg.syncEnabled) return;
|
||||||
if (m <= 0) return;
|
const intervalMin = Math.max(0, Number(cfg.syncIntervalMinutes) || 0);
|
||||||
ldapSyncTimer = setInterval(() => {
|
if (intervalMin <= 0) return;
|
||||||
performLdapSync(db, loadIntegrations, 'automatic').catch((err) =>
|
|
||||||
console.error('LDAP auto-sync:', err),
|
const row = db
|
||||||
);
|
.prepare("SELECT value FROM app_settings WHERE key = 'ldap_last_sync_at'")
|
||||||
}, m * 60 * 1000);
|
.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);
|
||||||
}
|
}
|
||||||
|
|||||||
277
server/ldap-auth.js
Normal file
277
server/ldap-auth.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
if (existing) {
|
const dn = entryDn(entry);
|
||||||
if (existing.source === 'local') continue;
|
const rawUser = getAttr(entry, usernameAttr);
|
||||||
db.prepare(
|
const firstname = getAttr(entry, firstAttr);
|
||||||
`UPDATE users SET ldap_dn = ?, updated_at = datetime('now') WHERE id = ?`,
|
const lastname = getAttr(entry, lastAttr);
|
||||||
).run(dn, existing.id);
|
if (!rawUser || !firstname || !lastname) continue;
|
||||||
n += 1;
|
const username = String(rawUser).trim().toLowerCase();
|
||||||
} else {
|
if (!username) continue;
|
||||||
const id = randomUUID();
|
ldapUsers.push({ username, firstname, lastname, dn });
|
||||||
db.prepare(
|
}
|
||||||
`INSERT INTO users (id, username, password_hash, role, source, ldap_dn, active, updated_at)
|
|
||||||
VALUES (?, ?, NULL, 'user', 'ldap', ?, 1, datetime('now'))`,
|
dbSync.exec('BEGIN');
|
||||||
).run(id, un, dn);
|
try {
|
||||||
n += 1;
|
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');
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user