mirror of
https://github.com/guyg1474/hcrb.git
synced 2026-10-16 07:13:28 +02:00
185 lines
6.5 KiB
JavaScript
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();
|
|
}
|