Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d5ab818e9 | ||
|
|
1c28917bb5 | ||
|
|
b369ac955b | ||
|
|
ac7892dd32 | ||
|
|
1ee37c402a | ||
|
|
1d03ab498f | ||
|
|
6ee8582ce5 | ||
|
|
d43d1a3f34 | ||
|
|
ad6983a49d | ||
|
|
4cccf3b1e8 | ||
|
|
1aec65dc95 | ||
|
|
ec2ed91621 | ||
|
|
a05133f39d | ||
|
|
03d20b0e09 | ||
|
|
a35721abec | ||
|
|
abb0617356 | ||
|
|
dd095d9696 | ||
|
|
b0fcabe4a5 | ||
|
|
3e1ac99f38 | ||
|
|
dd63620fee |
49
.env.example
49
.env.example
@@ -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
3
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -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
235
backend/oidc.js
Normal 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,
|
||||||
|
};
|
||||||
93
backend/package-lock.json
generated
93
backend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
54
backend/umlauts.js
Normal 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 };
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
# Frontend Environment Variables
|
|
||||||
# API base URL for backend (default: http://localhost:5000/api)
|
|
||||||
VITE_API_BASE=http://localhost:5000/api
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 '';
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
53
frontend/src/context/ThemeContext.jsx
Normal file
53
frontend/src/context/ThemeContext.jsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
53
frontend/src/utils/umlauts.js
Normal file
53
frontend/src/utils/umlauts.js
Normal 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, '');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user