/** * Auth routes module. * * Punkt 5: Register returns correct status ('inaktiv'). * Punkt 4: Session tokens are hashed (SHA-256) before storage. * Punkt 6: Uses better-sqlite3 synchronous API. */ const express = require('express'); const bcrypt = require('bcryptjs'); const db = require('../db'); const { auditLog } = require('../auditLog'); const { authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, isAccountLocked, recordFailedLogin, recordSuccessfulLogin, setAuthCookie, clearAuthCookie, setCSRFCookie, hashToken } = require('../middleware/auth'); const { isLDAPConfigured, authenticateLDAP } = require('../ldapSync'); const { loginLimiter, registerLimiter } = require('../middleware/rateLimit'); const { validate, registerSchema, loginSchema } = require('../middleware/validation'); const router = express.Router(); // Register router.post('/register', registerLimiter, validate(registerSchema), async (req, res) => { const { email, password, name } = req.validatedBody; try { const hash = bcrypt.hashSync(password, 10); // 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 userId = info.lastInsertRowid; auditLog(null, 'register', 'user', userId, `New registration: ${email}`, req); // P4: Set CSRF cookie for the new session const csrfToken = setCSRFCookie(res); // Return correct status 'inaktiv' (Punkt 5 fix) res.status(201).json({ id: userId, email, name, role: 'user', status: 'inaktiv', source: 'local', csrfToken, message: 'Registrierung erfolgreich. Ein Administrator muss dein Konto freischalten.' }); } catch (err) { if (err.message && err.message.includes('UNIQUE constraint')) { return res.status(409).json({ error: 'E-Mail bereits vergeben.' }); } res.status(500).json({ error: 'Interner Serverfehler.' }); } }); // Login router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => { const { email, password } = req.validatedBody; const row = await db.prepare('SELECT id, email, name, role, status, source, username, password FROM users WHERE LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)').get(email, email); // Punkt 12: Account-Lockout check if (row && await isAccountLocked(row.id)) { return res.status(423).json({ error: 'Konto gesperrt wegen zu vieler fehlgeschlagener Anmeldeversuche. Bitte später erneut versuchen.' }); } // If user not found locally, try LDAP auth if (!row) { if (isLDAPConfigured()) { try { 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); // M3: Unified error messages — don't reveal whether the account exists 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); // V6: Pass old token for session rotation (prevents session fixation) const oldToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', ''); const rawToken = await createSession(adRow.id, oldToken); setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie // P4: CSRF cookie derived from the new session token (deterministic, self-healing) 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) res.json({ ...adRow, csrfToken }); } catch (ldapErr) { return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' }); } } else { return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' }); } return; } if (row.status === 'inaktiv') { // M3: Unified error message — don't reveal whether the account exists return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' }); } if (row.source === 'ad') { if (!isLDAPConfigured()) { // M3: Unified error message return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' }); } try { await authenticateLDAP(row.username || row.email.split('@')[0], password); await recordSuccessfulLogin(row.id); // V6: Pass old token for session rotation (prevents session fixation) const oldTokenAD = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', ''); const rawToken = await createSession(row.id, oldTokenAD); setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie // P4: CSRF cookie derived from the new session token (deterministic, self-healing) const csrfToken = setCSRFCookie(res, hashToken(rawToken)); auditLog(row.id, 'login', 'user', row.id, 'AD login', req); const { password: _, ...safeRow } = row; // Bug 6: Don't expose token in response body (cookie-only auth) res.json({ ...safeRow, csrfToken }); } catch (ldapErr) { await recordFailedLogin(row.id); return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' }); } } else { // Local user - check password with bcrypt (auto-upgrade from plaintext) let passwordMatch = false; if (row.password.startsWith('$2a$') || row.password.startsWith('$2b$')) { passwordMatch = bcrypt.compareSync(password, row.password); } else { // Legacy plaintext comparison - auto-upgrade to bcrypt passwordMatch = row.password === password; if (passwordMatch) { // 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', req); 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 await db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hash, row.id); } } if (!passwordMatch) { await recordFailedLogin(row.id); return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' }); } await recordSuccessfulLogin(row.id); // V6: Pass old token for session rotation (prevents session fixation) const oldTokenLocal = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', ''); const rawToken = await createSession(row.id, oldTokenLocal); setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie // P4: CSRF cookie derived from the new session token (deterministic, self-healing) const csrfToken = setCSRFCookie(res, hashToken(rawToken)); auditLog(row.id, 'login', 'user', row.id, 'Local login', req); const { password: _, ...safeRow } = row; // Bug 6: Don't expose token in response body (cookie-only auth) res.json({ ...safeRow, csrfToken }); } }); // Logout router.post('/logout', async (req, res) => { const rawToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', ''); await deleteSession(rawToken, req); clearAuthCookie(res); // Punkt 8: Clear HttpOnly-Cookie res.json({ message: 'Abgemeldet.' }); }); // Check session router.get('/me', authMiddleware, (req, res) => { 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;