Files
hcrb/auth.js
T
2026-10-10 13:54:33 +02:00

185 lines
6.5 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.
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;
// ---------- 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 Benutzernamen 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 user.name;
}
// ---------- 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 });
} 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) => {
res.json({ user: sessionUser(req) });
});
}
// Alles, was nach dieser Middleware kommt, ist nur angemeldet erreichbar
export function requireAuth(req, res, next) {
req.user = sessionUser(req);
if (req.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();
}