Files
guyg1474andClaude Opus 5.5 819219fb2d Rollen (Admin/Mitglied), Seite Kooperationen, Textanpassungen
- 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>
2026-10-12 19:47:03 +02:00

202 lines
7.4 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.
// 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.' });
}