Compare commits

20 Commits

Author SHA1 Message Date
Kühn
4d5ab818e9 Robustheits-Fix: RFC-5321-konforme E-Mail-Local-Parts (keine Rand-Punkte/Doppelpunkte) + sAMAccountName-Fallback bei Nicht-Latin-Namen + Release v11 2026-09-17 13:15:27 +02:00
Kühn
1c28917bb5 Umlaut-Fix: zentrale Transliteration (ae/oe/ue/ss) fuer E-Mails, UPN und sAMAccountName + Release v10 2026-09-16 11:27:03 +02:00
Kühn
b369ac955b Release v9: Image-Tag auf DEV2.0-v9 angehoben 2026-09-16 11:06:02 +02:00
Kühn
ac7892dd32 Bugfix: lastInsertRowid in PostgreSQL-Transaktionen (txDb haengt RETURNING id an) — behob 500 beim Abschicken von Vorlagen 2026-09-16 11:04:16 +02:00
Kühn
1ee37c402a OIDC-Integration: Keycloak-Login (Authorization Code Flow + PKCE), app-seitige Rollen bleiben unangetastet 2026-09-15 11:41:29 +02:00
Kühn
1d03ab498f Release v8: Image-Tag auf DEV2.0-v8 angehoben 2026-09-10 17:40:07 +02:00
Kühn
6ee8582ce5 Theme-Toggle: Dark/Light-Mode pro Nutzer (localStorage, System-Default, FOUC-Schutz) 2026-09-10 17:34:40 +02:00
Kühn
d43d1a3f34 H3-Fix: TRUST_PROXY konfigurierbar (H3: Rate-Limit-Bypass hinter Proxy geloest) 2026-09-10 17:07:14 +02:00
Kühn
ad6983a49d Stack: Env-Variablen vervollstaendigt (COOKIE_SECURE, HSTS_ENABLED, LDAP_MAX_DELETE_PCT, UPLOAD_MAX_MB) 2026-09-10 17:03:35 +02:00
Kühn
4cccf3b1e8 Stack: Image-Tag auf DEV2.0-v7 aktualisiert 2026-09-10 16:59:18 +02:00
Kühn
1aec65dc95 Security & UX Release v7
Security:
- H1: Stored-XSS-Fix — Upload-Pfad-Whitelist (Server + Frontend-Guard safeFileUrl)
- H2: Transaktionen repariert — txDb-Contract in db.js (PG + SQLite), Rollback funktioniert
- H3-Vorbereitung: SESSION_SECRET wird in Compose durchgereicht (Fix M3)
- registerLimiter exportiert (Crash-Bug: Route.post ohne Callback)
- LDAP-Sync: PG-Transaktionsabbruch bei UNIQUE-Verstoß behoben (Precheck-Selects)
- LDAP-Filter: nur echte Benutzerkonten (keine Computer/Service-Accounts, Bit 512)
- Rollen app-seitig: Sync ändert nie role/status, neue User immer user+inaktiv
- DB-Cleanup: 82 Computer-/Service-Accounts aus lokaler User-Tabelle entfernt

UX:
- Dashboard: Vorlagen als Table-Liste + Column-Chart (Top 5 in %), 2 gleich große Spalten
- Table-Listen (Dashboard/Vorlageneditor/Aufgaben) scrollbar bis Seitenende
- Pagination 10/Seite im Dashboard, Sidebar-Label Dashboard
2026-09-10 16:57:20 +02:00
Kühn
ec2ed91621 Rework CSRF: deterministic token from session + self-healing cookie (no more stale 403s) 2026-08-31 11:26:00 +02:00
Kühn
a05133f39d Fix AD user creation: userAccountControl must be string for ldapts 2026-08-28 11:28:56 +02:00
Kühn
03d20b0e09 Security fixes N4/H2/H1 + hide template editor for non-admins 2026-08-28 10:15:31 +02:00
Kühn
a35721abec Fix file_path: send undefined instead of null 2026-08-25 13:24:40 +02:00
Kühn
abb0617356 Fix task validation: send boolean for is_checked, omit file_path when null 2026-08-25 13:16:28 +02:00
Kühn
dd095d9696 Fix CSRF: use getCSRFToken() in getAuthHeaders() to read from cookie 2026-08-25 12:17:08 +02:00
Kühn
b0fcabe4a5 Fix CSRF token: read from cookie after page reload 2026-08-25 12:02:07 +02:00
Kühn
3e1ac99f38 Remove hardcoded CORS_ORIGIN default - must be set via env 2026-08-25 11:38:18 +02:00
Kühn
dd63620fee Change port from 5000 to 3900 2026-08-25 11:14:39 +02:00
85 changed files with 1426 additions and 342 deletions

View File

@@ -1,49 +0,0 @@
# ============================================================
# Workflow Portal - Environment Configuration
# ============================================================
# Kopiere diese Datei zu .env und passe die Werte an.
# Alle Werte in <> müssen ausgefüllt werden.
# Werte mit Defaults können auskommentiert oder belassen werden.
# ============================================================
# ============ LDAP / Active Directory ============
LDAP_SERVER=PIDC02.seatle.intra
LDAP_PORT=636
LDAP_SEARCH_BASE=<z.B. DC=SEATLE,DC=INTRA>
LDAP_DOMAIN=SEATLE
LDAP_IGNORE_CERT_ERRORS=true
LDAP_BIND_USER=<z.B. svc_workflow@seatle.intra>
LDAP_BIND_PASSWORD=<LDAP-Service-Account-Passwort>
LDAP_SYNC_INTERVAL=300000
LDAP_FILTER=
LDAP_ATTRIBUTES=mail,displayName,memberOf,distinguishedName,sAMAccountName
LDAP_CREATE_OU=<z.B. OU=Users,OU=SEATLE,DC=SEATLE,DC=INTRA>
LDAP_UPN_SUFFIX=<z.B. seatle.intra>
# ============ Admin Account ============
ADMIN_EMAIL=admin@workflow.local
ADMIN_INIT_PASSWORD=<Admin-Initial-Passwort, min. 8 Zeichen mit Groß-/Kleinbuchstaben + Zahl>
# ============ Server ============
PORT=5000
NODE_ENV=production
CORS_ORIGIN=http://localhost:5000
# ============ PostgreSQL Database ============
POSTGRES_DB=workflow
POSTGRES_USER=workflow
POSTGRES_PASSWORD=<Sicheres Datenbank-Passwort>
# DATABASE_URL wird automatisch aus den Werten oben generiert:
# postgresql://workflow:<POSTGRES_PASSWORD>@db:5432/workflow
# ============ Security ============
SESSION_MAX_PER_USER=5
SESSION_TTL_HOURS=168
LOGIN_MAX_ATTEMPTS=5
LOGIN_LOCKOUT_MINUTES=15
BODY_LIMIT=1mb
UPLOAD_MAX_MB=10
# ============ DB Backup ============
BACKUP_INTERVAL_HOURS=6
BACKUP_RETENTION_DAYS=30

3
.gitignore vendored
View File

@@ -14,6 +14,9 @@ backend/data/
*.db *.db
*.db-journal *.db-journal
# DB-Backups (enthalten personenbezogene Daten — niemals ins Repo)
backups/
# Build artifacts # Build artifacts
frontend/dist/ frontend/dist/
dist/ dist/

View File

@@ -68,19 +68,38 @@ if (usePostgres) {
}, },
transaction(fn) { transaction(fn) {
// H2: Transaktions-Contract — fn erhält eine txDb-Instanz, deren
// prepare()-Statements auf DERSELBEN Connection laufen wie die Transaktion.
// WICHTIG: Alle Statements innerhalb von fn MÜSSEN über txDb.prepare()
// erzeugt werden. Statements, die außerhalb (über das globale db-Objekt)
// vorbereitet wurden, laufen außerhalb der Transaktion und machen sie
// wirkungslos (kein Rollback bei Fehlern).
return async (...args) => { return async (...args) => {
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
const txDb = { const txDb = {
_type: 'postgres',
_inTransaction: true,
prepare(sql) { prepare(sql) {
const pgSql = convertPlaceholders(sql); const pgSql = convertPlaceholders(sql);
return { return {
run: (...params) => client.query(pgSql, params).then(result => ({ run: (...params) => {
changes: result.rowCount, // BUGFIX (v8): lastInsertRowid innerhalb der Transaktion —
lastInsertRowid: result.rows[0]?.id || null, // identische RETURNING-Logik wie im outer prepare(). Ohne
})), // RETURNING id liefert pg keine rows → lastInsertRowid war
// null → Folge-Inserts (task_values mit task_id=null) schlugen
// mit FK-Verstoß fehl (500 beim Abschicken einer Vorlage).
const isInsert = pgSql.trim().toUpperCase().startsWith('INSERT');
const finalSql = isInsert && !pgSql.toUpperCase().includes('RETURNING')
? pgSql.replace(/;?\s*$/, ' RETURNING id')
: pgSql;
return client.query(finalSql, params).then(result => ({
changes: result.rowCount,
lastInsertRowid: result.rows[0]?.id || null,
}));
},
get: (...params) => client.query(pgSql, params).then(result => result.rows[0] || null), get: (...params) => client.query(pgSql, params).then(result => result.rows[0] || null),
all: (...params) => client.query(pgSql, params).then(result => result.rows), all: (...params) => client.query(pgSql, params).then(result => result.rows),
}; };
@@ -117,6 +136,12 @@ if (usePostgres) {
console.log('[DB] SQLite-Datenbank verbunden (better-sqlite3, WAL-Modus, async-Wrapper).'); console.log('[DB] SQLite-Datenbank verbunden (better-sqlite3, WAL-Modus, async-Wrapper).');
// H2: GLOBALER Transaktions-Mutex — SQLite hat nur EINE Connection, daher
// dürfen sich nie zwei Transaktionen überlappen (auch nicht verschiedene
// transaction()-Wrapper wie createTask + updateTemplate). Ein pro-Wrapper
// Mutex würde "cannot start a transaction within a transaction" ermöglichen.
let sqliteTxQueue = Promise.resolve();
db = { db = {
_type: 'sqlite', _type: 'sqlite',
@@ -140,8 +165,52 @@ if (usePostgres) {
}, },
transaction(fn) { transaction(fn) {
const tx = sqliteDb.transaction(fn); // H2: Transaktions-Contract (siehe PostgreSQL-Modus) — fn erhält eine
return (...args) => Promise.resolve(tx(...args)); // txDb-Instanz, deren Statements innerhalb der Transaktion laufen.
// better-sqlite3's transaction() ist synchron; die async fn wird über
// den GLOBALEN Warteschlangen-Mutex serialisiert (siehe oben).
return (...args) => {
const run = async () => {
const txDb = {
_type: 'sqlite',
_inTransaction: true,
prepare(sql) {
const stmt = sqliteDb.prepare(sql);
return {
run: (...params) => Promise.resolve(stmt.run(...params)),
get: (...params) => Promise.resolve(stmt.get(...params)),
all: (...params) => Promise.resolve(stmt.all(...params)),
};
},
exec(sql) {
sqliteDb.exec(sql);
return Promise.resolve();
},
pragma(str) {
sqliteDb.pragma(str);
return Promise.resolve({});
},
};
// BEGIN IMMEDIATE sichert den Schreib-Lock für die gesamte Transaktion.
// busy_timeout verhindert SQLITE_BUSY bei konkurrierenden Lesern (WAL).
sqliteDb.pragma('busy_timeout = 5000');
sqliteDb.exec('BEGIN IMMEDIATE');
try {
const result = await fn.call(txDb, ...args);
sqliteDb.exec('COMMIT');
return result;
} catch (err) {
try { sqliteDb.exec('ROLLBACK'); } catch (rollbackErr) {
console.error('[DB] Rollback-Fehler:', rollbackErr.message);
}
throw err;
}
};
const result = sqliteTxQueue.then(run, run);
// Queue darf nie in einem Fehlerzustand hängen bleiben
sqliteTxQueue = result.catch(() => {});
return result;
};
}, },
close() { close() {

View File

@@ -1,4 +1,5 @@
const { Client, Attribute, Change } = require('ldapts'); const { Client, Attribute, Change } = require('ldapts');
const { transliterateUmlauts, toSamAccountName } = require('./umlauts');
/** /**
* LDAP Operations Module (ldapts) * LDAP Operations Module (ldapts)
@@ -77,8 +78,18 @@ async function browseOUTree(searchBase) {
throw new Error('LDAP nicht konfiguriert.'); throw new Error('LDAP nicht konfiguriert.');
} }
const client = await createClient(); // H1: Validate searchBase - must be a DN under the configured LDAP_SEARCH_BASE
// (prevents arbitrary LDAP tree browsing outside the allowed scope)
const base = searchBase || LDAP_SEARCH_BASE; const base = searchBase || LDAP_SEARCH_BASE;
if (base !== LDAP_SEARCH_BASE) {
const escapeDNRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const basePattern = new RegExp(',' + escapeDNRegex(LDAP_SEARCH_BASE) + '$', 'i');
if (!basePattern.test(base)) {
throw new Error('Ungültige Suchbasis: muss unterhalb von ' + LDAP_SEARCH_BASE + ' liegen.');
}
}
const client = await createClient();
try { try {
const { searchEntries } = await client.search(base, { const { searchEntries } = await client.search(base, {
@@ -123,12 +134,10 @@ function escapeLDAPDN(str) {
/** /**
* Replace German umlauts and ß for sAMAccountName compatibility. * Replace German umlauts and ß for sAMAccountName compatibility.
* (Delegiert an zentrale umlauts.js — Logik dort gepflegt.)
*/ */
function replaceUmlauts(str) { function replaceUmlauts(str) {
return str return transliterateUmlauts(str);
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss');
} }
/** /**
@@ -156,19 +165,27 @@ async function createADUser({ ou, vorname, nachname, email, username, password,
const escapedCN = escapeLDAPDN(cnValue); const escapedCN = escapeLDAPDN(cnValue);
const dn = 'CN=' + escapedCN + ',' + ou; const dn = 'CN=' + escapedCN + ',' + ou;
// Build UPN // Build UPN — FIX: Username umlautfrei normalisieren (ä→ae etc.),
// damit kein ungültiger UPN wie müller@... entsteht.
const upnSuffix = LDAP_UPN_SUFFIX || LDAP_BIND_USER.split('@')[1] || LDAP_DOMAIN.toLowerCase() + '.intra'; const upnSuffix = LDAP_UPN_SUFFIX || LDAP_BIND_USER.split('@')[1] || LDAP_DOMAIN.toLowerCase() + '.intra';
const userPrincipalName = username + '@' + upnSuffix; const normalizedUsername = toSamAccountName(username) || 'user' + Date.now().toString(36);
const userPrincipalName = normalizedUsername + '@' + upnSuffix;
// sAMAccountName: max 20 chars // sAMAccountName: max 20 chars
let sAMAccountName = username; let sAMAccountName = toSamAccountName(username);
if (vorname && nachname) { if (vorname && nachname) {
sAMAccountName = replaceUmlauts(nachname + vorname.charAt(0)).replace(/[^a-zA-Z0-9]/g, ''); sAMAccountName = toSamAccountName(nachname + vorname.charAt(0));
} }
sAMAccountName = sAMAccountName.substring(0, 20); sAMAccountName = sAMAccountName.substring(0, 20);
// Fallback: Name nur aus Sonderzeichen → generischer Name (AD lehnt leeren sAMAccountName ab)
if (!sAMAccountName) {
sAMAccountName = ('user' + Date.now().toString(36)).substring(0, 20);
console.warn('[LDAP] sAMAccountName war nach Normalisierung leer — Fallback:', sAMAccountName);
}
// userAccountControl: 514 = NORMAL_ACCOUNT + ACCOUNTDISABLE // userAccountControl: 514 = NORMAL_ACCOUNT + ACCOUNTDISABLE
const userAccountControl = 514; // ldapts requires attribute values as strings (numbers cause "The string argument must be of type string" error)
const userAccountControl = '514';
const effectiveDisplayName = displayName || (nachname + ', ' + vorname); const effectiveDisplayName = displayName || (nachname + ', ' + vorname);
const entry = { const entry = {
@@ -244,13 +261,14 @@ async function createADUser({ ou, vorname, nachname, email, username, password,
console.log('[LDAP] Passwort gesetzt für:', dn); console.log('[LDAP] Passwort gesetzt für:', dn);
// Step 3: Enable the account (userAccountControl: 512 = NORMAL_ACCOUNT, enabled) // Step 3: Enable the account (userAccountControl: 512 = NORMAL_ACCOUNT, enabled)
// ldapts requires attribute values as strings
try { try {
await client.modify(dn, [ await client.modify(dn, [
new Change({ new Change({
operation: 'replace', operation: 'replace',
modification: new Attribute({ modification: new Attribute({
type: 'userAccountControl', type: 'userAccountControl',
values: [512], values: ['512'],
}), }),
}), }),
]); ]);
@@ -342,10 +360,19 @@ async function searchADGroups(query) {
throw new Error('LDAP nicht konfiguriert.'); throw new Error('LDAP nicht konfiguriert.');
} }
// H2: Validate query - length limit + only safe characters (prevents LDAP injection & DoS)
const safeQuery = String(query || '').trim();
if (!safeQuery || safeQuery.length < 2 || safeQuery.length > 100) {
return [];
}
if (!/^[a-zA-Z0-9äöüÄÖÜß._\- ]+$/.test(safeQuery)) {
return [];
}
const client = await createClient(); const client = await createClient();
try { try {
const escapedQuery = query.replace(/[()*\\]/g, '\\$&'); const escapedQuery = safeQuery.replace(/[()*\\]/g, '\\$&');
const { searchEntries } = await client.search(LDAP_SEARCH_BASE, { const { searchEntries } = await client.search(LDAP_SEARCH_BASE, {
filter: `(&(objectClass=group)(|(cn=*${escapedQuery}*)(displayName=*${escapedQuery}*)(sAMAccountName=*${escapedQuery}*)))`, filter: `(&(objectClass=group)(|(cn=*${escapedQuery}*)(displayName=*${escapedQuery}*)(sAMAccountName=*${escapedQuery}*)))`,
scope: 'sub', scope: 'sub',

View File

@@ -18,8 +18,15 @@ const { Client } = require('ldapts');
* LDAP_BIND_USER - Service account in user@domain.fqdn format * LDAP_BIND_USER - Service account in user@domain.fqdn format
* LDAP_BIND_PASSWORD - Password for the service account * LDAP_BIND_PASSWORD - Password for the service account
* LDAP_SYNC_INTERVAL - Sync interval in ms (default: 300000 = 5 min) * LDAP_SYNC_INTERVAL - Sync interval in ms (default: 300000 = 5 min)
* LDAP_FILTER - Custom LDAP filter (default: active users) * LDAP_FILTER - Custom LDAP filter (default: active user accounts only —
* excludes computers, service accounts (sa_*), trust accounts
* and the built-in Administrator/Gast)
* LDAP_ATTRIBUTES - Comma-separated LDAP attributes * LDAP_ATTRIBUTES - Comma-separated LDAP attributes
*
* Rollen (Fix 2, überarbeitet): Rollen werden AUSSCHLIESSLICH über die App
* verwaltet (Nutzerverwaltung). Der Sync legt neue User immer als 'user' an
* und ändert die Rolle bestehender User NIE — AD-Gruppen fließen nicht in
* App-Rechte ein.
*/ */
const LDAP_SERVER = process.env.LDAP_SERVER || ''; const LDAP_SERVER = process.env.LDAP_SERVER || '';
@@ -30,7 +37,16 @@ const LDAP_IGNORE_CERT_ERRORS = (process.env.LDAP_IGNORE_CERT_ERRORS || 'false')
const LDAP_BIND_USER = process.env.LDAP_BIND_USER || ''; const LDAP_BIND_USER = process.env.LDAP_BIND_USER || '';
const LDAP_BIND_PASSWORD = process.env.LDAP_BIND_PASSWORD || ''; const LDAP_BIND_PASSWORD = process.env.LDAP_BIND_PASSWORD || '';
const LDAP_SYNC_INTERVAL = parseInt(process.env.LDAP_SYNC_INTERVAL) || 300000; const LDAP_SYNC_INTERVAL = parseInt(process.env.LDAP_SYNC_INTERVAL) || 300000;
const LDAP_FILTER = process.env.LDAP_FILTER || '(&(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))'; // Fix 1: Standard-Filter nur echte Benutzerkonten — keine Computer ($), keine
// Service-Accounts (sa_*), keine Trust-Accounts, nicht deaktivierte Konten.
// 1.2.840.113556.1.4.803 = LDAP_MATCHING_RULE_BIT_AND (userAccountControl-Bits)
// Bit 2 = ACCOUNTDISABLE, Bit 512 = NORMAL_ACCOUNT (nur echte Benutzerkonten
// haben dieses Bit; Computer = 4096, Domain-Controller = 8192 haben es NICHT).
const LDAP_FILTER = process.env.LDAP_FILTER
|| '(&(objectClass=user)(sAMAccountName=*)(!(sAMAccountName=*$))(!(sAMAccountName=sa_*))'
+ '(!(sAMAccountName=Administrator))(!(sAMAccountName=Gast))'
+ '(!(userAccountControl:1.2.840.113556.1.4.803:=2))'
+ '(userAccountControl:1.2.840.113556.1.4.803:=512))';
const LDAP_ATTRIBUTES = (process.env.LDAP_ATTRIBUTES || 'mail,displayName,memberOf,distinguishedName,sAMAccountName').split(',').map(a => a.trim()); const LDAP_ATTRIBUTES = (process.env.LDAP_ATTRIBUTES || 'mail,displayName,memberOf,distinguishedName,sAMAccountName').split(',').map(a => a.trim());
let syncTimer = null; let syncTimer = null;
@@ -40,16 +56,6 @@ function isLDAPConfigured() {
return !!(LDAP_SERVER && LDAP_SEARCH_BASE && LDAP_BIND_USER && LDAP_BIND_PASSWORD); return !!(LDAP_SERVER && LDAP_SEARCH_BASE && LDAP_BIND_USER && LDAP_BIND_PASSWORD);
} }
function extractRole(memberOf) {
if (!memberOf) return 'user';
const groups = Array.isArray(memberOf) ? memberOf : [memberOf];
const groupStrings = groups.map(g => String(g).toLowerCase());
if (groupStrings.some(g => g.includes('admin') || g.includes('domain admins') || g.includes('domänen-admins'))) {
return 'admin';
}
return 'user';
}
async function syncLDAPUsers(db) { async function syncLDAPUsers(db) {
if (!isLDAPConfigured()) { if (!isLDAPConfigured()) {
console.log('[LDAP] Nicht konfiguriert - LDAP-Sync deaktiviert.'); console.log('[LDAP] Nicht konfiguriert - LDAP-Sync deaktiviert.');
@@ -92,12 +98,10 @@ async function syncLDAPUsers(db) {
const rawCn = Array.isArray(entry.cn) ? entry.cn[0] : entry.cn; const rawCn = Array.isArray(entry.cn) ? entry.cn[0] : entry.cn;
const rawDN = Array.isArray(entry.distinguishedName) ? entry.distinguishedName[0] : entry.distinguishedName; const rawDN = Array.isArray(entry.distinguishedName) ? entry.distinguishedName[0] : entry.distinguishedName;
const rawSAM = Array.isArray(entry.sAMAccountName) ? entry.sAMAccountName[0] : entry.sAMAccountName; const rawSAM = Array.isArray(entry.sAMAccountName) ? entry.sAMAccountName[0] : entry.sAMAccountName;
const rawMemberOf = Array.isArray(entry.memberOf) ? entry.memberOf : (entry.memberOf ? [entry.memberOf] : []);
const email = (rawMail || '').toLowerCase().trim(); const email = (rawMail || '').toLowerCase().trim();
const name = rawName || rawCn || ''; const name = rawName || rawCn || '';
const distinguishedName = rawDN || ''; const distinguishedName = rawDN || '';
const memberOf = rawMemberOf;
const username = (rawSAM || '').trim(); const username = (rawSAM || '').trim();
if (!email && !username) continue; // Skip users without email AND username if (!email && !username) continue; // Skip users without email AND username
@@ -105,7 +109,6 @@ async function syncLDAPUsers(db) {
adUsers.push({ adUsers.push({
email: email || (username + '@ad.local'), email: email || (username + '@ad.local'),
name, name,
role: extractRole(memberOf),
distinguishedName, distinguishedName,
username, username,
}); });
@@ -121,23 +124,46 @@ async function syncLDAPUsers(db) {
let inserted = 0; let inserted = 0;
let updated = 0; let updated = 0;
const insertStmt = db.prepare('INSERT INTO users (email, password, name, role, status, source, username) VALUES (?, ?, ?, ?, \'inaktiv\', \'ad\', ?)'); // H2: Statements innerhalb der Transaktion über txDb erzeugen.
const updateStmt = db.prepare('UPDATE users SET name = ?, username = ?, role = ? WHERE id = ?'); // WICHTIG (PostgreSQL): Ein UNIQUE-Verstoß bricht die GESAMTE Transaktion
// ab ("current transaction is aborted") — ein try/catch um den Insert
// hilft dort nicht. Daher werden Kollisionen VOR dem Insert per SELECT
// erkannt (ON CONFLICT wäre die Alternative, ist aber im gemeinsamen
// SQLite/PG-SQL-Dialekt nicht portabel).
const syncTransaction = db.transaction(async function () {
const txDb = this;
// Rollen werden app-seitig verwaltet: neue User immer 'user', bestehende
// User behalten ihre in der App gesetzte Rolle (Sync fasst role nie an).
const insertStmt = txDb.prepare('INSERT INTO users (email, password, name, role, status, source, username) VALUES (?, ?, ?, \'user\', \'inaktiv\', \'ad\', ?)');
const updateStmt = txDb.prepare('UPDATE users SET name = ?, username = ? WHERE id = ?');
const emailExistsStmt = txDb.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)');
const usernameExistsStmt = txDb.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)');
const syncTransaction = db.transaction(async () => {
for (const adUser of adUsers) { for (const adUser of adUsers) {
const existing = existingMap[adUser.email]; const existing = existingMap[adUser.email];
if (existing) { if (existing) {
await updateStmt.run(adUser.name, adUser.username, adUser.role, existing.id); await updateStmt.run(adUser.name, adUser.username, existing.id);
updated++; updated++;
delete existingMap[adUser.email]; delete existingMap[adUser.email];
} else { } else {
// Kollisions-Precheck: E-Mail oder Username bereits belegt?
const emailTaken = await emailExistsStmt.get(adUser.email);
if (emailTaken) {
console.warn('[LDAP] E-Mail bereits vorhanden:', adUser.email);
continue;
}
const usernameTaken = adUser.username ? await usernameExistsStmt.get(adUser.username) : null;
if (usernameTaken) {
console.warn('[LDAP] Username bereits vorhanden:', adUser.username);
continue;
}
try { try {
await insertStmt.run(adUser.email, 'LDAP_AUTH', adUser.name, adUser.role, adUser.username); await insertStmt.run(adUser.email, 'LDAP_AUTH', adUser.name, adUser.username);
inserted++; inserted++;
} catch (err) { } catch (err) {
if (err.message && err.message.includes('UNIQUE constraint') || err.message?.includes('duplicate key')) { // Fallback für Race-Conditions zwischen Precheck und Insert
console.warn('[LDAP] E-Mail bereits vorhanden:', adUser.email); if (err.message?.includes('UNIQUE constraint') || err.message?.includes('duplicate key')) {
console.warn('[LDAP] E-Mail bereits vorhanden (Race):', adUser.email);
} else { } else {
console.error('[LDAP] Insert-Fehler:', err.message); console.error('[LDAP] Insert-Fehler:', err.message);
} }
@@ -151,17 +177,50 @@ async function syncLDAPUsers(db) {
// Remove stale AD users // Remove stale AD users
const adEmails = adUsers.map(u => u.email.toLowerCase()); const adEmails = adUsers.map(u => u.email.toLowerCase());
const toRemove = existingRows.filter(r => !adEmails.includes(r.email.toLowerCase())); const toRemove = existingRows.filter(r => !adEmails.includes(r.email.toLowerCase()));
// H3: Mass-deletion protection — a single failed/empty LDAP result must not
// wipe the entire AD user base (and their tasks via ON DELETE CASCADE).
// Abort the sync if we would remove more than LDAP_MAX_DELETE_PCT (default 25%)
// of the currently known AD users, or if the LDAP search returned zero entries.
const LDAP_MAX_DELETE_PCT = parseInt(process.env.LDAP_MAX_DELETE_PCT) || 25;
let removed = 0; let removed = 0;
if (toRemove.length > 0) { if (toRemove.length > 0) {
const removeIds = toRemove.map(r => r.id).filter(id => Number.isInteger(id)); if (adUsers.length === 0) {
if (removeIds.length > 0) { console.warn('[LDAP] Sync lieferte 0 Nutzer — Löschen abgebrochen (Schutz gegen Massenlöschung).');
const placeholders = removeIds.map(() => '?').join(','); } else {
await db.prepare(`DELETE FROM users WHERE id IN (${placeholders}) AND source = 'ad'`).run(...removeIds); const deleteRatio = (toRemove.length / existingRows.length) * 100;
removed = removeIds.length; if (deleteRatio > LDAP_MAX_DELETE_PCT) {
console.warn(`[LDAP] ${toRemove.length} von ${existingRows.length} AD-Nutzern würden gelöscht werden (${deleteRatio.toFixed(1)}% > ${LDAP_MAX_DELETE_PCT}% Schwellwert) — Löschen abgebrochen.`);
} else {
const removeIds = toRemove.map(r => r.id).filter(id => Number.isInteger(id));
if (removeIds.length > 0) {
const placeholders = removeIds.map(() => '?').join(',');
await db.prepare(`DELETE FROM users WHERE id IN (${placeholders}) AND source = 'ad'`).run(...removeIds);
removed = removeIds.length;
}
}
} }
} }
console.log('[LDAP] Sync abgeschlossen: ' + inserted + ' neu, ' + updated + ' aktualisiert, ' + removed + ' entfernt'); console.log('[LDAP] Sync abgeschlossen: ' + inserted + ' neu, ' + updated + ' aktualisiert, ' + removed + ' entfernt');
// Fix 1 (Cleanup): Bereits kontaminierte Konten entfernen — Computer-Accounts
// ($-Suffix), Service-Accounts (sa_*) und der eingebaute Administrator/Gast.
// Diese sollten laut geschärftem Filter nie mehr gesynct werden; der Cleanup
// räumt Bestände auf, die vor dem Fix angelegt wurden. Läuft nur, wenn der
// Standard-Filter aktiv ist (bei Custom-Filter entscheidet der Betreiber).
const isDefaultFilter = !process.env.LDAP_FILTER;
if (isDefaultFilter) {
const junkRows = await db.prepare(
`SELECT id FROM users WHERE source = 'ad' AND (username LIKE '%$' OR username LIKE 'sa\\_%' ESCAPE '\\' OR LOWER(username) IN ('administrator', 'gast'))`
).all();
const cleanupIds = junkRows.map(r => r.id).filter(id => Number.isInteger(id));
if (cleanupIds.length > 0) {
const placeholders = cleanupIds.map(() => '?').join(',');
const del = await db.prepare(`DELETE FROM users WHERE id IN (${placeholders}) AND source = 'ad'`).run(...cleanupIds);
console.log('[LDAP] Cleanup: ' + del.changes + ' Computer-/Service-Accounts entfernt.');
}
}
} catch (err) { } catch (err) {
console.error('[LDAP] Sync-Fehler:', err.message); console.error('[LDAP] Sync-Fehler:', err.message);
} finally { } finally {

View File

@@ -9,7 +9,17 @@ const db = require('../db');
const { auditLog } = require('../auditLog'); const { auditLog } = require('../auditLog');
// P6: Cookie config - defined early for use in CSRF and auth cookies // P6: Cookie config - defined early for use in CSRF and auth cookies
// M1: Cookie security is now configurable via COOKIE_SECURE env var so that
// plain-HTTP deployments (e.g. behind a TLS-terminating proxy that sets
// X-Forwarded-Proto) can still use secure cookies, while HTTP-only dev/test
// setups can disable them. Defaults to NODE_ENV === 'production'.
// COOKIE_SECURE=true → always secure
// COOKIE_SECURE=false → never secure (HTTP dev)
// unset → secure in production, lax in development
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
const COOKIE_SECURE = process.env.COOKIE_SECURE !== undefined
? process.env.COOKIE_SECURE === 'true'
: isProduction;
const COOKIE_NAME = 'workflow_token'; const COOKIE_NAME = 'workflow_token';
function hashToken(token) { function hashToken(token) {
@@ -20,13 +30,36 @@ function hashToken(token) {
const CSRF_COOKIE_NAME = 'workflow_csrf'; const CSRF_COOKIE_NAME = 'workflow_csrf';
const CSRF_HEADER_NAME = 'x-csrf-token'; const CSRF_HEADER_NAME = 'x-csrf-token';
function setCSRFCookie(res) { // CSRF token is derived deterministically from the session token hash (HMAC).
const csrfToken = crypto.randomBytes(32).toString('hex'); // Advantage: cookie and header can never drift apart (no more stale-token 403s),
// works across tabs, page reloads and re-logins. The cookie is self-healed by
// authMiddleware on every request if it is missing or out of sync.
// M2: Fail-fast in production if SESSION_SECRET is missing — a hardcoded
// fallback secret in the source tree is a security risk.
const SESSION_SECRET = process.env.SESSION_SECRET || '';
if (!SESSION_SECRET) {
if (process.env.NODE_ENV === 'production') {
console.error('[FATAL] SESSION_SECRET Umgebungsvariable ist in der Produktion nicht gesetzt. Setze sie auf einen langen, zufälligen Wert.');
process.exit(1);
} else {
console.warn('[WARN] SESSION_SECRET nicht gesetzt — verwende unsicheren Fallback nur für die Entwicklung.');
}
}
const CSRF_SECRET = SESSION_SECRET || 'workflow-portal-csrf-dev-only-fallback';
function deriveCSRFToken(tokenHash) {
return crypto.createHmac('sha256', CSRF_SECRET).update(tokenHash).digest('hex');
}
function setCSRFCookie(res, tokenHash) {
// If a session token hash is provided, derive the CSRF token from it (deterministic).
// Otherwise (e.g. register, no session yet) fall back to a random token.
const csrfToken = tokenHash ? deriveCSRFToken(tokenHash) : crypto.randomBytes(32).toString('hex');
res.cookie(CSRF_COOKIE_NAME, csrfToken, { res.cookie(CSRF_COOKIE_NAME, csrfToken, {
httpOnly: false, // Must be readable by JS to send back in header httpOnly: false, // Must be readable by JS to send back in header
secure: isProduction, secure: COOKIE_SECURE,
sameSite: isProduction ? 'strict' : 'lax', sameSite: isProduction ? 'strict' : 'lax',
maxAge: 24 * 60 * 60 * 1000, // 24h maxAge: 24 * 60 * 60 * 1000, // 24h (re-set by authMiddleware on every request)
path: '/', path: '/',
}); });
return csrfToken; return csrfToken;
@@ -40,9 +73,25 @@ function csrfMiddleware(req, res, next) {
const cookieToken = req.cookies?.[CSRF_COOKIE_NAME]; const cookieToken = req.cookies?.[CSRF_COOKIE_NAME];
const headerToken = req.headers[CSRF_HEADER_NAME]; const headerToken = req.headers[CSRF_HEADER_NAME];
if (!cookieToken || !headerToken || cookieToken !== headerToken) { // Accept if header matches cookie (classic double-submit) OR if the header
// matches the token derived from the current session (self-healing path).
let valid = cookieToken && headerToken && cookieToken === headerToken;
if (!valid && req.tokenHash && headerToken) {
valid = headerToken === deriveCSRFToken(req.tokenHash);
}
if (!valid) {
return res.status(403).json({ error: 'CSRF-Token ungültig oder fehlend.' }); return res.status(403).json({ error: 'CSRF-Token ungültig oder fehlend.' });
} }
// Self-heal: ensure the cookie always carries the correct token
if (req.tokenHash && cookieToken !== deriveCSRFToken(req.tokenHash)) {
res.cookie(CSRF_COOKIE_NAME, deriveCSRFToken(req.tokenHash), {
httpOnly: false,
secure: COOKIE_SECURE,
sameSite: isProduction ? 'strict' : 'lax',
maxAge: 24 * 60 * 60 * 1000,
path: '/',
});
}
next(); next();
} }
@@ -66,6 +115,17 @@ async function authMiddleware(req, res, next) {
req.user = { id: session.user_id, email: session.email, name: session.name, role: session.role, status: session.status, source: session.source, username: session.username }; req.user = { id: session.user_id, email: session.email, name: session.name, role: session.role, status: session.status, source: session.source, username: session.username };
req.tokenHash = tokenHash; req.tokenHash = tokenHash;
// Self-healing CSRF: on safe requests (GET/HEAD/OPTIONS), re-issue the CSRF cookie
// derived from the current session so it can never go stale or out of sync.
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
res.cookie(CSRF_COOKIE_NAME, deriveCSRFToken(tokenHash), {
httpOnly: false,
secure: COOKIE_SECURE,
sameSite: isProduction ? 'strict' : 'lax',
maxAge: 24 * 60 * 60 * 1000,
path: '/',
});
}
next(); next();
} }
@@ -102,12 +162,12 @@ async function createSession(userId, oldRawToken) {
return rawToken; return rawToken;
} }
async function deleteSession(rawToken) { async function deleteSession(rawToken, req) {
if (!rawToken) return; if (!rawToken) return;
const tokenHash = hashToken(rawToken); const tokenHash = hashToken(rawToken);
const session = await db.prepare('SELECT user_id FROM sessions WHERE token = ?').get(tokenHash); const session = await db.prepare('SELECT user_id FROM sessions WHERE token = ?').get(tokenHash);
if (session) { if (session) {
auditLog(session.user_id, 'logout', 'user', session.user_id, null); auditLog(session.user_id, 'logout', 'user', session.user_id, null, req);
} }
await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash); await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash);
} }
@@ -151,7 +211,7 @@ function setAuthCookie(res, token) {
const ttlHours = parseInt(process.env.SESSION_TTL_HOURS) || 168; const ttlHours = parseInt(process.env.SESSION_TTL_HOURS) || 168;
res.cookie(COOKIE_NAME, token, { res.cookie(COOKIE_NAME, token, {
httpOnly: true, httpOnly: true,
secure: isProduction, secure: COOKIE_SECURE,
sameSite: isProduction ? 'strict' : 'lax', sameSite: isProduction ? 'strict' : 'lax',
maxAge: ttlHours * 60 * 60 * 1000, maxAge: ttlHours * 60 * 60 * 1000,
path: '/', path: '/',
@@ -165,6 +225,6 @@ function clearAuthCookie(res) {
module.exports = { module.exports = {
authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, hashToken, authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, hashToken,
isAccountLocked, recordFailedLogin, recordSuccessfulLogin, isAccountLocked, recordFailedLogin, recordSuccessfulLogin,
setAuthCookie, clearAuthCookie, COOKIE_NAME, setAuthCookie, clearAuthCookie, COOKIE_NAME, COOKIE_SECURE,
setCSRFCookie, csrfMiddleware, CSRF_COOKIE_NAME, CSRF_HEADER_NAME setCSRFCookie, csrfMiddleware, CSRF_COOKIE_NAME, CSRF_HEADER_NAME
}; };

View File

@@ -23,6 +23,16 @@ const loginLimiter = rateLimit({
message: { error: 'Zu viele Anmeldeversuche. Bitte in 1 Minute erneut versuchen.' }, message: { error: 'Zu viele Anmeldeversuche. Bitte in 1 Minute erneut versuchen.' },
}); });
// H5: Register rate limit: 10 registrations per hour per IP (prevents account
// flooding, audit-log spam, and bcrypt CPU abuse)
const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Zu viele Registrierungsversuche. Bitte später erneut versuchen.' },
});
// Punkt 23: Task creation rate limit: 20 per minute per user // Punkt 23: Task creation rate limit: 20 per minute per user
const taskCreateLimiter = rateLimit({ const taskCreateLimiter = rateLimit({
windowMs: 60 * 1000, windowMs: 60 * 1000,
@@ -43,4 +53,4 @@ const uploadLimiter = rateLimit({
message: { error: 'Zu viele Upload-Anfragen. Bitte später erneut versuchen.' }, message: { error: 'Zu viele Upload-Anfragen. Bitte später erneut versuchen.' },
}); });
module.exports = { apiLimiter, loginLimiter, taskCreateLimiter, uploadLimiter }; module.exports = { apiLimiter, loginLimiter, registerLimiter, taskCreateLimiter, uploadLimiter };

View File

@@ -77,17 +77,35 @@ const updateTemplateSchema = z.object({
}); });
// ============ Task Schemas ============ // ============ Task Schemas ============
// H1: Whitelist für Datei-Pfade — nur Pfade akzeptieren, die exakt vom
// Upload-Endpoint emittiert werden (/api/upload/uploads/<ts>-<rand>-<name>.<ext>).
// Verhindert Stored XSS über javascript:/data:-URLs in Task-Dateilinks.
// Endungen beschränkt auf die vom Uploader erlaubten Typen (inkl. .bin-Fallback
// für abgelehnte Original-Endungen). Der Name-Teil ist bewusst `*` (nicht `+`),
// da der Uploader auch Dateien mit leerem Basisnamen erzeugen kann (z.B. ".pdf").
const UPLOAD_PATH_PATTERN = /^\/api\/upload\/uploads\/[0-9]+-[0-9]+-[a-zA-Z0-9._-]*\.(pdf|png|jpg|jpeg|gif|txt|doc|docx|bin)$/;
const filePathSchema = z.string().regex(UPLOAD_PATH_PATTERN, 'Ungueltiger Dateipfad.');
/**
* H1: Prüft, ob ein Pfad/URL ein legitimer Upload-Link ist.
* Wird auch in routes/tasks.js verwendet, um `value`-Felder von
* file_upload-Steps zu validieren (dort ist der Step-Typ erst serverseitig bekannt).
*/
function isSafeUploadPath(path) {
return typeof path === 'string' && UPLOAD_PATH_PATTERN.test(path);
}
const createTaskSchema = z.object({ const createTaskSchema = z.object({
template_id: z.number().int().positive('Template-ID ist erforderlich.'), template_id: z.number().int().positive('Template-ID ist erforderlich.'),
title: z.string().min(1, 'Titel ist erforderlich.').max(500), title: z.string().min(1, 'Titel ist erforderlich.').max(500),
user_id: z.number().int().positive().optional(), user_id: z.number().int().positive().optional(),
file_path: z.string().optional(), file_path: filePathSchema.optional(),
// V9: Limit task values array to prevent DoS via huge payloads // V9: Limit task values array to prevent DoS via huge payloads
values: z.array(z.object({ values: z.array(z.object({
step_id: z.number().int().positive().optional(), step_id: z.number().int().positive().optional(),
value: z.string().max(10000).optional(), value: z.string().max(10000).optional(),
is_checked: z.boolean().optional(), is_checked: z.boolean().optional(),
file_path: z.string().optional(), file_path: filePathSchema.optional(),
})).max(100, 'Maximal 100 Werte pro Aufgabe erlaubt.').optional().default([]), })).max(100, 'Maximal 100 Werte pro Aufgabe erlaubt.').optional().default([]),
}); });
@@ -206,4 +224,5 @@ module.exports = {
// Middleware // Middleware
validate, validate,
validateQuery, validateQuery,
isSafeUploadPath,
}; };

View File

@@ -196,6 +196,39 @@ async function initDatabase() {
} }
}, 60 * 60 * 1000); }, 60 * 60 * 1000);
// H4: uploads table — track file ownership for authorization on download
await migrate('add_uploads_table', isPostgres
? `CREATE TABLE IF NOT EXISTS uploads (
id SERIAL PRIMARY KEY, filename TEXT UNIQUE NOT NULL, user_id INTEGER NOT NULL,
original_name TEXT, size INTEGER, created_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`
: `CREATE TABLE IF NOT EXISTS uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT UNIQUE NOT NULL, user_id INTEGER NOT NULL,
original_name TEXT, size INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`
);
await migrate('idx_uploads_filename', isPostgres
? `CREATE INDEX IF NOT EXISTS idx_uploads_filename ON uploads(filename)`
: `CREATE INDEX IF NOT EXISTS idx_uploads_filename ON uploads(filename)`
);
await migrate('idx_uploads_user_id', isPostgres
? `CREATE INDEX IF NOT EXISTS idx_uploads_user_id ON uploads(user_id)`
: `CREATE INDEX IF NOT EXISTS idx_uploads_user_id ON uploads(user_id)`
);
// OIDC: stabile Provider-Identität (claims.sub) pro User speichern.
// UNIQUE verhindert, dass zwei User denselben Provider-Account beanspruchen.
await migrate('add_users_oidc_sub', isPostgres
? `ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_sub TEXT`
: `ALTER TABLE users ADD COLUMN oidc_sub TEXT`
);
await migrate('idx_users_oidc_sub', isPostgres
? `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc_sub ON users(oidc_sub) WHERE oidc_sub IS NOT NULL`
: `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc_sub ON users(oidc_sub) WHERE oidc_sub IS NOT NULL`
);
console.log('Datenbanktabellen initialisiert.'); console.log('Datenbanktabellen initialisiert.');
} }

235
backend/oidc.js Normal file
View File

@@ -0,0 +1,235 @@
/**
* OpenID Connect (OIDC) Module — Keycloak-Integration.
*
* Authorization Code Flow mit PKCE:
* 1. GET /api/auth/oidc/login → erzeugt State+Nonce+PKCE, leitet zu Keycloak weiter
* 2. Keycloak authentifiziert den Nutzer, leitet zurück auf /api/auth/oidc/callback
* 3. Callback validiert Code+State, tauscht den Code gegen Tokens,
* provisioniert den User lokal und erstellt eine App-Session (HttpOnly-Cookie)
*
* Sicherheitsprinzipien:
* - State/Nonce in kurzlebigen, HttpOnly-Cookies (kein Server-Side-Store nötig)
* - PKCE (S256) gegen Authorization-Code-Interception
* - ID-Token-Signatur wird von openid-client gegen die JWKS des Providers geprüft
* - Rollen bleiben app-seitig (siehe Rollen-Konzept): neue OIDC-User starten
* immer als 'user' + 'inaktiv' und müssen von einem Admin freigeschaltet werden
*
* ENV:
* OIDC_ISSUER - z.B. https://keycloak.example.com/realms/workflow
* OIDC_CLIENT_ID - Client-ID in Keycloak
* OIDC_CLIENT_SECRET - Client-Secret (confidential client)
* OIDC_REDIRECT_URI - z.B. https://workflow.example.com/api/auth/oidc/callback
* OIDC_LOGOUT_URL - optional: Keycloak-Logout-Endpoint (RP-initiated logout)
* APP_BASE_URL - öffentliche Basis-URL der App (für Redirect nach Logout)
*/
const { Issuer, generators } = require('openid-client');
const crypto = require('crypto');
const db = require('./db');
const { auditLog } = require('./auditLog');
const { createSession, setAuthCookie, setCSRFCookie, hashToken } = require('./middleware/auth');
const OIDC_ISSUER = process.env.OIDC_ISSUER || '';
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || '';
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || '';
const OIDC_REDIRECT_URI = process.env.OIDC_REDIRECT_URI || '';
const APP_BASE_URL = process.env.APP_BASE_URL || '';
function isOIDCConfigured() {
return !!(OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_CLIENT_SECRET && OIDC_REDIRECT_URI);
}
// Discovery wird einmalig gecached (Issuer.discover ist idempotent, aber wir
// wollen den Client-Objekt-Overhead pro Request vermeiden)
let clientPromise = null;
async function getClient() {
if (!isOIDCConfigured()) {
throw new Error('OIDC nicht konfiguriert.');
}
if (!clientPromise) {
clientPromise = Issuer.discover(OIDC_ISSUER).then((issuer) => {
const client = new issuer.Client({
client_id: OIDC_CLIENT_ID,
client_secret: OIDC_CLIENT_SECRET,
redirect_uris: [OIDC_REDIRECT_URI],
response_types: ['code'],
});
console.log('[OIDC] Discovery erfolgreich:', issuer.issuer);
return client;
}).catch((err) => {
// Bei Fehler Cache verwerfen, damit ein Retry möglich ist
clientPromise = null;
throw new Error('OIDC Discovery fehlgeschlagen: ' + err.message);
});
}
return clientPromise;
}
// ============ State/Nonce/PKCE-Handling (kurzlebige HttpOnly-Cookies) ============
const OIDC_STATE_COOKIE = 'oidc_state';
const OIDC_NONCE_COOKIE = 'oidc_nonce';
const OIDC_VERIFIER_COOKIE = 'oidc_verifier';
const OIDC_COOKIE_MAX_AGE = 10 * 60 * 1000; // 10 Minuten
const oidcCookieOptions = (maxAge) => ({
httpOnly: true,
secure: process.env.COOKIE_SECURE === 'true' || (process.env.COOKIE_SECURE === undefined && process.env.NODE_ENV === 'production'),
sameSite: 'lax', // lax ist nötig, damit der Redirect von Keycloak das Cookie mitbringt
maxAge,
path: '/api/auth/oidc',
});
function setFlowCookies(res, state, nonce, verifier) {
res.cookie(OIDC_STATE_COOKIE, state, oidcCookieOptions(OIDC_COOKIE_MAX_AGE));
res.cookie(OIDC_NONCE_COOKIE, nonce, oidcCookieOptions(OIDC_COOKIE_MAX_AGE));
res.cookie(OIDC_VERIFIER_COOKIE, verifier, oidcCookieOptions(OIDC_COOKIE_MAX_AGE));
}
function clearFlowCookies(res) {
const opts = { ...oidcCookieOptions(0), maxAge: 0 };
res.clearCookie(OIDC_STATE_COOKIE, opts);
res.clearCookie(OIDC_NONCE_COOKIE, opts);
res.clearCookie(OIDC_VERIFIER_COOKIE, opts);
}
/**
* Startet den Authorization-Code-Flow: leitet den Browser zu Keycloak weiter.
*/
async function startAuthFlow(req, res) {
const client = await getClient();
const state = generators.state();
const nonce = generators.nonce();
const codeVerifier = generators.codeVerifier();
setFlowCookies(res, state, nonce, codeVerifier);
const authorizationUrl = client.authorizationUrl({
scope: 'openid email profile',
state,
nonce,
code_challenge: generators.codeChallenge(codeVerifier),
code_challenge_method: 'S256',
});
auditLog(null, 'oidc.login_start', null, null, 'OIDC authorization flow gestartet', req);
res.redirect(authorizationUrl);
}
/**
* Provisioniert den OIDC-User in der lokalen DB.
* Identität: claims.sub (stabil), Fallback-Matching über E-Mail.
* Rollen/Status bleiben app-seitig: Neuanlage immer 'user' + 'inaktiv'.
*/
async function provisionUser(claims, req) {
const sub = String(claims.sub || '');
const email = String(claims.email || '').toLowerCase().trim();
const preferredUsername = String(claims.preferred_username || '').toLowerCase().trim();
const name = String(claims.name || claims.given_name || preferredUsername || email || 'OIDC-User');
if (!sub) {
throw new Error('OIDC-Claims ohne sub — Login abgelehnt.');
}
if (!email) {
throw new Error('OIDC-Claims ohne E-Mail — Login abgelehnt (email-Scope erforderlich).');
}
// 1. Primär: Identität über oidc_sub (stabil, auch bei E-Mail-Wechsel)
let user = await db.prepare('SELECT id, email, name, role, status, source, username FROM users WHERE oidc_sub = ?').get(sub);
// 2. Fallback: bestehender User mit gleicher E-Mail wird verknüpft
if (!user) {
user = await db.prepare('SELECT id, email, name, role, status, source, username FROM users WHERE LOWER(email) = LOWER(?)').get(email);
if (user) {
await db.prepare('UPDATE users SET oidc_sub = ? WHERE id = ?').run(sub, user.id);
console.log('[OIDC] Bestehenden User mit oidc_sub verknüpft:', email);
}
}
// 3. Neuanlage: immer 'user' + 'inaktiv' (Admin muss freischalten)
if (!user) {
const info = await db.prepare(
'INSERT INTO users (email, password, name, role, status, source, username, oidc_sub) VALUES (?, ?, ?, \'user\', \'inaktiv\', \'oidc\', ?, ?)'
).run(email, 'OIDC_AUTH', name, preferredUsername || email.split('@')[0], sub);
user = await db.prepare('SELECT id, email, name, role, status, source, username FROM users WHERE id = ?').get(info.lastInsertRowid);
auditLog(user.id, 'oidc.register', 'user', user.id, `Neuer OIDC-User: ${email}`, req);
}
// Namen aktuell halten (Rolle/Status bleiben app-seitig — bewusst NICHT überschrieben)
if (user.name !== name) {
await db.prepare('UPDATE users SET name = ? WHERE id = ?').run(name, user.id);
}
return user;
}
/**
* Verarbeitet den OIDC-Callback: Code-Tausch, Claims-Prüfung, Session-Erstellung.
* Gibt den User zurück; die Antwort (Redirect) schreibt die aufrufende Route.
*/
async function handleCallback(req, res) {
const client = await getClient();
// CSRF-Schutz des Flows: State aus Cookie muss mit Query übereinstimmen
const cookieState = req.cookies?.[OIDC_STATE_COOKIE];
const cookieNonce = req.cookies?.[OIDC_NONCE_COOKIE];
const cookieVerifier = req.cookies?.[OIDC_VERIFIER_COOKIE];
clearFlowCookies(res);
if (!cookieState || !cookieNonce || !cookieVerifier) {
throw new Error('OIDC-Flow abgelaufen oder manipuliert (fehlende Flow-Cookies).');
}
if (!req.query.state || req.query.state !== cookieState) {
throw new Error('OIDC-State stimmt nicht überein (möglicher CSRF-Versuch).');
}
if (req.query.error) {
throw new Error(`OIDC-Fehler vom Provider: ${req.query.error_description || req.query.error}`);
}
const params = client.callbackParams(req);
const tokenSet = await client.callback(OIDC_REDIRECT_URI, params, {
state: cookieState,
nonce: cookieNonce,
code_verifier: cookieVerifier,
});
const claims = tokenSet.claims();
const user = await provisionUser(claims, req);
if (user.status === 'inaktiv') {
auditLog(user.id, 'oidc.login_denied', 'user', user.id, 'OIDC-Login abgelehnt: Konto inaktiv', req);
const err = new Error('Konto noch nicht freigeschaltet. Ein Administrator muss dein Konto freischalten.');
err.status = 403;
throw err;
}
// Session-Rotation: alte Session invalidieren (V6-Konzept), neue erstellen
const oldRawToken = req.cookies?.workflow_token || null;
const rawToken = await createSession(user.id, oldRawToken);
setAuthCookie(res, rawToken);
setCSRFCookie(res, hashToken(rawToken));
auditLog(user.id, 'oidc.login', 'user', user.id, 'OIDC-Login erfolgreich', req);
return user;
}
/**
* RP-initiated Logout: lokale Session wird vom Aufrufer gelöscht; hier wird
* nur die Keycloak-Logout-URL gebaut (optional mit id_token_hint).
*/
function buildLogoutUrl(idTokenHint) {
if (!process.env.OIDC_LOGOUT_URL) return null;
const url = new URL(process.env.OIDC_LOGOUT_URL);
if (idTokenHint) url.searchParams.set('id_token_hint', idTokenHint);
if (APP_BASE_URL) url.searchParams.set('post_logout_redirect_uri', APP_BASE_URL);
return url.toString();
}
module.exports = {
isOIDCConfigured,
startAuthFlow,
handleCallback,
buildLogoutUrl,
OIDC_STATE_COOKIE,
OIDC_NONCE_COOKIE,
OIDC_VERIFIER_COOKIE,
};

View File

@@ -14,10 +14,11 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"express-async-errors": "^3.1.1", "express-async-errors": "^3.1.1",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.6.2",
"helmet": "^8.2.0", "helmet": "^8.2.0",
"ldapts": "^8.2.0", "ldapts": "^8.2.0",
"multer": "^2.2.0", "multer": "^2.2.0",
"openid-client": "^5.7.1",
"pg": "^8.13.0", "pg": "^8.13.0",
"zod": "^3.24.0" "zod": "^3.24.0"
}, },
@@ -594,11 +595,12 @@
} }
}, },
"node_modules/express-rate-limit": { "node_modules/express-rate-limit": {
"version": "8.5.2", "version": "8.7.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.4.3",
"ip-address": "^10.2.0" "ip-address": "^10.2.0"
}, },
"engines": { "engines": {
@@ -611,6 +613,29 @@
"express": ">= 4.11" "express": ">= 4.11"
} }
}, },
"node_modules/express-rate-limit/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/express-rate-limit/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/file-uri-to-path": { "node_modules/file-uri-to-path": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -945,6 +970,15 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/ldapts": { "node_modules/ldapts": {
"version": "8.2.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.2.0.tgz", "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.2.0.tgz",
@@ -957,6 +991,18 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1194,6 +1240,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1206,6 +1261,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -1227,6 +1291,21 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1961,6 +2040,12 @@
"node": ">=0.4" "node": ">=0.4"
} }
}, },
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",

View File

@@ -18,6 +18,7 @@
"helmet": "^8.2.0", "helmet": "^8.2.0",
"ldapts": "^8.2.0", "ldapts": "^8.2.0",
"multer": "^2.2.0", "multer": "^2.2.0",
"openid-client": "^5.7.1",
"pg": "^8.13.0", "pg": "^8.13.0",
"zod": "^3.24.0" "zod": "^3.24.0"
}, },

View File

@@ -8,6 +8,7 @@ const { isLDAPConfigured } = require('../ldapSync');
const { browseOUTree, createADUser, checkADUserExists, deleteADUser } = require('../ldapOperations'); const { browseOUTree, createADUser, checkADUserExists, deleteADUser } = require('../ldapOperations');
const { searchADGroups, addUserToGroups, browseADGroups } = require('../ldapOperations'); const { searchADGroups, addUserToGroups, browseADGroups } = require('../ldapOperations');
const { validate, createADUserSchema, deleteADUserSchema } = require('../middleware/validation'); const { validate, createADUserSchema, deleteADUserSchema } = require('../middleware/validation');
const { toSamAccountName } = require('../umlauts');
const router = express.Router(); const router = express.Router();
@@ -68,13 +69,21 @@ router.post('/create-user', adminMiddleware, validate(createADUserSchema), async
const { ou, vorname, nachname, email, username, password, department, telefon, titel, displayName, physicalDeliveryOfficeName, company, description, wWWHomePage, streetAddress, postOfficeBox, l, st, postalCode, c, groups } = req.validatedBody; const { ou, vorname, nachname, email, username, password, department, telefon, titel, displayName, physicalDeliveryOfficeName, company, description, wWWHomePage, streetAddress, postOfficeBox, l, st, postalCode, c, groups } = req.validatedBody;
try { try {
let sAMAccountName = username; // FIX: Umlaute zentral transliterieren (ä→ae etc.) — galt vorher nur für
// nachname/vorname einzeln; jetzt einheitlich über umlauts.js. Der Username
// (und damit UPN) wird ebenfalls umlautfrei normalisiert.
let sAMAccountName = toSamAccountName(username);
if (vorname && nachname) { if (vorname && nachname) {
sAMAccountName = nachname.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue') sAMAccountName = toSamAccountName(nachname + vorname.charAt(0));
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue').replace(/ß/g, 'ss') }
+ vorname.charAt(0).replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue') sAMAccountName = sAMAccountName.substring(0, 20);
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue').replace(/ß/g, 'ss');
sAMAccountName = sAMAccountName.replace(/[^a-zA-Z0-9]/g, '').substring(0, 20); // Fallback: Wenn der Name nur aus Sonderzeichen bestand (z.B. rein chinesisch),
// wäre sAMAccountName leer — AD würde den User ablehnen. Dann generischen
// Namen aus Zeitstempel verwenden und den Original-Username als displayName behalten.
if (!sAMAccountName) {
sAMAccountName = 'user' + Date.now().toString(36);
console.warn('[AD] sAMAccountName war nach Normalisierung leer — Fallback:', sAMAccountName);
} }
const existing = await checkADUserExists(username, sAMAccountName); const existing = await checkADUserExists(username, sAMAccountName);
@@ -84,9 +93,9 @@ router.post('/create-user', adminMiddleware, validate(createADUserSchema), async
const result = await createADUser({ ou, vorname, nachname, email, username, password, department, telefon, titel, displayName, physicalDeliveryOfficeName, company, description, wWWHomePage, streetAddress, postOfficeBox, l, st, postalCode, c }); const result = await createADUser({ ou, vorname, nachname, email, username, password, department, telefon, titel, displayName, physicalDeliveryOfficeName, company, description, wWWHomePage, streetAddress, postOfficeBox, l, st, postalCode, c });
if (result.warning && result.dn) { if (result.warning && result.dn) {
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `AD user created with warning: ${username} - ${result.warning}`); auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `AD user created with warning: ${username} - ${result.warning}`, req);
} else { } else {
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `Created AD user: ${username}`); auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `Created AD user: ${username}`, req);
} }
// Add user to groups if specified // Add user to groups if specified
@@ -95,7 +104,7 @@ router.post('/create-user', adminMiddleware, validate(createADUserSchema), async
try { try {
groupResults = await addUserToGroups(result.dn, groups); groupResults = await addUserToGroups(result.dn, groups);
const addedCount = groupResults.filter(r => r.status === 'added').length; const addedCount = groupResults.filter(r => r.status === 'added').length;
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `Added ${username} to ${addedCount} group(s)`); auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `Added ${username} to ${addedCount} group(s)`, req);
} catch (groupErr) { } catch (groupErr) {
console.error('[WARN] Gruppenzuweisung fehlgeschlagen:', groupErr.message); console.error('[WARN] Gruppenzuweisung fehlgeschlagen:', groupErr.message);
groupResults = groups.map(dn => ({ dn, status: 'error', error: groupErr.message })); groupResults = groups.map(dn => ({ dn, status: 'error', error: groupErr.message }));
@@ -104,7 +113,7 @@ router.post('/create-user', adminMiddleware, validate(createADUserSchema), async
res.status(201).json({ ...result, groupResults }); res.status(201).json({ ...result, groupResults });
} catch (err) { } catch (err) {
auditLog(req.user?.id, 'ad.create-user-failed', 'ad_user', null, `Failed to create AD user: ${username} - ${err.message}`); auditLog(req.user?.id, 'ad.create-user-failed', 'ad_user', null, `Failed to create AD user: ${username} - ${err.message}`, req);
res.status(500).json({ error: 'Interner Serverfehler.' }); res.status(500).json({ error: 'Interner Serverfehler.' });
} }
}); });
@@ -114,7 +123,7 @@ router.delete('/delete-user', adminMiddleware, validate(deleteADUserSchema), asy
const { dn } = req.validatedBody; const { dn } = req.validatedBody;
try { try {
await deleteADUser(dn); await deleteADUser(dn);
auditLog(req.user?.id, 'ad.delete-user', 'ad_user', null, `Deleted AD user: ${dn}`); auditLog(req.user?.id, 'ad.delete-user', 'ad_user', null, `Deleted AD user: ${dn}`, req);
res.json({ success: true, message: 'Benutzer erfolgreich gelöscht.' }); res.json({ success: true, message: 'Benutzer erfolgreich gelöscht.' });
} catch (err) { } catch (err) {
res.status(500).json({ error: 'Fehler beim Löschen des AD-Benutzers: ' + err.message }); res.status(500).json({ error: 'Fehler beim Löschen des AD-Benutzers: ' + err.message });

View File

@@ -9,22 +9,22 @@ const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const db = require('../db'); const db = require('../db');
const { auditLog } = require('../auditLog'); const { auditLog } = require('../auditLog');
const { authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, isAccountLocked, recordFailedLogin, recordSuccessfulLogin, setAuthCookie, clearAuthCookie, setCSRFCookie } = require('../middleware/auth'); const { authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, isAccountLocked, recordFailedLogin, recordSuccessfulLogin, setAuthCookie, clearAuthCookie, setCSRFCookie, hashToken } = require('../middleware/auth');
const { isLDAPConfigured, authenticateLDAP } = require('../ldapSync'); const { isLDAPConfigured, authenticateLDAP } = require('../ldapSync');
const { loginLimiter } = require('../middleware/rateLimit'); const { loginLimiter, registerLimiter } = require('../middleware/rateLimit');
const { validate, registerSchema, loginSchema } = require('../middleware/validation'); const { validate, registerSchema, loginSchema } = require('../middleware/validation');
const router = express.Router(); const router = express.Router();
// Register // Register
router.post('/register', validate(registerSchema), async (req, res) => { router.post('/register', registerLimiter, validate(registerSchema), async (req, res) => {
const { email, password, name } = req.validatedBody; const { email, password, name } = req.validatedBody;
try { try {
const hash = bcrypt.hashSync(password, 10); const hash = bcrypt.hashSync(password, 10);
// VULN-FIX: Force role to 'user' - never trust client-supplied role on register // VULN-FIX: Force role to 'user' - never trust client-supplied role on register
const info = await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, \'user\', \'inaktiv\', \'local\')').run(email, hash, name); const info = await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, \'user\', \'inaktiv\', \'local\')').run(email, hash, name);
const userId = info.lastInsertRowid; const userId = info.lastInsertRowid;
auditLog(null, 'register', 'user', userId, `New registration: ${email}`); auditLog(null, 'register', 'user', userId, `New registration: ${email}`, req);
// P4: Set CSRF cookie for the new session // P4: Set CSRF cookie for the new session
const csrfToken = setCSRFCookie(res); const csrfToken = setCSRFCookie(res);
// Return correct status 'inaktiv' (Punkt 5 fix) // Return correct status 'inaktiv' (Punkt 5 fix)
@@ -54,15 +54,17 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
try { try {
const ldapResult = await authenticateLDAP(email, password); const ldapResult = await authenticateLDAP(email, password);
const adRow = await db.prepare('SELECT id, email, name, role, status, source, username FROM users WHERE LOWER(username) = LOWER(?)').get(ldapResult.username); const adRow = await db.prepare('SELECT id, email, name, role, status, source, username FROM users WHERE LOWER(username) = LOWER(?)').get(ldapResult.username);
if (!adRow) return res.status(404).json({ error: 'Nutzer im System nicht gefunden. Bitte warte auf die naechste Synchronisation.' }); // M3: Unified error messages — don't reveal whether the account exists
if (adRow.status === 'inaktiv') return res.status(403).json({ error: 'Dein Konto ist deaktiviert.' }); if (!adRow) return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
if (adRow.status === 'inaktiv') return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
await recordSuccessfulLogin(adRow.id); await recordSuccessfulLogin(adRow.id);
// V6: Pass old token for session rotation (prevents session fixation) // V6: Pass old token for session rotation (prevents session fixation)
const oldToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', ''); const oldToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
const rawToken = await createSession(adRow.id, oldToken); const rawToken = await createSession(adRow.id, oldToken);
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
const csrfToken = setCSRFCookie(res); // P4: CSRF cookie // P4: CSRF cookie derived from the new session token (deterministic, self-healing)
auditLog(adRow.id, 'login', 'user', adRow.id, 'AD login'); const csrfToken = setCSRFCookie(res, hashToken(rawToken));
auditLog(adRow.id, 'login', 'user', adRow.id, 'AD login', req);
// Bug 6: Don't expose token in response body (cookie-only auth) // Bug 6: Don't expose token in response body (cookie-only auth)
res.json({ ...adRow, csrfToken }); res.json({ ...adRow, csrfToken });
} catch (ldapErr) { } catch (ldapErr) {
@@ -75,12 +77,14 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
} }
if (row.status === 'inaktiv') { if (row.status === 'inaktiv') {
return res.status(403).json({ error: 'Dein Konto ist deaktiviert. Bitte wende dich an einen Administrator.' }); // M3: Unified error message — don't reveal whether the account exists
return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
} }
if (row.source === 'ad') { if (row.source === 'ad') {
if (!isLDAPConfigured()) { if (!isLDAPConfigured()) {
return res.status(403).json({ error: 'AD-Anmeldung nicht konfiguriert.' }); // M3: Unified error message
return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
} }
try { try {
await authenticateLDAP(row.username || row.email.split('@')[0], password); await authenticateLDAP(row.username || row.email.split('@')[0], password);
@@ -89,8 +93,9 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
const oldTokenAD = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', ''); const oldTokenAD = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
const rawToken = await createSession(row.id, oldTokenAD); const rawToken = await createSession(row.id, oldTokenAD);
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
const csrfToken = setCSRFCookie(res); // P4: CSRF cookie // P4: CSRF cookie derived from the new session token (deterministic, self-healing)
auditLog(row.id, 'login', 'user', row.id, 'AD login'); const csrfToken = setCSRFCookie(res, hashToken(rawToken));
auditLog(row.id, 'login', 'user', row.id, 'AD login', req);
const { password: _, ...safeRow } = row; const { password: _, ...safeRow } = row;
// Bug 6: Don't expose token in response body (cookie-only auth) // Bug 6: Don't expose token in response body (cookie-only auth)
res.json({ ...safeRow, csrfToken }); res.json({ ...safeRow, csrfToken });
@@ -108,7 +113,7 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
passwordMatch = row.password === password; passwordMatch = row.password === password;
if (passwordMatch) { if (passwordMatch) {
// P8: Log plaintext login for security monitoring (auto-upgrade follows) // P8: Log plaintext login for security monitoring (auto-upgrade follows)
auditLog(row.id, 'plaintext_login_upgraded', 'user', row.id, 'Legacy plaintext password upgraded to bcrypt'); auditLog(row.id, 'plaintext_login_upgraded', 'user', row.id, 'Legacy plaintext password upgraded to bcrypt', req);
console.warn('[SECURITY] User', row.email, 'logged in with plaintext password - upgrading to bcrypt.'); console.warn('[SECURITY] User', row.email, 'logged in with plaintext password - upgrading to bcrypt.');
const hash = bcrypt.hashSync(password, 12); // Punkt 10: 12 rounds per OWASP const hash = bcrypt.hashSync(password, 12); // Punkt 10: 12 rounds per OWASP
await db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hash, row.id); await db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hash, row.id);
@@ -123,8 +128,9 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
const oldTokenLocal = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', ''); const oldTokenLocal = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
const rawToken = await createSession(row.id, oldTokenLocal); const rawToken = await createSession(row.id, oldTokenLocal);
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
const csrfToken = setCSRFCookie(res); // P4: CSRF cookie // P4: CSRF cookie derived from the new session token (deterministic, self-healing)
auditLog(row.id, 'login', 'user', row.id, 'Local login'); const csrfToken = setCSRFCookie(res, hashToken(rawToken));
auditLog(row.id, 'login', 'user', row.id, 'Local login', req);
const { password: _, ...safeRow } = row; const { password: _, ...safeRow } = row;
// Bug 6: Don't expose token in response body (cookie-only auth) // Bug 6: Don't expose token in response body (cookie-only auth)
res.json({ ...safeRow, csrfToken }); res.json({ ...safeRow, csrfToken });
@@ -134,7 +140,7 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
// Logout // Logout
router.post('/logout', async (req, res) => { router.post('/logout', async (req, res) => {
const rawToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', ''); const rawToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
await deleteSession(rawToken); await deleteSession(rawToken, req);
clearAuthCookie(res); // Punkt 8: Clear HttpOnly-Cookie clearAuthCookie(res); // Punkt 8: Clear HttpOnly-Cookie
res.json({ message: 'Abgemeldet.' }); res.json({ message: 'Abgemeldet.' });
}); });
@@ -144,4 +150,40 @@ router.get('/me', authMiddleware, (req, res) => {
res.json(req.user); res.json(req.user);
}); });
// ============ OpenID Connect (Keycloak) ============
const oidc = require('../oidc');
// Status-Endpoint fürs Frontend: OIDC-Button nur anzeigen, wenn konfiguriert
router.get('/oidc/status', (req, res) => {
res.json({ configured: oidc.isOIDCConfigured() });
});
// Schritt 1: Weiterleitung zu Keycloak (Authorization Code Flow + PKCE)
router.get('/oidc/login', async (req, res) => {
try {
await oidc.startAuthFlow(req, res);
} catch (err) {
console.error('[OIDC] Login-Start fehlgeschlagen:', err.message);
res.status(502).json({ error: 'Anmeldung über OIDC momentan nicht verfügbar.' });
}
});
// Schritt 2: Callback von Keycloak — Code-Tausch, Provisionierung, Session
router.get('/oidc/callback', async (req, res) => {
const appBase = process.env.APP_BASE_URL || '';
try {
await oidc.handleCallback(req, res);
// Erfolgreich: zurück zur App (Frontend übernimmt via /auth/me)
res.redirect(appBase || '/');
} catch (err) {
console.error('[OIDC] Callback-Fehler:', err.message);
auditLog(null, 'oidc.login_failed', null, null, err.message, req);
const status = err.status || 502;
// Dem Nutzer einen verständlichen Fehler zeigen (SPA-Fallback rendert index.html;
// die Meldung geht als Query-Parameter mit und wird vom Login-Formular angezeigt)
const msg = encodeURIComponent(err.message || 'OIDC-Anmeldung fehlgeschlagen.');
res.redirect((appBase || '/') + '?oidcError=' + msg);
}
});
module.exports = router; module.exports = router;

View File

@@ -49,8 +49,11 @@ router.get('/stats', async (req, res) => {
stats[newKey] = typeof value === 'string' ? Number(value) : value; stats[newKey] = typeof value === 'string' ? Number(value) : value;
} }
// Top 5 Vorlagen nach Task-Anzahl (Graph zeigt max. 5 Säulen).
// HAVING filtert Vorlagen ohne Tasks heraus, damit der Graph nur
// tatsächlich genutzte Vorlagen zeigt.
const topTemplates = await db.prepare( const topTemplates = await db.prepare(
'SELECT t.id, t.name, COUNT(tk.id) as task_count FROM templates t LEFT JOIN tasks tk ON t.id = tk.template_id GROUP BY t.id ORDER BY task_count DESC LIMIT 5' 'SELECT t.id, t.name, COUNT(tk.id) as task_count FROM templates t LEFT JOIN tasks tk ON t.id = tk.template_id GROUP BY t.id HAVING COUNT(tk.id) > 0 ORDER BY task_count DESC LIMIT 5'
).all(); ).all();
const recentActivity = await db.prepare( const recentActivity = await db.prepare(

View File

@@ -10,12 +10,60 @@ const db = require('../db');
const { auditLog } = require('../auditLog'); const { auditLog } = require('../auditLog');
const { authMiddleware, adminMiddleware } = require('../middleware/auth'); const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const { taskCreateLimiter } = require('../middleware/rateLimit'); const { taskCreateLimiter } = require('../middleware/rateLimit');
const { validate, validateQuery, createTaskSchema, updateTaskStatusSchema, updateTaskValuesSchema, addTaskFieldSchema, paginationSchema } = require('../middleware/validation'); const { validate, validateQuery, createTaskSchema, updateTaskStatusSchema, updateTaskValuesSchema, addTaskFieldSchema, paginationSchema, isSafeUploadPath } = require('../middleware/validation');
const router = express.Router(); const router = express.Router();
router.use(authMiddleware); router.use(authMiddleware);
// H1: Defense-in-Depth — file_upload-Step-Werte werden als Download-Link
// gerendert. Nur Pfade akzeptieren, die exakt vom Upload-Endpoint stammen,
// damit kein javascript:/data:-XSS über den Wert eingeschleust werden kann.
// (Der Step-Typ ist erst serverseitig bekannt, daher die Prüfung hier.)
// Variante für Task-Create: Werte tragen step_id, Step-Typ wird nachgeschlagen.
async function validateFileUploadValues(values) {
const stepIds = values.map(v => v.step_id).filter(id => Number.isInteger(id));
if (stepIds.length === 0) return;
const placeholders = stepIds.map(() => '?').join(',');
const steps = await db.prepare(`SELECT id, type FROM template_steps WHERE id IN (${placeholders})`).all(...stepIds);
const typeMap = {};
steps.forEach(s => { typeMap[s.id] = s.type; });
for (const v of values) {
if (v.step_id && typeMap[v.step_id] === 'file_upload' && v.value && !isSafeUploadPath(v.value)) {
const err = new Error('Ungueltiger Dateipfad in Werten.');
err.status = 400;
throw err;
}
}
}
// H1: Variante für Task-Values-Update (PUT /:id/values): Das Schema enthält
// kein step_id, daher wird der Step-Typ über die bestehenden task_values
// ermittelt. Werte, die unverändert zum DB-Stand sind, werden akzeptiert
// (Legacy-Daten blockieren keine legitimen Edits); nur NEU gesetzte unsichere
// Werte werden abgelehnt.
async function validateFileUploadValueUpdates(taskId, values) {
const ids = values.map(v => v.id).filter(id => Number.isInteger(id));
if (ids.length === 0) return;
const placeholders = ids.map(() => '?').join(',');
const rows = await db.prepare(
`SELECT tv.id, tv.value as old_value, ts.type as step_type
FROM task_values tv LEFT JOIN template_steps ts ON tv.step_id = ts.id
WHERE tv.task_id = ? AND tv.id IN (${placeholders})`
).all(taskId, ...ids);
const rowMap = {};
rows.forEach(r => { rowMap[r.id] = r; });
for (const v of values) {
const row = rowMap[v.id];
if (!row) continue; // Fremde/unbekannte IDs scheitern später am UPDATE selbst
if (row.step_type === 'file_upload' && v.value && v.value !== row.old_value && !isSafeUploadPath(v.value)) {
const err = new Error('Ungueltiger Dateipfad in Werten.');
err.status = 400;
throw err;
}
}
}
// Create task - Punkt 8: Transaction // Create task - Punkt 8: Transaction
router.post('/', taskCreateLimiter, validate(createTaskSchema), async (req, res) => { router.post('/', taskCreateLimiter, validate(createTaskSchema), async (req, res) => {
const { template_id, title, values, file_path, user_id } = req.validatedBody; const { template_id, title, values, file_path, user_id } = req.validatedBody;
@@ -27,22 +75,33 @@ router.post('/', taskCreateLimiter, validate(createTaskSchema), async (req, res)
return res.status(400).json({ error: 'template_id und title sind erforderlich.' }); return res.status(400).json({ error: 'template_id und title sind erforderlich.' });
} }
const insertTask = db.prepare('INSERT INTO tasks (template_id, user_id, title, status, file_path) VALUES (?, ?, ?, \'offen\', ?)'); // H1: file_upload-Step-Werte gegen Upload-Whitelist prüfen (XSS-Schutz)
const insertValue = db.prepare('INSERT INTO task_values (task_id, step_id, value, is_checked, file_path, snap_label, snap_type, snap_page_num, snap_ad_field, snap_ad_prefix, snap_dropdown_options, snap_email_source_fields, snap_hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); try {
await validateFileUploadValues(values);
} catch (err) {
return res.status(err.status || 400).json({ error: err.message });
}
// H2: Alle Statements innerhalb der Transaktion MÜSSEN über txDb.prepare()
// erzeugt werden (fn erhält txDb als this) — sonst laufen sie außerhalb der
// Transaktion und ein Rollback ist unmöglich.
const createTask = db.transaction(async function () {
const txDb = this;
const insertTask = txDb.prepare('INSERT INTO tasks (template_id, user_id, title, status, file_path) VALUES (?, ?, ?, \'offen\', ?)');
const insertValue = txDb.prepare('INSERT INTO task_values (task_id, step_id, value, is_checked, file_path, snap_label, snap_type, snap_page_num, snap_ad_field, snap_ad_prefix, snap_dropdown_options, snap_email_source_fields, snap_hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
const createTask = db.transaction(async () => {
const info = await insertTask.run(template_id, targetUserId, title, file_path); const info = await insertTask.run(template_id, targetUserId, title, file_path);
const taskId = info.lastInsertRowid; const taskId = info.lastInsertRowid;
if (values.length > 0) { if (values.length > 0) {
// Fetch step metadata for snapshot // Fetch step metadata for snapshot (auch über txDb — konsistente Connection)
const stepIds = values.map(v => v.step_id).filter(Boolean); const stepIds = values.map(v => v.step_id).filter(Boolean);
const stepMetaMap = {}; const stepMetaMap = {};
if (stepIds.length > 0) { if (stepIds.length > 0) {
const validStepIds = stepIds.filter(id => Number.isInteger(id)); const validStepIds = stepIds.filter(id => Number.isInteger(id));
if (validStepIds.length > 0) { if (validStepIds.length > 0) {
const placeholders = validStepIds.map(() => '?').join(','); const placeholders = validStepIds.map(() => '?').join(',');
const steps = await db.prepare(`SELECT id, label, type, page_num, ad_field, ad_prefix, dropdown_options, email_source_fields, hidden FROM template_steps WHERE id IN (${placeholders})`).all(...validStepIds); const steps = await txDb.prepare(`SELECT id, label, type, page_num, ad_field, ad_prefix, dropdown_options, email_source_fields, hidden FROM template_steps WHERE id IN (${placeholders})`).all(...validStepIds);
steps.forEach(s => { stepMetaMap[s.id] = s; }); steps.forEach(s => { stepMetaMap[s.id] = s; });
} }
} }
@@ -67,7 +126,7 @@ router.post('/', taskCreateLimiter, validate(createTaskSchema), async (req, res)
try { try {
const taskId = await createTask(); const taskId = await createTask();
auditLog(req.user?.id, 'create_task', 'task', taskId, `Task created: ${title}`); auditLog(req.user?.id, 'create_task', 'task', taskId, `Task created: ${title}`, req);
res.status(201).json({ id: taskId, template_id, user_id: targetUserId, title, status: 'offen', file_path, values }); res.status(201).json({ id: taskId, template_id, user_id: targetUserId, title, status: 'offen', file_path, values });
} catch (err) { } catch (err) {
console.error('[ERROR] POST /tasks -', err.message); console.error('[ERROR] POST /tasks -', err.message);
@@ -87,7 +146,7 @@ router.patch('/:id/status', validate(updateTaskStatusSchema), async (req, res) =
} }
const info = await db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(status, taskId); const info = await db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(status, taskId);
if (info.changes === 0) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' }); if (info.changes === 0) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
auditLog(req.user?.id, 'update_task', 'task', taskId, `Status changed to: ${status}`); auditLog(req.user?.id, 'update_task', 'task', taskId, `Status changed to: ${status}`, req);
res.json({ id: taskId, status }); res.json({ id: taskId, status });
}); });
@@ -96,8 +155,17 @@ router.put('/:id/values', adminMiddleware, validate(updateTaskValuesSchema), asy
const taskId = parseInt(req.params.id); const taskId = parseInt(req.params.id);
const { values } = req.validatedBody; const { values } = req.validatedBody;
const updateValue = db.prepare('UPDATE task_values SET value = ?, is_checked = ? WHERE id = ? AND task_id = ?'); // H1: Auch beim Admin-Update nur sichere Upload-Pfade für file_upload-Steps akzeptieren
const updateTransaction = db.transaction(async (vals) => { try {
await validateFileUploadValueUpdates(taskId, values);
} catch (err) {
return res.status(err.status || 400).json({ error: err.message });
}
// H2: Statements innerhalb der Transaktion über txDb erzeugen
const updateTransaction = db.transaction(async function (vals) {
const txDb = this;
const updateValue = txDb.prepare('UPDATE task_values SET value = ?, is_checked = ? WHERE id = ? AND task_id = ?');
let updated = 0; let updated = 0;
for (const v of vals) { for (const v of vals) {
const info = await updateValue.run(v.value || '', v.is_checked ? 1 : 0, v.id, taskId); const info = await updateValue.run(v.value || '', v.is_checked ? 1 : 0, v.id, taskId);
@@ -108,7 +176,7 @@ router.put('/:id/values', adminMiddleware, validate(updateTaskValuesSchema), asy
try { try {
const updated = await updateTransaction(values); const updated = await updateTransaction(values);
auditLog(req.user?.id, 'update_task', 'task', taskId, `Updated ${updated} task values`); auditLog(req.user?.id, 'update_task', 'task', taskId, `Updated ${updated} task values`, req);
res.json({ updated, taskId }); res.json({ updated, taskId });
} catch (err) { } catch (err) {
res.status(500).json({ error: 'Interner Serverfehler.' }); res.status(500).json({ error: 'Interner Serverfehler.' });
@@ -131,7 +199,7 @@ router.post('/:id/add-field', adminMiddleware, validate(addTaskFieldSchema), asy
'INSERT INTO task_values (task_id, step_id, value, is_checked, custom_label, custom_type, custom_dropdown_options, custom_ad_field, custom_hidden, custom_email_source_fields) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO task_values (task_id, step_id, value, is_checked, custom_label, custom_type, custom_dropdown_options, custom_ad_field, custom_hidden, custom_email_source_fields) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(taskId, fieldValue, fieldType === 'checkbox' ? 0 : 0, label.trim(), fieldType, customDropdownOptions, customAdField, customHidden, customEmailSourceFields); ).run(taskId, fieldValue, fieldType === 'checkbox' ? 0 : 0, label.trim(), fieldType, customDropdownOptions, customAdField, customHidden, customEmailSourceFields);
auditLog(req.user?.id, 'task.add-field', 'task', taskId, `Added field: ${label.trim()}`); auditLog(req.user?.id, 'task.add-field', 'task', taskId, `Added field: ${label.trim()}`, req);
res.status(201).json({ res.status(201).json({
id: info.lastInsertRowid, task_id: taskId, custom_label: label.trim(), custom_type: fieldType, id: info.lastInsertRowid, task_id: taskId, custom_label: label.trim(), custom_type: fieldType,
value: fieldValue, page_num: page_num || 1, value: fieldValue, page_num: page_num || 1,
@@ -150,7 +218,7 @@ router.delete('/:id/fields/:fieldId', adminMiddleware, async (req, res) => {
const fieldId = parseInt(req.params.fieldId); const fieldId = parseInt(req.params.fieldId);
const info = await db.prepare('DELETE FROM task_values WHERE id = ? AND task_id = ? AND custom_label IS NOT NULL').run(fieldId, taskId); const info = await db.prepare('DELETE FROM task_values WHERE id = ? AND task_id = ? AND custom_label IS NOT NULL').run(fieldId, taskId);
if (info.changes === 0) return res.status(404).json({ error: 'Feld nicht gefunden oder kein benutzerdefiniertes Feld.' }); if (info.changes === 0) return res.status(404).json({ error: 'Feld nicht gefunden oder kein benutzerdefiniertes Feld.' });
auditLog(req.user?.id, 'task.delete-field', 'task', taskId, `Deleted field: ${fieldId}`); auditLog(req.user?.id, 'task.delete-field', 'task', taskId, `Deleted field: ${fieldId}`, req);
res.json({ message: 'Feld gelöscht.' }); res.json({ message: 'Feld gelöscht.' });
}); });
@@ -159,7 +227,7 @@ router.delete('/:id', adminMiddleware, async (req, res) => {
const taskId = parseInt(req.params.id); const taskId = parseInt(req.params.id);
const info = await db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId); const info = await db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
if (info.changes === 0) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' }); if (info.changes === 0) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
auditLog(req.user?.id, 'delete_task', 'task', taskId, null); auditLog(req.user?.id, 'delete_task', 'task', taskId, null, req);
res.json({ message: 'Aufgabe gelöscht.' }); res.json({ message: 'Aufgabe gelöscht.' });
}); });
@@ -169,11 +237,16 @@ router.get('/:id', async (req, res) => {
const task = await db.prepare('SELECT t.*, u.name as user_name, u.email as user_email, tpl.name as template_name, tpl.ad_create FROM tasks t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN templates tpl ON t.template_id = tpl.id WHERE t.id = ?').get(taskId); const task = await db.prepare('SELECT t.*, u.name as user_name, u.email as user_email, tpl.name as template_name, tpl.ad_create FROM tasks t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN templates tpl ON t.template_id = tpl.id WHERE t.id = ?').get(taskId);
if (!task) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' }); if (!task) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
// K2: BOLA protection — non-admins may only read their own tasks
if (task.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Keine Berechtigung, diese Aufgabe anzuzeigen.' });
}
const values = await db.prepare( const values = await db.prepare(
`SELECT tv.*, COALESCE(ts.label, tv.snap_label) as step_label, COALESCE(ts.type, tv.snap_type) as step_type, COALESCE(ts.page_num, tv.snap_page_num) as page_num, COALESCE(ts.ad_field, tv.snap_ad_field) as ad_field, COALESCE(ts.ad_prefix, tv.snap_ad_prefix) as ad_prefix, COALESCE(ts.dropdown_options, tv.snap_dropdown_options) as dropdown_options, COALESCE(ts.email_source_fields, tv.snap_email_source_fields) as email_source_fields, COALESCE(ts.hidden, tv.snap_hidden) as hidden, tv.custom_label, tv.custom_type, tv.custom_dropdown_options, tv.custom_ad_field, tv.custom_hidden, tv.custom_email_source_fields FROM task_values tv LEFT JOIN template_steps ts ON tv.step_id = ts.id WHERE tv.task_id = ? ORDER BY ts.step_order ASC, tv.id ASC` `SELECT tv.*, COALESCE(ts.label, tv.snap_label) as step_label, COALESCE(ts.type, tv.snap_type) as step_type, COALESCE(ts.page_num, tv.snap_page_num) as page_num, COALESCE(ts.ad_field, tv.snap_ad_field) as ad_field, COALESCE(ts.ad_prefix, tv.snap_ad_prefix) as ad_prefix, COALESCE(ts.dropdown_options, tv.snap_dropdown_options) as dropdown_options, COALESCE(ts.email_source_fields, tv.snap_email_source_fields) as email_source_fields, COALESCE(ts.hidden, tv.snap_hidden) as hidden, tv.custom_label, tv.custom_type, tv.custom_dropdown_options, tv.custom_ad_field, tv.custom_hidden, tv.custom_email_source_fields FROM task_values tv LEFT JOIN template_steps ts ON tv.step_id = ts.id WHERE tv.task_id = ? ORDER BY ts.step_order ASC, tv.id ASC`
).all(taskId); ).all(taskId);
auditLog(req.user?.id, 'view_task', 'task', taskId, null); auditLog(req.user?.id, 'view_task', 'task', taskId, null, req);
res.json({ ...task, values: values || [] }); res.json({ ...task, values: values || [] });
}); });
@@ -183,10 +256,14 @@ router.get('/', validateQuery(paginationSchema), async (req, res) => {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const status = req.query.status; const status = req.query.status;
let whereClause = ''; // K2: BOLA protection — non-admins only see their own tasks
const isAdmin = req.user.role === 'admin';
let whereClause = isAdmin ? '' : ' WHERE t.user_id = ?';
const params = []; const params = [];
if (!isAdmin) params.push(req.user.id);
if (status && ['offen', 'erledigt'].includes(status)) { if (status && ['offen', 'erledigt'].includes(status)) {
whereClause = ' WHERE t.status = ?'; whereClause = isAdmin ? ' WHERE t.status = ?' : ' AND t.status = ?';
params.push(status); params.push(status);
} }

View File

@@ -39,11 +39,13 @@ router.post('/', adminMiddleware, validate(createTemplateSchema), async (req, re
const fileUpload = allows_file_upload ? 1 : 0; const fileUpload = allows_file_upload ? 1 : 0;
const adCreate = ad_create ? 1 : 0; const adCreate = ad_create ? 1 : 0;
const insertTemplate = db.prepare('INSERT INTO templates (name, description, is_assignable, allows_file_upload, ad_create) VALUES (?, ?, ?, ?, ?)'); // H2: Alle Statements innerhalb der Transaktion über txDb erzeugen
const insertStep = db.prepare('INSERT INTO template_steps (template_id, page_num, label, type, step_order, email_domain, email_source_fields, dropdown_options, ad_field, hidden, ad_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); // (fn erhält txDb als this) — sonst kein Rollback möglich.
const createTemplate = db.transaction(async function () {
const txDb = this;
const insertTemplate = txDb.prepare('INSERT INTO templates (name, description, is_assignable, allows_file_upload, ad_create) VALUES (?, ?, ?, ?, ?)');
const insertStep = txDb.prepare('INSERT INTO template_steps (template_id, page_num, label, type, step_order, email_domain, email_source_fields, dropdown_options, ad_field, hidden, ad_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
// Punkt 8: Transaction for template + steps
const createTemplate = db.transaction(async () => {
const info = await insertTemplate.run(name, description, assignable, fileUpload, adCreate); const info = await insertTemplate.run(name, description, assignable, fileUpload, adCreate);
const templateId = info.lastInsertRowid; const templateId = info.lastInsertRowid;
@@ -60,7 +62,7 @@ router.post('/', adminMiddleware, validate(createTemplateSchema), async (req, re
try { try {
const templateId = await createTemplate(); const templateId = await createTemplate();
auditLog(req.user?.id, 'create_template', 'template', templateId, `Created template: ${name}`); auditLog(req.user?.id, 'create_template', 'template', templateId, `Created template: ${name}`, req);
res.status(201).json({ id: templateId, name, description, is_assignable: !!assignable, allows_file_upload: !!fileUpload, ad_create: !!adCreate, steps }); res.status(201).json({ id: templateId, name, description, is_assignable: !!assignable, allows_file_upload: !!fileUpload, ad_create: !!adCreate, steps });
} catch (err) { } catch (err) {
res.status(500).json({ error: 'Interner Serverfehler.' }); res.status(500).json({ error: 'Interner Serverfehler.' });
@@ -76,12 +78,13 @@ router.put('/:id', adminMiddleware, validate(updateTemplateSchema), async (req,
const fileUpload = allows_file_upload ? 1 : 0; const fileUpload = allows_file_upload ? 1 : 0;
const adCreate = ad_create ? 1 : 0; const adCreate = ad_create ? 1 : 0;
const updateTemplate = db.prepare('UPDATE templates SET name = ?, description = ?, is_assignable = ?, allows_file_upload = ?, ad_create = ? WHERE id = ?'); // H2: Statements innerhalb der Transaktion über txDb erzeugen
const deleteSteps = db.prepare('DELETE FROM template_steps WHERE template_id = ?'); const updateTemplateTransaction = db.transaction(async function () {
const insertStep = db.prepare('INSERT INTO template_steps (template_id, page_num, label, type, step_order, email_domain, email_source_fields, dropdown_options, ad_field, hidden, ad_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); const txDb = this;
const updateTemplate = txDb.prepare('UPDATE templates SET name = ?, description = ?, is_assignable = ?, allows_file_upload = ?, ad_create = ? WHERE id = ?');
const deleteSteps = txDb.prepare('DELETE FROM template_steps WHERE template_id = ?');
const insertStep = txDb.prepare('INSERT INTO template_steps (template_id, page_num, label, type, step_order, email_domain, email_source_fields, dropdown_options, ad_field, hidden, ad_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
// Punkt 8: Transaction for update + delete old steps + insert new steps
const updateTemplateTransaction = db.transaction(async () => {
const info = await updateTemplate.run(name, description, assignable, fileUpload, adCreate, templateId); const info = await updateTemplate.run(name, description, assignable, fileUpload, adCreate, templateId);
if (info.changes === 0) throw new Error('NOT_FOUND'); if (info.changes === 0) throw new Error('NOT_FOUND');
@@ -99,7 +102,7 @@ router.put('/:id', adminMiddleware, validate(updateTemplateSchema), async (req,
try { try {
await updateTemplateTransaction(); await updateTemplateTransaction();
auditLog(req.user?.id, 'update_template', 'template', templateId, `Updated template: ${name}`); auditLog(req.user?.id, 'update_template', 'template', templateId, `Updated template: ${name}`, req);
res.json({ id: templateId, name, description, is_assignable: !!assignable, allows_file_upload: !!fileUpload, ad_create: !!adCreate, steps }); res.json({ id: templateId, name, description, is_assignable: !!assignable, allows_file_upload: !!fileUpload, ad_create: !!adCreate, steps });
} catch (err) { } catch (err) {
if (err.message === 'NOT_FOUND') return res.status(404).json({ error: 'Vorlage nicht gefunden.' }); if (err.message === 'NOT_FOUND') return res.status(404).json({ error: 'Vorlage nicht gefunden.' });
@@ -112,7 +115,7 @@ router.delete('/:id', adminMiddleware, async (req, res) => {
const templateId = parseInt(req.params.id); const templateId = parseInt(req.params.id);
const info = await db.prepare('DELETE FROM templates WHERE id = ?').run(templateId); const info = await db.prepare('DELETE FROM templates WHERE id = ?').run(templateId);
if (info.changes === 0) return res.status(404).json({ error: 'Vorlage nicht gefunden.' }); if (info.changes === 0) return res.status(404).json({ error: 'Vorlage nicht gefunden.' });
auditLog(req.user?.id, 'delete_template', 'template', templateId, null); auditLog(req.user?.id, 'delete_template', 'template', templateId, null, req);
res.json({ message: 'Vorlage gelöscht.' }); res.json({ message: 'Vorlage gelöscht.' });
}); });

View File

@@ -1,13 +1,18 @@
/** /**
* File upload routes module. * File upload routes module.
*
* H4: Download authorization — files are tracked in the `uploads` table with
* owner_id. A user may download a file only if they own it or are admin.
* Legacy files (not in the table) are admin-only by default.
*/ */
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const multer = require('multer'); const multer = require('multer');
const { authMiddleware } = require('../middleware/auth'); const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const { uploadLimiter } = require('../middleware/rateLimit'); const { uploadLimiter } = require('../middleware/rateLimit');
const { auditLog } = require('../auditLog'); const { auditLog } = require('../auditLog');
const db = require('../db');
const router = express.Router(); const router = express.Router();
@@ -21,6 +26,25 @@ if (!fs.existsSync(uploadDir)) {
const ALLOWED_MIMES = ['application/pdf', 'image/png', 'image/jpeg', 'image/gif', 'text/plain', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']; const ALLOWED_MIMES = ['application/pdf', 'image/png', 'image/jpeg', 'image/gif', 'text/plain', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
const ALLOWED_EXTS = ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.txt', '.doc', '.docx']; const ALLOWED_EXTS = ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.txt', '.doc', '.docx'];
// H4: Magic-byte signatures for the most common allowed types. We verify the
// first bytes of the uploaded file to reject MIME-spoofed payloads.
const MAGIC_BYTES = [
{ ext: '.pdf', mime: 'application/pdf', bytes: [0x25, 0x50, 0x44, 0x46] }, // %PDF
{ ext: '.png', mime: 'image/png', bytes: [0x89, 0x50, 0x4E, 0x47] }, // PNG
{ ext: '.jpg', mime: 'image/jpeg', bytes: [0xFF, 0xD8, 0xFF] },
{ ext: '.jpeg', mime: 'image/jpeg', bytes: [0xFF, 0xD8, 0xFF] },
{ ext: '.gif', mime: 'image/gif', bytes: [0x47, 0x49, 0x46, 0x38] }, // GIF8
];
function detectMagic(buf) {
for (const sig of MAGIC_BYTES) {
if (buf.length >= sig.bytes.length && sig.bytes.every((b, i) => buf[i] === b)) {
return sig;
}
}
return null;
}
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadDir), destination: (req, file, cb) => cb(null, uploadDir),
filename: (req, file, cb) => { filename: (req, file, cb) => {
@@ -44,22 +68,69 @@ const upload = multer({
}, },
}); });
// P12: Serve uploads as attachments (prevent XSS) - requires authentication // H4: Authorized download — replaces the previous static serving which let any
router.use('/uploads', authMiddleware, express.static(uploadDir, { // logged-in user download any file. Ownership is verified against the uploads
setHeaders: (res) => { // table; legacy files (not tracked) are admin-only.
res.setHeader('Content-Disposition', 'attachment'); router.get('/uploads/:filename', authMiddleware, async (req, res) => {
res.setHeader('X-Content-Type-Options', 'nosniff'); const filename = path.basename(req.params.filename);
}, // Block path traversal — only allow safe characters that the uploader itself emits
})); if (!/^[0-9]+-[0-9]+-[a-zA-Z0-9._-]+\.[a-zA-Z0-9]+$/.test(filename)) {
return res.status(400).json({ error: 'Ungültiger Dateiname.' });
}
const filePath = path.join(uploadDir, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Datei nicht gefunden.' });
}
const uploadRow = await db.prepare('SELECT user_id FROM uploads WHERE filename = ?').get(filename);
const isAdmin = req.user.role === 'admin';
if (uploadRow) {
if (uploadRow.user_id !== req.user.id && !isAdmin) {
return res.status(403).json({ error: 'Keine Berechtigung, diese Datei herunterzuladen.' });
}
} else if (!isAdmin) {
// Legacy file not tracked in uploads table — admin only
return res.status(403).json({ error: 'Keine Berechtigung, diese Datei herunterzuladen.' });
}
res.setHeader('Content-Disposition', 'attachment');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.sendFile(filePath);
});
// Upload endpoint - Punkt 23: Rate limited per user // Upload endpoint - Punkt 23: Rate limited per user
router.post('/', authMiddleware, uploadLimiter, upload.single('file'), (req, res) => { router.post('/', authMiddleware, uploadLimiter, upload.single('file'), async (req, res) => {
if (!req.file) { if (!req.file) {
return res.status(400).json({ error: 'Keine Datei hochgeladen.' }); return res.status(400).json({ error: 'Keine Datei hochgeladen.' });
} }
const fileUrl = '/uploads/' + req.file.filename;
auditLog(req.user?.id, 'file_upload', null, null, `Uploaded: ${req.file.filename} (${req.file.size} bytes)`); // H4: Magic-byte verification — reject files whose content doesn't match
// the declared type (MIME spoofing). Text/Office types have no stable magic
// bytes, so we only enforce the binary types we can verify.
const buf = fs.readFileSync(req.file.path, { encoding: null, flag: 'r' });
const detected = detectMagic(buf);
const declaredExt = path.extname(req.file.filename).toLowerCase();
if (detected && detected.ext !== declaredExt) {
// Content doesn't match extension — delete and reject
fs.unlink(req.file.path, () => {});
auditLog(req.user?.id, 'file_upload_rejected', null, null, `Magic-byte mismatch: ${req.file.filename} (declared ${declaredExt}, detected ${detected.ext})`, req);
return res.status(400).json({ error: 'Dateiinhalt passt nicht zur Dateiendung.' });
}
// H4: Record ownership so download authorization can be enforced
try {
await db.prepare('INSERT INTO uploads (filename, user_id, original_name, size) VALUES (?, ?, ?, ?)')
.run(req.file.filename, req.user.id, req.file.originalname, req.file.size);
} catch (err) {
// If the uploads table isn't created yet (migration not applied), we still
// allow the upload but log the error — the file itself is already on disk.
console.error('[UPLOAD] uploads-Tabelle nicht verfügbar:', err.message);
}
const fileUrl = '/api/upload/uploads/' + req.file.filename;
auditLog(req.user?.id, 'file_upload', null, null, `Uploaded: ${req.file.filename} (${req.file.size} bytes)`, req);
res.json({ filename: req.file.filename, originalname: req.file.originalname, url: fileUrl, size: req.file.size }); res.json({ filename: req.file.filename, originalname: req.file.originalname, url: fileUrl, size: req.file.size });
}); });
module.exports = router; module.exports = router;

View File

@@ -49,7 +49,7 @@ router.post('/', adminMiddleware, validate(createUserSchema), async (req, res) =
try { try {
const hash = bcrypt.hashSync(password, 12); // Punkt 10: 12 rounds per OWASP const hash = bcrypt.hashSync(password, 12); // Punkt 10: 12 rounds per OWASP
const info = await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, ?, ?, \'local\')').run(email, hash, name, role, status); const info = await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, ?, ?, \'local\')').run(email, hash, name, role, status);
auditLog(req.user?.id, 'create_user', 'user', info.lastInsertRowid, `Created user: ${email}`); auditLog(req.user?.id, 'create_user', 'user', info.lastInsertRowid, `Created user: ${email}`, req);
res.status(201).json({ id: info.lastInsertRowid, email, name, role, status, source: 'local' }); res.status(201).json({ id: info.lastInsertRowid, email, name, role, status, source: 'local' });
} catch (err) { } catch (err) {
if (err.message && err.message.includes('UNIQUE constraint')) { if (err.message && err.message.includes('UNIQUE constraint')) {
@@ -62,7 +62,6 @@ router.post('/', adminMiddleware, validate(createUserSchema), async (req, res) =
// Update user // Update user
router.put('/:id', validate(updateUserSchema), async (req, res) => { router.put('/:id', validate(updateUserSchema), async (req, res) => {
const userId = parseInt(req.params.id); const userId = parseInt(req.params.id);
const { email, name, password, role, status, current_password } = req.validatedBody;
// VULN-04: Authorization check - only admin or self (with restrictions) // VULN-04: Authorization check - only admin or self (with restrictions)
const isSelf = req.user.id === userId; const isSelf = req.user.id === userId;
@@ -70,12 +69,18 @@ router.put('/:id', validate(updateUserSchema), async (req, res) => {
if (!isAdmin && !isSelf) { if (!isAdmin && !isSelf) {
return res.status(403).json({ error: 'Keine Berechtigung, diesen Nutzer zu bearbeiten.' }); return res.status(403).json({ error: 'Keine Berechtigung, diesen Nutzer zu bearbeiten.' });
} }
// Non-admins may NOT change role or status (privilege escalation prevention)
// K1: Strip role/status FIRST for non-admins (BEFORE reading values) to prevent
// privilege escalation via mass assignment. Earlier code destructured first
// and deleted afterwards, which left the local copies intact.
if (!isAdmin) { if (!isAdmin) {
delete req.validatedBody.role; delete req.validatedBody.role;
delete req.validatedBody.status; delete req.validatedBody.status;
} }
// Now safe to read — non-admins can never see role/status here
const { email, name, password, role, status, current_password } = req.validatedBody;
// P7: Non-admins changing their own password must verify the current password // P7: Non-admins changing their own password must verify the current password
if (!isAdmin && isSelf && password && password.trim()) { if (!isAdmin && isSelf && password && password.trim()) {
if (!current_password) { if (!current_password) {
@@ -108,7 +113,7 @@ router.put('/:id', validate(updateUserSchema), async (req, res) => {
return res.status(400).json({ error: 'Status muss "aktiv" oder "inaktiv" sein.' }); return res.status(400).json({ error: 'Status muss "aktiv" oder "inaktiv" sein.' });
} }
await db.prepare('UPDATE users SET role = ?, status = ? WHERE id = ?').run(finalRole, finalStatus, userId); await db.prepare('UPDATE users SET role = ?, status = ? WHERE id = ?').run(finalRole, finalStatus, userId);
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated AD user role/status`); auditLog(req.user?.id, 'update_user', 'user', userId, `Updated AD user role/status`, req);
return res.json({ id: userId, email: user.email, name: user.name, role: finalRole, status: finalStatus, source: user.source, username: user.username }); return res.json({ id: userId, email: user.email, name: user.name, role: finalRole, status: finalStatus, source: user.source, username: user.username });
} }
@@ -139,7 +144,7 @@ router.put('/:id', validate(updateUserSchema), async (req, res) => {
} }
// VULN-13: Invalidate all sessions for this user after password change // VULN-13: Invalidate all sessions for this user after password change
await invalidateUserSessions(userId); await invalidateUserSessions(userId);
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated local user with new password (sessions invalidated)`); auditLog(req.user?.id, 'update_user', 'user', userId, `Updated local user with new password (sessions invalidated)`, req);
res.json({ id: userId, email, name: finalName, role: finalRole, status: finalStatus, source: user.source, username: user.username }); res.json({ id: userId, email, name: finalName, role: finalRole, status: finalStatus, source: user.source, username: user.username });
} else { } else {
try { try {
@@ -150,7 +155,7 @@ router.put('/:id', validate(updateUserSchema), async (req, res) => {
} }
throw err; throw err;
} }
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated local user`); auditLog(req.user?.id, 'update_user', 'user', userId, `Updated local user`, req);
res.json({ id: userId, email, name: finalName, role: finalRole, status: finalStatus, source: user.source, username: user.username }); res.json({ id: userId, email, name: finalName, role: finalRole, status: finalStatus, source: user.source, username: user.username });
} }
}); });
@@ -165,7 +170,7 @@ router.delete('/:id', adminMiddleware, async (req, res) => {
} }
const info = await db.prepare('DELETE FROM users WHERE id = ?').run(userId); const info = await db.prepare('DELETE FROM users WHERE id = ?').run(userId);
if (info.changes === 0) return res.status(404).json({ error: 'Nutzer nicht gefunden.' }); if (info.changes === 0) return res.status(404).json({ error: 'Nutzer nicht gefunden.' });
auditLog(req.user?.id, 'delete_user', 'user', userId, `Deleted user: ${user.email}`); auditLog(req.user?.id, 'delete_user', 'user', userId, `Deleted user: ${user.email}`, req);
res.json({ message: 'Nutzer geloescht.' }); res.json({ message: 'Nutzer geloescht.' });
}); });

View File

@@ -48,8 +48,18 @@ const { startLDAPSync, isLDAPConfigured } = require('./ldapSync');
const app = express(); const app = express();
const PORT = process.env.PORT || 5000; const PORT = process.env.PORT || 5000;
// Trust proxy for correct IP in rate limiting (Docker/Reverse Proxy) // H3-Fix: Trust proxy konfigurierbar — hinter TLS-terminierendem Proxy (HAProxy)
app.set('trust proxy', 1); // MUSS trust proxy aktiv sein, damit Express die echte Client-IP aus
// X-Forwarded-For liest (Rate-Limiting, Audit-Log). Bei direktem Port-Zugriff
// (ohne Proxy) muss es deaktiviert sein, sonst ist X-Forwarded-For spoofbar
// und das Rate-Limiting umgehbar.
// TRUST_PROXY=true → 1 Hop vertrauen (HAProxy/Nginx davor) ← Produktion
// TRUST_PROXY=false → keine Proxy-Header vertrauen ← direkter Zugriff
// unset → true in Produktion (Docker-Stack läuft hinter HAProxy)
const TRUST_PROXY = process.env.TRUST_PROXY !== undefined
? process.env.TRUST_PROXY === 'true'
: process.env.NODE_ENV === 'production';
app.set('trust proxy', TRUST_PROXY ? 1 : false);
// ============ Security Middleware ============ // ============ Security Middleware ============
// P14: Validate CORS_ORIGIN - filter empty/invalid entries before using in CSP // P14: Validate CORS_ORIGIN - filter empty/invalid entries before using in CSP
@@ -60,6 +70,15 @@ const validCorsOrigins = rawCorsOrigin
.filter(o => o && /^https?:\/\/.+/.test(o)); .filter(o => o && /^https?:\/\/.+/.test(o));
const cspConnectSrc = ["'self'", ...validCorsOrigins]; const cspConnectSrc = ["'self'", ...validCorsOrigins];
// M1: HSTS only makes sense over HTTPS. When serving plain HTTP (e.g. without
// a TLS-terminating proxy), HSTS is ignored by browsers and can even cause
// issues. Allow disabling it via HSTS_ENABLED=false (default: enabled in prod
// when COOKIE_SECURE is true, i.e. when TLS is expected).
const { COOKIE_SECURE } = require('./middleware/auth');
const hstsEnabled = process.env.HSTS_ENABLED !== undefined
? process.env.HSTS_ENABLED === 'true'
: COOKIE_SECURE;
app.use(helmet({ app.use(helmet({
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
@@ -71,12 +90,12 @@ app.use(helmet({
fontSrc: ["'self'", "data:"], fontSrc: ["'self'", "data:"],
}, },
}, },
// P18: HSTS - enforce HTTPS in production // P18: HSTS - enforce HTTPS in production (only effective over HTTPS)
hsts: { hsts: hstsEnabled ? {
maxAge: 31536000, maxAge: 31536000,
includeSubDomains: true, includeSubDomains: true,
preload: true, preload: true,
}, } : false,
crossOriginEmbedderPolicy: false, crossOriginEmbedderPolicy: false,
})); }));
@@ -87,14 +106,16 @@ const allowedOrigins = validCorsOrigins.length > 0
: ['http://localhost:5000', 'http://localhost:5173', 'http://127.0.0.1:5000', 'http://127.0.0.1:5173']; : ['http://localhost:5000', 'http://localhost:5173', 'http://127.0.0.1:5000', 'http://127.0.0.1:5173'];
app.use(cors({ origin: allowedOrigins, credentials: true })); app.use(cors({ origin: allowedOrigins, credentials: true }));
// Punkt 14: Body-Size-Limit to prevent DoS via large payloads // Punkt 14: Body-Size-Limit to prevent DoS via large payloads
app.use(express.json({ limit: process.env.BODY_LIMIT || '1mb' })); // N4: 10MB to accommodate file uploads (matches UPLOAD_MAX_MB)
app.use(express.json({ limit: process.env.BODY_LIMIT || '10mb' }));
// Punkt 8: Cookie-Parser für HttpOnly-Cookie Auth // Punkt 8: Cookie-Parser für HttpOnly-Cookie Auth
app.use(cookieParser()); app.use(cookieParser());
// P4: CSRF protection for state-changing requests (Double-Submit-Cookie) // P4: CSRF protection for state-changing requests (Double-Submit-Cookie)
// Skip CSRF check for login/register (no session yet, no CSRF token available) // Skip CSRF check for login/register (no session yet, no CSRF token available)
// and OIDC flow endpoints (GET-Redirects; der Flow selbst ist per State-Cookie geschützt)
app.use('/api', (req, res, next) => { app.use('/api', (req, res, next) => {
if (req.path.startsWith('/auth/login') || req.path.startsWith('/auth/register') || req.path.startsWith('/v1/auth/login') || req.path.startsWith('/v1/auth/register')) { if (req.path.startsWith('/auth/login') || req.path.startsWith('/auth/register') || req.path.startsWith('/v1/auth/login') || req.path.startsWith('/v1/auth/register') || req.path.startsWith('/auth/oidc/') || req.path.startsWith('/v1/auth/oidc/')) {
return next(); return next();
} }
csrfMiddleware(req, res, next); csrfMiddleware(req, res, next);

54
backend/umlauts.js Normal file
View File

@@ -0,0 +1,54 @@
/**
* Umlaut-Transliteration für AD-kompatible Namen (E-Mails, sAMAccountName, UPN).
*
* Wandelt deutsche Sonderzeichen in ASCII-Äquivalente um:
* ä→ae, ö→oe, ü→ue, ß→ss (und Großvarianten)
*
* WICHTIG: Reihenfolge — Großbuchstaben-Varianten VOR toLowerCase() behandeln,
* damit "Schröder" → "Schroeder" (nicht "schroder") und "Straße" → "Strasse".
*/
function transliterateUmlauts(str) {
if (typeof str !== 'string') return '';
return str
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss');
}
/**
* Normalisiert einen Namen für den lokalen Teil einer E-Mail-Adresse:
* Umlaute → ASCII, Kleinbuchstaben, Leerzeichen → Punkte, nur a-z0-9. erlaubt.
* Beispiel: "Jürgen Schröder" → "juergen.schroeder"
*
* RFC 5321-konform: Kein führender/abschließender Punkt, keine Doppelpunkte
* (".."), kein leerer Local-Part (gibt '' zurück — Aufrufer muss Fallback haben).
*/
function toEmailLocalPart(str) {
if (typeof str !== 'string') return '';
return transliterateUmlauts(str)
.toLowerCase()
.replace(/\s+/g, '.')
.replace(/[^a-z0-9.]/g, '')
.replace(/\.{2,}/g, '.') // ".." → "." (durch aufeinanderfolgende Trenner)
.replace(/^\.+|\.+$/g, ''); // führende/abschließende Punkte entfernen (RFC 5321)
}
/**
* Normalisiert einen Namen für sAMAccountName/UPN (Punkt-Format):
* wie toEmailLocalPart, aber ohne Punkt-Entfernung (Punkte sind erlaubt).
*/
function toAdAccountName(str) {
return toEmailLocalPart(str);
}
/**
* Normalisiert einen Namen für das Pre-Windows-2000-Format (Nachname+Initial):
* Umlaute → ASCII, nur a-zA-Z0-9 erlaubt, Groß-/Kleinschreibung bleibt erhalten.
* Beispiel: "Schröder" + "J" → "SchroederJ"
*/
function toSamAccountName(str) {
if (typeof str !== 'string') return '';
return transliterateUmlauts(str).replace(/[^a-zA-Z0-9]/g, '');
}
module.exports = { transliterateUmlauts, toEmailLocalPart, toAdAccountName, toSamAccountName };

View File

@@ -6,9 +6,9 @@
services: services:
app: app:
image: gitea.leonkn.de/lxe/workflow-app:DEV1.0 image: gitea.leonkn.de/lxe/workflow-app:DEV2.0-v11
ports: ports:
- "5000:5000" - "3900:5000"
volumes: volumes:
- app-data:/app/data - app-data:/app/data
environment: environment:
@@ -24,10 +24,28 @@ services:
- LDAP_ATTRIBUTES=${LDAP_ATTRIBUTES:-mail,displayName,memberOf,distinguishedName,sAMAccountName} - LDAP_ATTRIBUTES=${LDAP_ATTRIBUTES:-mail,displayName,memberOf,distinguishedName,sAMAccountName}
- LDAP_CREATE_OU=${LDAP_CREATE_OU:-} - LDAP_CREATE_OU=${LDAP_CREATE_OU:-}
- LDAP_UPN_SUFFIX=${LDAP_UPN_SUFFIX:-} - LDAP_UPN_SUFFIX=${LDAP_UPN_SUFFIX:-}
# OIDC (Keycloak) — leer = deaktiviert, Login-Button wird nicht angezeigt
- OIDC_ISSUER=${OIDC_ISSUER:-}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
- OIDC_LOGOUT_URL=${OIDC_LOGOUT_URL:-}
- APP_BASE_URL=${APP_BASE_URL:-}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@workflow.local} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@workflow.local}
- ADMIN_INIT_PASSWORD=${ADMIN_INIT_PASSWORD:-} - ADMIN_INIT_PASSWORD=${ADMIN_INIT_PASSWORD:-}
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:5000} # M3: Session-Secret für CSRF-Token-Ableitung (Pflicht in Produktion)
- SESSION_SECRET=${SESSION_SECRET:-}
# M1: Cookie/HSTS-Sicherheit (false für plain HTTP, true hinter TLS-Proxy)
- COOKIE_SECURE=${COOKIE_SECURE:-false}
- HSTS_ENABLED=${HSTS_ENABLED:-false}
# H3: Massenlösch-Schutz beim LDAP-Sync (max % löschbarer AD-User pro Sync)
- LDAP_MAX_DELETE_PCT=${LDAP_MAX_DELETE_PCT:-25}
# Upload-Größenlimit in MB
- UPLOAD_MAX_MB=${UPLOAD_MAX_MB:-10}
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3900}
- NODE_ENV=${NODE_ENV:-production} - NODE_ENV=${NODE_ENV:-production}
# H3: Proxy-Header vertrauen (true hinter HAProxy/Nginx, false bei direktem Zugriff)
- TRUST_PROXY=${TRUST_PROXY:-true}
- DATABASE_URL=postgresql://${POSTGRES_USER:-workflow}:${POSTGRES_PASSWORD:-workflow}@db:5432/${POSTGRES_DB:-workflow} - DATABASE_URL=postgresql://${POSTGRES_USER:-workflow}:${POSTGRES_PASSWORD:-workflow}@db:5432/${POSTGRES_DB:-workflow}
- SESSION_MAX_PER_USER=${SESSION_MAX_PER_USER:-5} - SESSION_MAX_PER_USER=${SESSION_MAX_PER_USER:-5}
- SESSION_TTL_HOURS=${SESSION_TTL_HOURS:-168} - SESSION_TTL_HOURS=${SESSION_TTL_HOURS:-168}

View File

@@ -2,7 +2,7 @@ services:
app: app:
build: . build: .
ports: ports:
- "5000:5000" - "3900:5000"
volumes: volumes:
- ./backend/data:/app/data - ./backend/data:/app/data
environment: environment:
@@ -18,10 +18,21 @@ services:
- LDAP_ATTRIBUTES=${LDAP_ATTRIBUTES:-mail,displayName,memberOf,distinguishedName,sAMAccountName} - LDAP_ATTRIBUTES=${LDAP_ATTRIBUTES:-mail,displayName,memberOf,distinguishedName,sAMAccountName}
- LDAP_CREATE_OU=${LDAP_CREATE_OU:-} - LDAP_CREATE_OU=${LDAP_CREATE_OU:-}
- LDAP_UPN_SUFFIX=${LDAP_UPN_SUFFIX:-} - LDAP_UPN_SUFFIX=${LDAP_UPN_SUFFIX:-}
# OIDC (Keycloak) — leer = deaktiviert, Login-Button wird nicht angezeigt
- OIDC_ISSUER=${OIDC_ISSUER:-}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
- OIDC_LOGOUT_URL=${OIDC_LOGOUT_URL:-}
- APP_BASE_URL=${APP_BASE_URL:-}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@workflow.local} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@workflow.local}
- ADMIN_INIT_PASSWORD=${ADMIN_INIT_PASSWORD:-} - ADMIN_INIT_PASSWORD=${ADMIN_INIT_PASSWORD:-}
# M3: Session-Secret für CSRF-Token-Ableitung (Pflicht in Produktion)
- SESSION_SECRET=${SESSION_SECRET:-}
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:5000} - CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:5000}
- NODE_ENV=${NODE_ENV:-production} - NODE_ENV=${NODE_ENV:-production}
# H3: Proxy-Header vertrauen (true hinter HAProxy/Nginx, false bei direktem Zugriff)
- TRUST_PROXY=${TRUST_PROXY:-true}
# Punkt 4: PostgreSQL (auto-started) # Punkt 4: PostgreSQL (auto-started)
- DATABASE_URL=postgresql://${POSTGRES_USER:-workflow}:${POSTGRES_PASSWORD:-workflow}@db:5432/${POSTGRES_DB:-workflow} - DATABASE_URL=postgresql://${POSTGRES_USER:-workflow}:${POSTGRES_PASSWORD:-workflow}@db:5432/${POSTGRES_DB:-workflow}
# Punkt 9: Session-Limitierung # Punkt 9: Session-Limitierung

View File

@@ -1,3 +0,0 @@
# Frontend Environment Variables
# API base URL for backend (default: http://localhost:5000/api)
VITE_API_BASE=http://localhost:5000/api

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider, useAuth } from './context/AuthContext'; import { AuthProvider, useAuth } from './context/AuthContext';
import { ThemeProvider } from './context/ThemeContext';
import { ToastProvider, useToast } from './components/Toast'; import { ToastProvider, useToast } from './components/Toast';
import { setGlobalErrorHandler } from './utils/api'; import { setGlobalErrorHandler } from './utils/api';
import AuthModal from './components/AuthModal'; import AuthModal from './components/AuthModal';
@@ -42,7 +43,7 @@ function AppContent() {
// #13: Map tab IDs to display labels for mobile navbar indicator // #13: Map tab IDs to display labels for mobile navbar indicator
const tabLabels = { const tabLabels = {
dashboard: 'Vorlagen', dashboard: 'Dashboard',
templates: 'Vorlageneditor', templates: 'Vorlageneditor',
tasks: 'Aufgaben', tasks: 'Aufgaben',
users: 'Nutzerverwaltung', users: 'Nutzerverwaltung',
@@ -50,8 +51,14 @@ function AppContent() {
}; };
// Punkt 1: Immediate tab switch - no skeleton/transition delay to avoid bounce // Punkt 1: Immediate tab switch - no skeleton/transition delay to avoid bounce
// Admin-only tabs: non-admins are redirected to dashboard (defense in depth)
const ADMIN_TABS = ['templates', 'tasks', 'users', 'auditlog'];
const handleTabChange = (newTab) => { const handleTabChange = (newTab) => {
if (newTab === activeTab) return; if (newTab === activeTab) return;
if (user?.role !== 'admin' && ADMIN_TABS.includes(newTab)) {
setActiveTab('dashboard');
return;
}
setActiveTab(newTab); setActiveTab(newTab);
}; };
@@ -74,21 +81,21 @@ function AppContent() {
const renderPages = () => ( const renderPages = () => (
<> <>
{/* Dashboard is always rendered for all users */} {/* Dashboard is always rendered for all users */}
<div className={activeTab === 'dashboard' ? 'block' : 'hidden'}> <div className={activeTab === 'dashboard' ? 'h-full flex flex-col min-h-0' : 'hidden'}>
<Dashboard onNavigate={handleTabChange} /> <Dashboard onNavigate={handleTabChange} />
</div> </div>
{isAdmin && ( {isAdmin && (
<> <>
<div className={activeTab === 'templates' ? 'block' : 'hidden'}> <div className={activeTab === 'templates' ? 'h-full flex flex-col min-h-0' : 'hidden'}>
<TemplatesPage /> <TemplatesPage />
</div> </div>
<div className={activeTab === 'tasks' ? 'block' : 'hidden'}> <div className={activeTab === 'tasks' ? 'h-full flex flex-col min-h-0' : 'hidden'}>
<TasksPage /> <TasksPage />
</div> </div>
<div className={activeTab === 'users' ? 'block' : 'hidden'}> <div className={activeTab === 'users' ? 'h-full flex flex-col min-h-0' : 'hidden'}>
<UsersPage /> <UsersPage />
</div> </div>
<div className={activeTab === 'auditlog' ? 'block' : 'hidden'}> <div className={activeTab === 'auditlog' ? 'h-full flex flex-col min-h-0' : 'hidden'}>
<AuditLogPage /> <AuditLogPage />
</div> </div>
</> </>
@@ -100,10 +107,10 @@ function AppContent() {
); );
return ( return (
<div className="drawer lg:drawer-open"> <div className="drawer lg:drawer-open h-screen overflow-hidden">
<input id="sidebar-drawer" type="checkbox" className="drawer-toggle" /> <input id="sidebar-drawer" type="checkbox" className="drawer-toggle" />
<div className="drawer-content flex flex-col"> <div className="drawer-content flex flex-col h-screen">
{/* Mobile navbar */} {/* Mobile navbar */}
<div className="lg:hidden navbar bg-base-100 shadow-md px-4 sticky top-0 z-30"> <div className="lg:hidden navbar bg-base-100 shadow-md px-4 sticky top-0 z-30">
<label htmlFor="sidebar-drawer" className="btn btn-square btn-ghost drawer-button"> <label htmlFor="sidebar-drawer" className="btn btn-square btn-ghost drawer-button">
@@ -114,10 +121,10 @@ function AppContent() {
<span className="ml-2 font-bold">{tabLabels[activeTab] || 'Workflow Portal'}</span> <span className="ml-2 font-bold">{tabLabels[activeTab] || 'Workflow Portal'}</span>
</div> </div>
{/* Main content */} {/* Main content — h-full für flex-1 Listen in den Seiten */}
<main className="flex-1 overflow-auto bg-base-100"> <main className="flex-1 min-h-0 overflow-auto bg-base-100">
<ErrorBoundary> <ErrorBoundary>
<div> <div className="h-full flex flex-col">
{renderPages()} {renderPages()}
</div> </div>
</ErrorBoundary> </ErrorBoundary>
@@ -133,11 +140,13 @@ function AppContent() {
export default function App() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider> <ThemeProvider>
<ToastProvider> <AuthProvider>
<AppContent /> <ToastProvider>
</ToastProvider> <AppContent />
</AuthProvider> </ToastProvider>
</AuthProvider>
</ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { API_BASE } from '../utils/api';
export default function AuthModal() { export default function AuthModal() {
const { login, register } = useAuth(); const { login, register } = useAuth();
@@ -11,6 +12,22 @@ export default function AuthModal() {
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [oidcConfigured, setOidcConfigured] = useState(false);
// OIDC-Verfügbarkeit + Fehler aus dem Callback (als Query-Parameter) laden
useEffect(() => {
fetch(`${API_BASE}/auth/oidc/status`)
.then((res) => (res.ok ? res.json() : { configured: false }))
.then((data) => setOidcConfigured(!!data.configured))
.catch(() => setOidcConfigured(false));
const params = new URLSearchParams(window.location.search);
const oidcError = params.get('oidcError');
if (oidcError) {
setError(oidcError);
// Parameter aus der URL entfernen (sauberer Zustand bei Reload)
window.history.replaceState({}, '', window.location.pathname);
}
}, []);
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
@@ -138,6 +155,20 @@ export default function AuthModal() {
<button className="btn btn-outline btn-sm w-full" onClick={toggleMode}> <button className="btn btn-outline btn-sm w-full" onClick={toggleMode}>
{mode === 'login' ? 'Neuen Account erstellen' : 'Zurück zur Anmeldung'} {mode === 'login' ? 'Neuen Account erstellen' : 'Zurück zur Anmeldung'}
</button> </button>
{/* OIDC (Keycloak) — nur angezeigt, wenn serverseitig konfiguriert */}
{oidcConfigured && (
<>
<div className="divider text-sm">Single Sign-On</div>
<a
href={`${API_BASE}/auth/oidc/login`}
className="btn btn-secondary w-full"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 7a2 2 0 012 2m4 2a8 8 0 01-8 8m0 0a8 8 0 01-8-8 8 8 0 018-8 8 8 0 018 8m-6 4h.01M12 15v.01" /></svg>
Mit Unternehmens-Konto anmelden
</a>
</>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useToast, ConfirmModal } from './Toast'; import { useToast, ConfirmModal } from './Toast';
import { apiFetch, FILE_BASE } from '../utils/api'; import { apiFetch } from '../utils/api';
import { toEmailLocalPart, toSamAccountName } from '../utils/umlauts';
export default function FillModal({ template, onSubmit, onClose }) { export default function FillModal({ template, onSubmit, onClose }) {
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
@@ -95,10 +96,12 @@ export default function FillModal({ template, onSubmit, onClose }) {
const parts = sourceFields.map((fieldLabel) => { const parts = sourceFields.map((fieldLabel) => {
const sourceStep = template.steps.find((s) => s.label === fieldLabel); const sourceStep = template.steps.find((s) => s.label === fieldLabel);
if (!sourceStep || !values[sourceStep.id]) return ''; if (!sourceStep || !values[sourceStep.id]) return '';
return values[sourceStep.id].trim().toLowerCase().replace(/\s+/g, '.'); // FIX: Umlaute konvertieren (ä→ae etc.) — sonst ungültige E-Mails wie j.schröder@...
}).filter(Boolean); return toEmailLocalPart(values[sourceStep.id]);
}).filter(Boolean); // leere Teile (z.B. Name nur aus Sonderzeichen) fallen weg
if (parts.length === 0) return ''; if (parts.length === 0) return '';
const domain = step.email_domain.startsWith('@') ? step.email_domain : '@' + step.email_domain; const domain = step.email_domain.startsWith('@') ? step.email_domain : '@' + step.email_domain;
// parts sind durch toEmailLocalPart bereits punkten- und leer-part-bereinigt
return parts.join('.') + domain; return parts.join('.') + domain;
}; };
@@ -151,13 +154,7 @@ export default function FillModal({ template, onSubmit, onClose }) {
return sourceStep ? (values[sourceStep.id] || '').trim() : ''; return sourceStep ? (values[sourceStep.id] || '').trim() : '';
}).filter(Boolean); }).filter(Boolean);
if (sourceValues.length > 0) { if (sourceValues.length > 0) {
value = sourceValues.join('.') value = toEmailLocalPart(sourceValues.join('.'));
.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'ae').replace(/Ö/g, 'oe').replace(/Ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/\s+/g, '.')
.replace(/[^a-zA-Z0-9.]/g, '');
} else { } else {
value = ''; value = '';
} }
@@ -168,11 +165,7 @@ export default function FillModal({ template, onSubmit, onClose }) {
const vorname = vornameStep ? (values[vornameStep.id] || '').trim() : ''; const vorname = vornameStep ? (values[vornameStep.id] || '').trim() : '';
const nachname = nachnameStep ? (values[nachnameStep.id] || '').trim() : ''; const nachname = nachnameStep ? (values[nachnameStep.id] || '').trim() : '';
if (vorname && nachname) { if (vorname && nachname) {
value = (nachname + vorname.charAt(0)) value = toSamAccountName(nachname + vorname.charAt(0));
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss')
.replace(/[^a-zA-Z0-9]/g, '');
} else { } else {
value = values[s.id] || ''; value = values[s.id] || '';
} }
@@ -182,9 +175,9 @@ export default function FillModal({ template, onSubmit, onClose }) {
} }
return { return {
step_id: s.id, step_id: s.id,
value, value: value || '',
is_checked: s.type === 'checkbox' ? (values[s.id] ? 1 : 0) : 0, is_checked: s.type === 'checkbox' ? !!values[s.id] : false,
file_path: null, file_path: undefined,
}; };
}); });
@@ -194,7 +187,7 @@ export default function FillModal({ template, onSubmit, onClose }) {
template_id: template.id, template_id: template.id,
title: template.name, title: template.name,
values: taskValues, values: taskValues,
file_path: (allowsFileUpload && uploadedFile) ? uploadedFile : null, file_path: (allowsFileUpload && uploadedFile) ? uploadedFile : undefined,
}); });
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@@ -337,13 +330,7 @@ export default function FillModal({ template, onSubmit, onClose }) {
return sourceStep ? (values[sourceStep.id] || '').trim() : ''; return sourceStep ? (values[sourceStep.id] || '').trim() : '';
}).filter(Boolean); }).filter(Boolean);
if (sourceValues.length > 0) { if (sourceValues.length > 0) {
return sourceValues.join('.') return toEmailLocalPart(sourceValues.join('.'));
.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'ae').replace(/Ö/g, 'oe').replace(/Ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/\s+/g, '.')
.replace(/[^a-zA-Z0-9.]/g, '');
} }
return ''; return '';
} }
@@ -353,11 +340,7 @@ export default function FillModal({ template, onSubmit, onClose }) {
const vorname = vornameStep ? (values[vornameStep.id] || '').trim() : ''; const vorname = vornameStep ? (values[vornameStep.id] || '').trim() : '';
const nachname = nachnameStep ? (values[nachnameStep.id] || '').trim() : ''; const nachname = nachnameStep ? (values[nachnameStep.id] || '').trim() : '';
if (vorname && nachname) { if (vorname && nachname) {
return (nachname + vorname.charAt(0)) return toSamAccountName(nachname + vorname.charAt(0));
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss')
.replace(/[^a-zA-Z0-9]/g, '');
} }
return ''; return '';
})()} })()}

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useTheme } from '../context/ThemeContext';
// Punkt 5: SVG Icons für jeden Tab // Punkt 5: SVG Icons für jeden Tab
const tabIcons = { const tabIcons = {
@@ -22,12 +23,14 @@ const tabIcons = {
export default function Sidebar({ activeTab, onTabChange }) { export default function Sidebar({ activeTab, onTabChange }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { isDark, toggleTheme } = useTheme();
// Punkt 19: Collapsible Sidebar State // Punkt 19: Collapsible Sidebar State
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const tabs = [ const tabs = [
{ id: 'dashboard', label: 'Vorlagen' }, { id: 'dashboard', label: 'Dashboard' },
{ id: 'templates', label: 'Vorlageneditor' }, // Vorlageneditor nur für Admins sichtbar
...(user?.role === 'admin' ? [{ id: 'templates', label: 'Vorlageneditor' }] : []),
...(user?.role === 'admin' ? [{ id: 'tasks', label: 'Aufgaben' }] : []), ...(user?.role === 'admin' ? [{ id: 'tasks', label: 'Aufgaben' }] : []),
...(user?.role === 'admin' ? [{ id: 'users', label: 'Nutzerverwaltung' }] : []), ...(user?.role === 'admin' ? [{ id: 'users', label: 'Nutzerverwaltung' }] : []),
...(user?.role === 'admin' ? [{ id: 'auditlog', label: 'Audit-Log' }] : []), ...(user?.role === 'admin' ? [{ id: 'auditlog', label: 'Audit-Log' }] : []),
@@ -100,6 +103,26 @@ export default function Sidebar({ activeTab, onTabChange }) {
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg> <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
</button> </button>
)} )}
{/* Theme-Toggle: Dark/Light-Mode, individuelle Einstellung pro Nutzer (localStorage) */}
{!collapsed && (
<button className="btn btn-ghost btn-sm w-full justify-start gap-2 mt-2" onClick={toggleTheme} title={isDark ? 'Zu hellem Design wechseln' : 'Zu dunklem Design wechseln'}>
{isDark ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
)}
<span>{isDark ? 'Helles Design' : 'Dunkles Design'}</span>
</button>
)}
{collapsed && (
<button className="btn btn-ghost btn-xs btn-square" onClick={toggleTheme} title={isDark ? 'Zu hellem Design wechseln' : 'Zu dunklem Design wechseln'}>
{isDark ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
)}
</button>
)}
</div> </div>
</aside> </aside>
</div> </div>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { apiFetch, FILE_BASE } from '../utils/api'; import { apiFetch, safeFileUrl } from '../utils/api';
import { useToast, ConfirmModal } from './Toast'; import { useToast, ConfirmModal } from './Toast';
const CUSTOM_STEP_TYPES = [ const CUSTOM_STEP_TYPES = [
@@ -843,13 +843,13 @@ export default function TaskModal({ task, currentUserRole, onClose, onTaskUpdate
); );
})} })}
{/* File attachment */} {/* File attachment — H1: nur validierte Upload-Pfade als Link */}
{task.file_path && ( {safeFileUrl(task.file_path) && (
<div className="mb-4"> <div className="mb-4">
<h4 className="font-semibold text-sm opacity-70 mb-2">Dateianhang</h4> <h4 className="font-semibold text-sm opacity-70 mb-2">Dateianhang</h4>
<div className="bg-base-200 p-3 rounded-lg"> <div className="bg-base-200 p-3 rounded-lg">
<a <a
href={`${FILE_BASE}${task.file_path}`} href={safeFileUrl(task.file_path)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="btn btn-outline btn-sm" className="btn btn-outline btn-sm"
@@ -861,7 +861,7 @@ export default function TaskModal({ task, currentUserRole, onClose, onTaskUpdate
</div> </div>
)} )}
{(!task.values || task.values.length === 0) && !task.file_path && ( {(!task.values || task.values.length === 0) && !safeFileUrl(task.file_path) && (
<div className="text-center py-4 opacity-50">Keine Werte vorhanden.</div> <div className="text-center py-4 opacity-50">Keine Werte vorhanden.</div>
)} )}

View File

@@ -1,7 +1,9 @@
import React from 'react'; import React from 'react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
export default function TemplateCard({ template, onOpen, onEdit, onDelete }) { // action: optionales Action-Element (z.B. "Starten"-Button im Dashboard).
// Wenn gesetzt, ersetzt es die Admin-Aktionen (Bearbeiten/Löschen).
export default function TemplateCard({ template, onOpen, onEdit, onDelete, action }) {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
@@ -22,7 +24,9 @@ export default function TemplateCard({ template, onOpen, onEdit, onDelete }) {
</p> </p>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
{isAdmin && ( {action ? (
action
) : isAdmin && (
<> <>
<button className="btn btn-outline btn-sm" onClick={() => onEdit(template)}> <button className="btn btn-outline btn-sm" onClick={() => onEdit(template)}>
Bearbeiten Bearbeiten

View File

@@ -0,0 +1,53 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
/**
* Theme-Context: Dark/Light-Mode-Umschaltung für daisyUI (data-theme auf <html>).
* - Persistenz in localStorage ('workflow_theme': 'dark' | 'light')
* - Ohne gespeicherte Wahl folgt das Theme der Systemeinstellung (prefers-color-scheme)
* - FOUC-Schutz: initiales data-theme wird synchron vor dem ersten Render gesetzt
*/
const STORAGE_KEY = 'workflow_theme';
// Synchron beim Modul-Load ausführen — verhindert einen Theme-Flash beim Start
function resolveInitialTheme() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === 'dark' || saved === 'light') return saved;
} catch (e) { /* localStorage nicht verfügbar — Fallback unten */ }
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
// Direkt beim Laden das Theme setzen (vor React-Render)
const initialTheme = resolveInitialTheme();
document.documentElement.setAttribute('data-theme', initialTheme);
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(initialTheme);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch (e) { /* Speichern fehlgeschlagen — Theme bleibt für diese Session aktiv */ }
}, [theme]);
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
return (
<ThemeContext.Provider value={{ theme, toggleTheme, isDark: theme === 'dark' }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import FillModal from '../components/FillModal'; import FillModal from '../components/FillModal';
import TemplateCard from '../components/TemplateCard';
import { apiFetch } from '../utils/api'; import { apiFetch } from '../utils/api';
export default function Dashboard({ onNavigate }) { export default function Dashboard({ onNavigate }) {
@@ -13,6 +14,9 @@ export default function Dashboard({ onNavigate }) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [fillTemplate, setFillTemplate] = useState(null); const [fillTemplate, setFillTemplate] = useState(null);
// Pagination für die Vorlagen-Table-Liste (10 Einträge pro Seite)
const TEMPLATES_PER_PAGE = 10;
const [currentPage, setCurrentPage] = useState(1);
const fetchTemplates = useCallback(async () => { const fetchTemplates = useCallback(async () => {
try { try {
@@ -52,6 +56,12 @@ export default function Dashboard({ onNavigate }) {
t.name.toLowerCase().includes(search.toLowerCase()) || t.name.toLowerCase().includes(search.toLowerCase()) ||
(t.description || '').toLowerCase().includes(search.toLowerCase()) (t.description || '').toLowerCase().includes(search.toLowerCase())
); );
// Pagination: Suchwechsel setzt auf Seite 1 zurück
const totalPages = Math.max(Math.ceil(filtered.length / TEMPLATES_PER_PAGE), 1);
const pagedTemplates = filtered.slice((currentPage - 1) * TEMPLATES_PER_PAGE, currentPage * TEMPLATES_PER_PAGE);
useEffect(() => {
setCurrentPage(1);
}, [search]);
const handleSubmitTask = async (payload) => { const handleSubmitTask = async (payload) => {
const res = await apiFetch('/tasks', { const res = await apiFetch('/tasks', {
@@ -160,42 +170,11 @@ export default function Dashboard({ onNavigate }) {
</section> </section>
)} )}
{/* Haupt-Grid: Top-Vorlagen (links) | Vorlagen mit Suche (rechts) */} {/* Haupt-Grid: Vorlagen-Liste (links) | Graph-Card (rechts) — gleich große Spalten */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-stretch">
{/* Linke Spalte: Top-Vorlagen Rangliste */} {/* Linke Spalte: Vorlagen als Table-Liste */}
{isAdmin && topTemplates.length > 0 && ( <section aria-label="Vorlagen" className="flex flex-col">
<section className="lg:col-span-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
<div className="flex items-center justify-between mb-4 h-[38px]">
<h2 className="text-xl font-medium tracking-tight">Meistgenutzte Vorlagen</h2>
</div>
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden">
<div className="divide-y divide-base-300">
{topTemplates.map((t) => (
<div
key={t.id}
className="p-4 flex items-center justify-between hover:bg-base-300/50 transition-colors"
>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">{t.name}</span>
<span className="text-xs opacity-60">{t.task_count} Aufgaben</span>
</div>
<span className={`text-[10px] font-mono py-1 px-2 rounded shrink-0 ml-3 ${
t.rank === 1 || t === topTemplates[0]
? 'bg-primary/10 text-primary border border-primary/20'
: 'bg-base-300 text-base-content/60'
}`}>
TOP {topTemplates.indexOf(t) + 1}
</span>
</div>
))}
</div>
</div>
</section>
)}
{/* Rechte Spalte: Vorlagen mit Suchleiste + Grid */}
<section className={isAdmin && topTemplates.length > 0 ? "lg:col-span-8" : "lg:col-span-12"}>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4 h-[38px]">
<h2 className="text-xl font-medium tracking-tight">{isAdmin ? 'Vorlagen' : 'Verfügbare Vorlagen'}</h2> <h2 className="text-xl font-medium tracking-tight">{isAdmin ? 'Vorlagen' : 'Verfügbare Vorlagen'}</h2>
<div className="form-control w-full sm:w-72"> <div className="form-control w-full sm:w-72">
<input <input
@@ -217,34 +196,96 @@ export default function Dashboard({ onNavigate }) {
<p className="text-sm opacity-60">Keine Vorlagen für „{search}" gefunden.</p> <p className="text-sm opacity-60">Keine Vorlagen für „{search}" gefunden.</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3"> <>
{filtered.map((tpl) => { {/* Scrollbare Liste — flex-1 + min-h-0 (Pflicht für overflow in Flexbox),
const pageCount = [...new Set(tpl.steps?.map((s) => s.page_num) || [])].length; damit die Liste innerhalb der Card-Höhe scrollt statt sie aufzuspannen */}
return ( <div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden flex-1 flex flex-col min-h-[600px] max-h-[600px]">
<article key={tpl.id} className="card bg-base-200 shadow-sm hover:shadow-md transition-all rounded-lg p-4 flex flex-col justify-between min-h-32"> <div className="divide-y divide-base-300 flex-1 min-h-0 overflow-y-auto">
<div> {pagedTemplates.map((tpl) => (
{pageCount > 1 && ( <TemplateCard
<div className="flex justify-end mb-2"> key={tpl.id}
<span className="text-[10px] opacity-50 font-mono">{pageCount} Seiten</span> template={tpl}
</div> action={
)} <button
<h3 className="text-base font-medium line-clamp-2 leading-tight">{tpl.name}</h3> className="btn btn-primary btn-sm"
</div> onClick={() => setFillTemplate(tpl)}
<div className="flex items-center justify-end mt-3"> >
<button <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
className="btn btn-primary btn-sm" Starten
onClick={() => setFillTemplate(tpl)} </button>
> }
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> />
Starten ))}
</button> </div>
</div> </div>
</article>
); {/* Pagination: 10 Vorlagen pro Seite */}
})} {totalPages > 1 && (
</div> <div className="flex justify-center items-center gap-2 mt-4">
<button
className="btn btn-sm btn-outline"
disabled={currentPage <= 1}
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
>
« Zurück
</button>
<span className="text-sm opacity-70">
Seite {currentPage} von {totalPages}
</span>
<button
className="btn btn-sm btn-outline"
disabled={currentPage >= totalPages}
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
>
Weiter »
</button>
</div>
)}
</>
)} )}
</section> </section>
{/* Rechte Spalte: Meistgenutzte Vorlagen als Column-Chart (Anteil in %) */}
{isAdmin && topTemplates.length > 0 && (
<section aria-label="Meistgenutzte Vorlagen" className="flex flex-col">
{/* Header mit gleicher Mindesthöhe wie der Listen-Header (Suchbox = 48px),
damit beide Cards auf derselben Höhe starten */}
<div className="flex items-center justify-between mb-4 sm:min-h-[48px]">
<h2 className="text-xl font-medium tracking-tight">Meistgenutzt</h2>
</div>
<div className="card bg-base-200 shadow-sm rounded-xl flex-1 flex flex-col">
<div className="card-body p-5 flex-1 flex flex-col justify-center">
{(() => {
// Prozentanteil jeder Vorlage an allen Task-Zuordnungen der Top-Liste
const totalCount = topTemplates.reduce((sum, t) => sum + (t.task_count || 0), 0);
if (totalCount === 0) return null;
return (
<div className="flex items-end justify-around gap-2 sm:gap-4 h-64 pt-2">
{topTemplates.map((t, idx) => {
const pct = Math.round(((t.task_count || 0) / totalCount) * 100);
return (
<div key={t.id} className="flex flex-col items-center justify-end h-full flex-1 min-w-0">
{/* Prozentwert über der Säule */}
<span className="text-xs font-semibold tabular-nums mb-1">{pct}%</span>
{/* Säule: Höhe proportional zum Prozentanteil, animiert per CSS-Transition */}
<div
className={`w-full max-w-16 rounded-t-md transition-all duration-700 ${idx === 0 ? 'bg-primary' : 'bg-primary/50'}`}
style={{ height: `${Math.max(pct, 2)}%` }}
/>
{/* Beschriftung unter der Säule */}
<span className="text-[11px] opacity-70 truncate w-full text-center mt-2" title={t.name}>
{t.name}
</span>
</div>
);
})}
</div>
);
})()}
</div>
</div>
</section>
)}
</div> </div>
{fillTemplate && ( {fillTemplate && (

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useToast, ConfirmModal } from '../components/Toast'; import { useToast, ConfirmModal } from '../components/Toast';
import TaskModal from '../components/TaskModal'; import TaskModal from '../components/TaskModal';
import { apiFetch, FILE_BASE } from '../utils/api'; import { apiFetch, safeFileUrl } from '../utils/api';
export default function TasksPage() { export default function TasksPage() {
const { user } = useAuth(); const { user } = useAuth();
@@ -107,7 +107,7 @@ export default function TasksPage() {
} }
return ( return (
<div className="p-4 md:p-6"> <div className="p-4 md:p-6 h-full flex flex-col min-h-0">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6"> <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<h2 className="text-2xl font-bold">Aufgaben</h2> <h2 className="text-2xl font-bold">Aufgaben</h2>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -146,9 +146,11 @@ export default function TasksPage() {
</div> </div>
</div> </div>
) : ( ) : (
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden"> <>
<div className="divide-y divide-base-300"> {/* Scrollbare Table-Liste — füllt die restliche Seitenhöhe */}
{filtered.map((task) => ( <div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden flex-1 min-h-0 flex flex-col">
<div className="divide-y divide-base-300 flex-1 min-h-0 overflow-y-auto">
{filtered.map((task) => (
<div <div
key={task.id} key={task.id}
className="p-4 sm:p-5 hover:bg-base-300/40 transition-colors cursor-pointer" className="p-4 sm:p-5 hover:bg-base-300/40 transition-colors cursor-pointer"
@@ -168,11 +170,11 @@ export default function TasksPage() {
<span className="text-sm opacity-70">{task.template_name}</span> <span className="text-sm opacity-70">{task.template_name}</span>
)} )}
</div> </div>
{/* File attachments in row */} {/* File attachments in row — H1: nur validierte Upload-Pfade als Link */}
{task.file_path && ( {safeFileUrl(task.file_path) && (
<div className="mt-2"> <div className="mt-2">
<a <a
href={`${FILE_BASE}${task.file_path}`} href={safeFileUrl(task.file_path)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline" className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
@@ -183,13 +185,13 @@ export default function TasksPage() {
</a> </a>
</div> </div>
)} )}
{/* File attachments from task values */} {/* File attachments from task values — H1: nur validierte Upload-Pfade als Link */}
{task.values?.filter(v => v.step_type === 'file_upload' && v.value).length > 0 && ( {task.values?.filter(v => v.step_type === 'file_upload' && safeFileUrl(v.value)).length > 0 && (
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{task.values.filter(v => v.step_type === 'file_upload' && v.value).map(v => ( {task.values.filter(v => v.step_type === 'file_upload' && safeFileUrl(v.value)).map(v => (
<a <a
key={v.id} key={v.id}
href={`${FILE_BASE}${v.value}`} href={safeFileUrl(v.value)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline" className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
@@ -223,8 +225,9 @@ export default function TasksPage() {
</div> </div>
</div> </div>
))} ))}
</div>
</div> </div>
</div> </>
)} )}
{selectedTask && ( {selectedTask && (

View File

@@ -124,7 +124,7 @@ export default function TemplatesPage() {
} }
return ( return (
<div className="p-6"> <div className="p-6 h-full flex flex-col min-h-0">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">Vorlageneditor</h2> <h2 className="text-2xl font-bold">Vorlageneditor</h2>
{isAdmin && ( {isAdmin && (
@@ -135,8 +135,9 @@ export default function TemplatesPage() {
)} )}
</div> </div>
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden"> {/* Scrollbare Table-Liste — füllt die restliche Seitenhöhe */}
<div className="divide-y divide-base-300"> <div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden flex-1 min-h-0 flex flex-col">
<div className="divide-y divide-base-300 flex-1 min-h-0 overflow-y-auto">
{templates.map((tpl) => ( {templates.map((tpl) => (
<TemplateCard <TemplateCard
key={tpl.id} key={tpl.id}

View File

@@ -5,23 +5,43 @@ export const FILE_BASE = import.meta.env.VITE_API_BASE
? import.meta.env.VITE_API_BASE.replace(/\/api$/, '') ? import.meta.env.VITE_API_BASE.replace(/\/api$/, '')
: ''; : '';
// P5: CSRF token kept in memory only (not localStorage - not sensitive, but avoids stale tokens) // H1: Sichere Download-URLs — nur Pfade, die exakt vom Upload-Endpoint stammen,
let csrfToken = null; // werden zu einer URL aufgelöst. Alles andere (javascript:, data:, absolute
// URLs, Pfad-Traversal, ausführbare Endungen) gibt null zurück und wird im UI
// nicht als Link gerendert. Spiegelt die serverseitige Whitelist in
// backend/middleware/validation.js (inkl. Endungs-Beschränkung).
const UPLOAD_PATH_PATTERN = /^\/api\/upload\/uploads\/[0-9]+-[0-9]+-[a-zA-Z0-9._-]*\.(pdf|png|jpg|jpeg|gif|txt|doc|docx|bin)$/;
export function safeFileUrl(path) {
if (typeof path !== 'string' || !UPLOAD_PATH_PATTERN.test(path)) return null;
return `${FILE_BASE}${path}`;
}
// P5: CSRF token — the server derives it deterministically from the session and
// self-heals the cookie on every GET. The frontend simply reads the cookie fresh
// on every request, so memory/cookie can never drift apart (no more 403s).
function getCSRFTokenFromCookie() {
const match = document.cookie.match(/workflow_csrf=([^;]+)/);
return match ? match[1] : null;
}
export function setCSRFToken(token) { export function setCSRFToken(token) {
csrfToken = token; // Kept for API compatibility; the cookie is the single source of truth now.
// No-op: token is always read fresh from the cookie.
} }
export function getCSRFToken() { export function getCSRFToken() {
return csrfToken; // Always read fresh from the cookie (server keeps it in sync on every GET)
return getCSRFTokenFromCookie();
} }
// P5: Auth relies on HttpOnly cookie only - no token in localStorage // P5: Auth relies on HttpOnly cookie only - no token in localStorage
export function getAuthHeaders(includeContentType = true) { export function getAuthHeaders(includeContentType = true) {
const headers = {}; const headers = {};
if (includeContentType) headers['Content-Type'] = 'application/json'; if (includeContentType) headers['Content-Type'] = 'application/json';
// P4: Attach CSRF token for state-changing requests // P4: Attach CSRF token for state-changing requests (read from cookie if memory is empty)
if (csrfToken) headers['x-csrf-token'] = csrfToken; const token = getCSRFToken();
if (token) headers['x-csrf-token'] = token;
return headers; return headers;
} }

View File

@@ -0,0 +1,53 @@
/**
* Umlaut-Transliteration für AD-kompatible Namen (E-Mails, sAMAccountName, UPN).
* Frontend-Variante (ES-Module) von backend/umlauts.js — Logik identisch halten!
*
* Wandelt deutsche Sonderzeichen in ASCII-Äquivalente um:
* ä→ae, ö→oe, ü→ue, ß→ss (und Großvarianten)
*/
/**
* Umlaute → ASCII-Äquivalente. Großbuchstaben-Varianten werden erhalten
* ("Schröder" → "Schroeder", "Straße" → "Strasse").
*/
export function transliterateUmlauts(str) {
if (typeof str !== 'string') return '';
return str
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss');
}
/**
* Normalisiert einen Namen für den lokalen Teil einer E-Mail-Adresse:
* Umlaute → ASCII, Kleinbuchstaben, Leerzeichen → Punkte, nur a-z0-9. erlaubt.
* Beispiel: "Jürgen Schröder" → "juergen.schroeder"
* RFC 5321: kein führender/abschließender Punkt, keine Doppelpunkte.
*/
export function toEmailLocalPart(str) {
if (typeof str !== 'string') return '';
return transliterateUmlauts(str)
.toLowerCase()
.replace(/\s+/g, '.')
.replace(/[^a-z0-9.]/g, '')
.replace(/\.{2,}/g, '.') // ".." → "." (durch aufeinanderfolgende Trenner)
.replace(/\.+$/g, '') // abschließende Punkte entfernen
.replace(/^\.+/, ''); // führende Punkte entfernen
}
/**
* Normalisiert einen Namen für sAMAccountName/UPN (Punkt-Format).
*/
export function toAdAccountName(str) {
return toEmailLocalPart(str);
}
/**
* Normalisiert einen Namen für das Pre-Windows-2000-Format (Nachname+Initial):
* Umlaute → ASCII, nur a-zA-Z0-9, Groß-/Kleinschreibung bleibt erhalten.
* Beispiel: "Schröder" + "J" → "SchroederJ"
*/
export function toSamAccountName(str) {
if (typeof str !== 'string') return '';
return transliterateUmlauts(str).replace(/[^a-zA-Z0-9]/g, '');
}