Files
workflow-app/backend/routes/auth.js

189 lines
9.0 KiB
JavaScript

/**
* 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;