mirror of
https://github.com/guyg1474/hcrb.git
synced 2026-10-15 23:13:28 +02:00
- Gemeinsame Zugänge mit Rollen: Admin darf alles, Mitglied nutzt Kalender, Fahrtplaner, Treffpunkte und bearbeitet Mitglieder (kein Hinzufügen, Löschen, Import, keine Patches, keine Statistik-Korrektur) - Server prüft die Rechte (403), Konten ohne Rolle gelten als Admin - npm run user: add <name> [admin|mitglied], role <name> <rolle> - Neue öffentliche Seite Kooperationen mit Titelbild, Anführer, Social-Media-Links und Beschreibung, Pflege durch Admin - Kopfzeile bleibt bei mittlerer Breite einzeilig - Texte auf Wunsch gekürzt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
202 lines
7.4 KiB
JavaScript
202 lines
7.4 KiB
JavaScript
// 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.' });
|
||
}
|