Dienstplan-Pro/server/index.js

183 lines
9.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const path = require('path');
const express = require('express');
const cookieParser = require('cookie-parser');
const { db, getDoc, putDoc } = require('./db');
const { scheduleBackups } = require('./backup');
const { audit } = require('./audit');
const { hit } = require('./ratelimit');
const { sendMagicLink } = require('./mailer');
const {
normalizeEmail, hashToken, createLoginToken, consumeLoginToken,
createSession, validateSession, deleteSession, SESSION_TTL_DAYS,
seedAdmin, migrateToMultiUser,
} = require('./auth');
const app = express();
app.use(express.json({ limit: '5mb' }));
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
const KEYS = ['employees', 'duties', 'vacation'];
const EMPTY = { employees: [], duties: {}, vacation: {} };
const RATE_LIMIT_EMAIL = parseInt(process.env.RATE_LIMIT_EMAIL, 10) || 5;
const RATE_LIMIT_IP = parseInt(process.env.RATE_LIMIT_IP, 10) || 50;
const RATE_LIMIT_WINDOW_MIN = parseInt(process.env.RATE_LIMIT_WINDOW_MIN, 10) || 15;
const SESSION_COOKIE = 'session';
// Secure-by-default; nur für lokalen HTTP-Dev per COOKIE_INSECURE=true abschaltbar.
const cookieSecure = process.env.COOKIE_INSECURE !== 'true';
function setSessionCookie(res, raw) {
res.cookie(SESSION_COOKIE, raw, {
httpOnly: true, secure: cookieSecure, sameSite: 'lax',
maxAge: SESSION_TTL_DAYS * 86400 * 1000, path: '/',
});
}
function clearSessionCookie(res) { res.clearCookie(SESSION_COOKIE, { path: '/' }); }
function ipHashOf(req) { return hashToken(req.ip || ''); }
function baseUrl(req) { return process.env.APP_BASE_URL || `${req.protocol}://${req.get('host')}`; }
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function authMiddleware(req, res, next) {
const raw = req.cookies && req.cookies[SESSION_COOKIE];
const u = raw ? validateSession(raw) : null;
if (!u) { clearSessionCookie(res); return res.status(401).json({ error: 'nicht angemeldet' }); }
req.user = { id: u.userId, email: u.email, isAdmin: u.isAdmin };
next();
}
function adminMiddleware(req, res, next) {
if (!req.user || !req.user.isAdmin) return res.status(403).json({ error: 'Admin-Rechte erforderlich' });
next();
}
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
// ── Auth ──────────────────────────────────────────────────────────────
// Login-Link anfordern: neutral (verrät keine Allowlist-Mitgliedschaft), rate-limited.
app.post('/api/auth/request', async (req, res) => {
const email = normalizeEmail((req.body && req.body.email) || '');
const ipH = ipHashOf(req);
const okEmail = hit('email:' + email, RATE_LIMIT_EMAIL, RATE_LIMIT_WINDOW_MIN);
const okIp = hit('ip:' + ipH, RATE_LIMIT_IP, RATE_LIMIT_WINDOW_MIN);
if (!okEmail || !okIp) { audit('auth_fail', null, ipH); return res.status(429).json({ error: 'Zu viele Anfragen.' }); }
const user = email ? db.prepare('SELECT id FROM users WHERE email = ?').get(email) : null;
if (user) {
db.prepare('UPDATE login_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL')
.run(new Date().toISOString(), user.id);
const raw = createLoginToken(user.id);
try { await sendMagicLink(email, `${baseUrl(req)}/auth?token=${raw}`); }
catch (e) { console.error('sendMagicLink fehlgeschlagen:', e.message); } // neutral bleiben, nie hängen
}
res.json({ ok: true }); // immer identisch
});
// Scanner-sichere Einlösung: GET zeigt nur die Bestätigungsseite (verbraucht NICHT).
app.get('/auth', (req, res) => {
const token = String(req.query.token || '');
res.set('Content-Type', 'text/html; charset=utf-8').send(`<!doctype html><html lang="de"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Anmelden Dienstplan-Pro</title><style>body{font-family:system-ui,sans-serif;background:linear-gradient(135deg,#667eea,#764ba2);min-height:100vh;display:flex;align-items:center;justify-content:center;margin:0}.card{background:#fff;padding:2rem;border-radius:12px;box-shadow:0 10px 40px rgba(0,0,0,.2);max-width:360px;text-align:center}h1{font-size:1.2rem;margin:0 0 .5rem}p{color:#555;font-size:.9rem}button{margin-top:1rem;background:#667eea;color:#fff;border:0;padding:.75rem 1.5rem;border-radius:8px;font-size:1rem;cursor:pointer}</style></head>
<body><div class="card"><h1>Bei Dienstplan-Pro anmelden</h1><p>Klicke auf „Jetzt anmelden", um deine Sitzung zu starten.</p>
<form method="POST" action="/auth/confirm"><input type="hidden" name="token" value="${escapeHtml(token)}"><button type="submit">Jetzt anmelden</button></form></div></body></html>`);
});
// Bestätigung (menschlicher POST) verbraucht den Token und erzeugt die Sitzung.
app.post('/auth/confirm', (req, res) => {
const token = String((req.body && req.body.token) || '');
const user = consumeLoginToken(token);
if (!user) {
return res.status(400).set('Content-Type', 'text/html; charset=utf-8').send(`<!doctype html><html lang="de"><head><meta charset="utf-8"><title>Link ungültig</title><style>body{font-family:system-ui,sans-serif;background:linear-gradient(135deg,#667eea,#764ba2);min-height:100vh;display:flex;align-items:center;justify-content:center;margin:0}.card{background:#fff;padding:2rem;border-radius:12px;max-width:360px;text-align:center}a{color:#667eea}</style></head><body><div class="card"><h1>Link ungültig oder abgelaufen</h1><p>Bitte fordere einen neuen Login-Link an.</p><a href="/">Zur Startseite</a></div></body></html>`);
}
const raw = createSession(user.userId); // neue Sitzung erst bei erfolgreichem Consume (keine Fixation)
setSessionCookie(res, raw);
audit('login_ok', user.userId, ipHashOf(req));
res.redirect(302, '/');
});
app.post('/api/auth/logout', (req, res) => {
const raw = req.cookies && req.cookies[SESSION_COOKIE];
if (raw) { const u = validateSession(raw); deleteSession(raw); if (u) audit('logout', u.userId); }
clearSessionCookie(res);
res.json({ ok: true });
});
app.get('/api/auth/me', (req, res) => {
const raw = req.cookies && req.cookies[SESSION_COOKIE];
const u = raw ? validateSession(raw) : null;
if (!u) return res.status(401).json({ error: 'nicht angemeldet' });
res.json({ email: u.email, isAdmin: u.isAdmin });
});
// Notzugang: Admin erzeugt einen Login-Link out-of-band (audit-geloggt).
app.post('/api/admin/login-link', authMiddleware, adminMiddleware, (req, res) => {
const email = normalizeEmail((req.body && req.body.email) || '');
const user = email ? db.prepare('SELECT id FROM users WHERE email = ?').get(email) : null;
if (!user) return res.status(404).json({ error: 'E-Mail nicht freigeschaltet' });
const raw = createLoginToken(user.id);
audit('emergency_link', user.id, ipHashOf(req));
res.json({ url: `${baseUrl(req)}/auth?token=${raw}` });
});
// ── Admin: Nutzerverwaltung (Allowlist) ──────────────────────────────
app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => {
const rows = db.prepare('SELECT id, email, is_admin FROM users ORDER BY email').all();
res.json({ users: rows.map(u => ({ id: u.id, email: u.email, isAdmin: !!u.is_admin })) });
});
app.post('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => {
const email = normalizeEmail((req.body && req.body.email) || '');
if (!email || !email.includes('@')) return res.status(400).json({ error: 'Ungültige E-Mail' });
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (!existing) {
db.prepare('INSERT INTO users (email, is_admin, created_at) VALUES (?, 0, ?)').run(email, new Date().toISOString());
audit('admin_add', req.user.id, ipHashOf(req));
}
res.json({ ok: true });
});
app.delete('/api/admin/users/:id', authMiddleware, adminMiddleware, (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Ungültige ID' });
const target = db.prepare('SELECT id, is_admin FROM users WHERE id = ?').get(id);
if (!target) return res.status(404).json({ error: 'Nutzer nicht gefunden' });
if (target.id === req.user.id) return res.status(400).json({ error: 'Du kannst dich nicht selbst entfernen.' });
if (target.is_admin && db.prepare('SELECT COUNT(*) c FROM users WHERE is_admin = 1').get().c <= 1) {
return res.status(400).json({ error: 'Der letzte Admin kann nicht entfernt werden.' });
}
db.prepare('DELETE FROM users WHERE id = ?').run(id); // CASCADE räumt Sessions/Tokens
audit('admin_remove', req.user.id, ipHashOf(req));
res.json({ ok: true });
});
// ── Daten pro Nutzer (hinter Auth; user_id NUR aus der Session, nie aus dem Client) ──
app.get('/api/state', authMiddleware, (req, res) => {
const state = { ...EMPTY, updatedAt: null };
for (const key of KEYS) {
const doc = getDoc(req.user.id, key);
if (doc) { state[key] = doc.value; if (!state.updatedAt || doc.updatedAt > state.updatedAt) state.updatedAt = doc.updatedAt; }
}
res.json(state);
});
app.put('/api/state', authMiddleware, (req, res) => {
const body = req.body || {};
const now = new Date().toISOString();
for (const key of KEYS) { if (body[key] !== undefined) putDoc(req.user.id, key, body[key], now); }
res.json({ status: 'ok', updatedAt: now });
});
app.use(express.static(path.join(__dirname, '..')));
if (require.main === module) {
// Startup-Reihenfolge: Fail-Fast auf ADMIN_EMAIL → Seed → Migration → listen.
const adminId = seedAdmin(process.env.ADMIN_EMAIL);
migrateToMultiUser(adminId);
const PORT = process.env.PORT || 3000;
app.listen(PORT, '0.0.0.0', () => { console.log(`Dienstplan-Pro auf :${PORT}`); scheduleBackups(); });
}
module.exports = app;
module.exports.authMiddleware = authMiddleware;
module.exports.adminMiddleware = adminMiddleware;