OIDC-Integration: Keycloak-Login (Authorization Code Flow + PKCE), app-seitige Rollen bleiben unangetastet
This commit is contained in:
@@ -218,6 +218,17 @@ async function initDatabase() {
|
|||||||
: `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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -150,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;
|
||||||
@@ -113,8 +113,9 @@ 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);
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ 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)
|
# M3: Session-Secret für CSRF-Token-Ableitung (Pflicht in Produktion)
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ 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)
|
# M3: Session-Secret für CSRF-Token-Ableitung (Pflicht in Produktion)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user