// Login: Benutzer, Passwort-Hashes und Sitzungen // // Passwörter werden nie im Klartext gespeichert, sondern mit scrypt (eigenes Salt pro Passwort) gehasht. // Sitzungs-Tokens liegen nur im Cookie des Browsers; auf dem Server wird lediglich ihr SHA-256-Hash gespeichert. // // Rollen: Die Konten sind gemeinsame Zugänge, keine persönlichen. // admin – alles, inkl. Mitglieder hinzufügen/löschen, Gruppe importieren, Patches, Statistik-Korrektur // mitglied – Kalender, Fahrtplaner, Treffpunkte, Mitglieder bearbeiten (nicht hinzufügen/löschen) import { scrypt, randomBytes, timingSafeEqual, createHash } from 'node:crypto'; import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, statSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const DATA_DIR = join(dirname(fileURLToPath(import.meta.url)), 'data'); export const USERS_FILE = join(DATA_DIR, 'users.json'); const SESSIONS_FILE = join(DATA_DIR, 'sessions.json'); const COOKIE = 'hcrb_session'; const SESSION_DAYS = 30; const MAX_FAILS = 10; // Fehlversuche pro IP … const FAIL_WINDOW = 15 * 60 * 1000; // … innerhalb von 15 Minuten export const MIN_PASSWORD = 8; export const ROLES = ['admin', 'mitglied']; // Konten aus der Zeit vor den Rollen wurden vom Betreiber angelegt → Admin, damit sich niemand aussperrt export const roleOf = (user) => (ROLES.includes(user?.role) ? user.role : 'admin'); // ---------- Passwort-Hashing (scrypt) ---------- const SCRYPT = { N: 2 ** 15, r: 8, p: 1, keylen: 64 }; function scryptAsync(password, salt, { N, r, p, keylen }) { return new Promise((resolve, reject) => { scrypt(password, salt, keylen, { N, r, p, maxmem: 128 * N * r * 2 }, (err, key) => err ? reject(err) : resolve(key)); }); } export async function hashPassword(password) { const salt = randomBytes(16); const key = await scryptAsync(password, salt, SCRYPT); const { N, r, p } = SCRYPT; return `scrypt$${N}$${r}$${p}$${salt.toString('base64')}$${key.toString('base64')}`; } async function verifyPassword(password, stored) { const [algo, N, r, p, salt, hash] = String(stored).split('$'); if (algo !== 'scrypt') return false; const expected = Buffer.from(hash, 'base64'); const key = await scryptAsync(password, Buffer.from(salt, 'base64'), { N: +N, r: +r, p: +p, keylen: expected.length }); return timingSafeEqual(key, expected); } // Für unbekannte Benutzernamen wird trotzdem gehasht, damit man sie nicht an der Antwortzeit erkennt let dummyHash; // ---------- Benutzer (data/users.json, wird von scripts/user.js gepflegt) ---------- export function normalizeName(name) { return String(name ?? '').trim().toLowerCase(); } function writeJson(file, data) { mkdirSync(DATA_DIR, { recursive: true }); const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 }); renameSync(tmp, file); } export function readUsers() { if (!existsSync(USERS_FILE)) return []; return JSON.parse(readFileSync(USERS_FILE, 'utf8')).users || []; } export function writeUsers(users) { writeJson(USERS_FILE, { users }); } // Der Server liest die Datei neu ein, sobald das Skript sie geändert hat let usersCache = { mtime: -1, users: [] }; function currentUsers() { const mtime = existsSync(USERS_FILE) ? statSync(USERS_FILE).mtimeMs : 0; if (mtime !== usersCache.mtime) usersCache = { mtime, users: readUsers() }; return usersCache.users; } // ---------- Sitzungen (data/sessions.json) ---------- const sha256 = (s) => createHash('sha256').update(s).digest('hex'); const sessions = new Map( existsSync(SESSIONS_FILE) ? JSON.parse(readFileSync(SESSIONS_FILE, 'utf8')).map((s) => [s.hash, s]) : [], ); function saveSessions() { const now = Date.now(); for (const [hash, s] of sessions) if (s.expires < now) sessions.delete(hash); writeJson(SESSIONS_FILE, [...sessions.values()]); } function readCookie(req, name) { for (const part of (req.headers.cookie || '').split(';')) { const [k, ...v] = part.trim().split('='); if (k === name) return decodeURIComponent(v.join('=')); } return null; } function setCookie(req, res, value, maxAgeSec) { const attrs = [`${COOKIE}=${value}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${maxAgeSec}`]; if (req.secure) attrs.push('Secure'); res.setHeader('Set-Cookie', attrs.join('; ')); } // Liefert den angemeldeten Benutzer ({ name, role }) oder null function sessionUser(req) { const token = readCookie(req, COOKIE); if (!token) return null; const s = sessions.get(sha256(token)); if (!s || s.expires < Date.now()) return null; // Benutzer gelöscht oder Passwort seit der Anmeldung geändert → Sitzung ungültig const user = currentUsers().find((u) => u.name === s.user); if (!user || s.created < (user.changedAt || 0)) return null; return { name: user.name, role: roleOf(user) }; } // ---------- Schutz vor Passwort-Raten ---------- const fails = new Map(); function tooManyFails(ip) { const f = fails.get(ip); if (f && Date.now() - f.first > FAIL_WINDOW) fails.delete(ip); return (fails.get(ip)?.count || 0) >= MAX_FAILS; } function recordFail(ip) { const f = fails.get(ip) || { count: 0, first: Date.now() }; f.count++; fails.set(ip, f); } // ---------- Express ---------- export function authRoutes(app) { app.post('/api/login', async (req, res, next) => { try { if (tooManyFails(req.ip)) { return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen.' }); } const name = normalizeName(req.body?.username); const password = String(req.body?.password ?? ''); const user = currentUsers().find((u) => u.name === name); dummyHash ??= await hashPassword(randomBytes(16).toString('hex')); const ok = await verifyPassword(password, user ? user.hash : dummyHash); if (!user || !ok) { recordFail(req.ip); return res.status(401).json({ error: 'Benutzername oder Passwort falsch.' }); } fails.delete(req.ip); const token = randomBytes(32).toString('base64url'); const now = Date.now(); sessions.set(sha256(token), { hash: sha256(token), user: user.name, created: now, expires: now + SESSION_DAYS * 86400e3, }); saveSessions(); setCookie(req, res, token, SESSION_DAYS * 86400); res.json({ user: user.name, role: roleOf(user) }); } catch (err) { next(err); } }); app.post('/api/logout', (req, res) => { const token = readCookie(req, COOKIE); if (token && sessions.delete(sha256(token))) saveSessions(); setCookie(req, res, '', 0); res.status(204).end(); }); app.get('/api/me', (req, res) => { const user = sessionUser(req); res.json({ user: user?.name ?? null, role: user?.role ?? null }); }); } // Alles, was nach dieser Middleware kommt, ist nur angemeldet erreichbar export function requireAuth(req, res, next) { const user = sessionUser(req); req.user = user?.name ?? null; req.role = user?.role ?? null; if (user) return next(); if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Bitte anmelden.' }); if (req.method === 'GET') return res.redirect(`/login?next=${encodeURIComponent(req.originalUrl)}`); res.status(401).end(); } // Nur für Admins (nach requireAuth verwenden) export function requireAdmin(req, res, next) { if (req.role === 'admin') return next(); res.status(403).json({ error: 'Das darf nur der Admin-Zugang.' }); }