code-kumpel/brain/auth.js

150 lines
7.8 KiB
JavaScript

/**
* Konten: Registrierung, Login, Logout, /me, Passwort ändern, Konto löschen.
* scrypt + Salt, Session-Tokens zufällig (nur SHA-256-Hash gespeichert), 30 Tage gültig.
*/
const crypto = require('crypto');
const SESSION_TTL = 30 * 24 * 3600 * 1000;
const MIN_PW = 8, MAX_PW = 256;
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 64 };
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,255}\.[^\s@]{2,}$/;
const normEmail = (e) => String(e || '').trim().toLowerCase();
const validEmail = (e) => e.length <= 254 && EMAIL_RE.test(e);
const emailKey = (e) => crypto.createHash('sha256').update('ck-email:' + e).digest('hex');
const tokenHash = (t) => crypto.createHash('sha256').update('ck-session:' + t).digest('hex');
function scrypt(pw, salt) {
return new Promise((res, rej) => crypto.scrypt(pw, salt, SCRYPT.keylen, { N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p, maxmem: 64 * 1024 * 1024 }, (e, k) => (e ? rej(e) : res(k))));
}
async function hashPassword(pw) {
const salt = crypto.randomBytes(16);
const key = await scrypt(pw, salt);
return `scrypt$${SCRYPT.N}$${salt.toString('base64')}$${key.toString('base64')}`;
}
async function verifyPassword(pw, stored) {
const parts = String(stored || '').split('$');
if (parts.length !== 4 || parts[0] !== 'scrypt') return false;
const key = await scrypt(pw, Buffer.from(parts[2], 'base64'));
const want = Buffer.from(parts[3], 'base64');
return want.length === key.length && crypto.timingSafeEqual(want, key);
}
// Dummy-Hash, damit unbekannte E-Mails gleich lange dauern
let DUMMY = null;
const dummy = async () => (DUMMY = DUMMY || (await hashPassword('dummy-password-xyz')));
function pwProblem(pw) {
if (typeof pw !== 'string' || pw.length < MIN_PW) return 'password_too_short';
if (pw.length > MAX_PW) return 'password_too_long';
return null;
}
/** Einfaches Fenster-Rate-Limit pro Schlüssel */
function createLimiter(max, windowMs, now = Date.now) {
const m = new Map();
return {
hit(key) {
const t = now(); const arr = (m.get(key) || []).filter((x) => t - x < windowMs);
if (arr.length >= max) { m.set(key, arr); return false; }
arr.push(t); m.set(key, arr);
if (m.size > 5000) for (const [k, v] of m) if (!v.length || t - v[v.length - 1] > windowMs) m.delete(k);
return true;
}
};
}
function createAuth(store, opts = {}) {
const now = opts.now || Date.now;
const loginLimit = opts.loginLimiter || createLimiter(10, 15 * 60 * 1000, now);
const registerLimit = opts.registerLimiter || createLimiter(5, 60 * 60 * 1000, now);
const publicUser = (u) => ({ id: u.id, email: u.email, createdAt: u.createdAt, emailVerified: u.emailVerified === undefined ? null : u.emailVerified, providers: u.providers || [] });
async function newSession(user) {
const token = crypto.randomBytes(32).toString('base64url');
const h = tokenHash(token);
const exp = now() + SESSION_TTL;
await store.put('sessions', h, { userId: user.id, emailKey: user.emailKey, exp, createdAt: now() });
const t = now();
user.sessions = (user.sessions || []).filter((s) => s.exp > t).concat([{ h, exp }]).slice(-20);
await store.put('users', user.emailKey, user);
return { token, expiresAt: exp };
}
async function register(body, ip) {
if (!registerLimit.hit('r:' + ip)) return { status: 429, body: { error: 'rate_limit' } };
const email = normEmail(body && body.email); const pw = body && body.password;
if (!validEmail(email)) return { status: 400, body: { error: 'invalid_email' } };
const p = pwProblem(pw); if (p) return { status: 400, body: { error: p } };
const ek = emailKey(email);
if (await store.get('users', ek)) return { status: 409, body: { error: 'registration_failed' } };
const user = { id: crypto.randomUUID(), email, emailKey: ek, pw: await hashPassword(pw), createdAt: now(), sessions: [] };
await store.put('users', ek, user);
if (opts.onRegister) { try { await opts.onRegister(user); } catch (e) { console.error('[auth] onRegister', e && e.message); } }
const s = await newSession(user);
return { status: 201, body: { user: publicUser(user), token: s.token, expiresAt: s.expiresAt } };
}
async function login(body, ip) {
const email = normEmail(body && body.email); const pw = String((body && body.password) || '');
if (!loginLimit.hit('ip:' + ip) || !loginLimit.hit('e:' + email)) return { status: 429, body: { error: 'rate_limit' } };
const user = validEmail(email) ? await store.get('users', emailKey(email)) : null;
const ok = user && user.pw ? await verifyPassword(pw, user.pw) : (await verifyPassword(pw, await dummy()), false);
if (!ok) return { status: 401, body: { error: 'invalid_credentials' } };
const s = await newSession(user);
return { status: 200, body: { user: publicUser(user), token: s.token, expiresAt: s.expiresAt } };
}
/** Bearer-Token → {user, h} oder null */
async function authenticate(authHeader) {
const m = /^Bearer\s+([A-Za-z0-9_-]{20,200})$/.exec(String(authHeader || ''));
if (!m) return null;
const h = tokenHash(m[1]);
const sess = await store.get('sessions', h);
if (!sess) return null;
if (sess.exp <= now()) { await store.del('sessions', h); return null; }
const user = await store.get('users', sess.emailKey);
if (!user || user.id !== sess.userId) { await store.del('sessions', h); return null; }
return { user, h };
}
const unauth = { status: 401, body: { error: 'unauthorized' } };
async function logout(authHeader) {
const a = await authenticate(authHeader); if (!a) return unauth;
await store.del('sessions', a.h);
a.user.sessions = (a.user.sessions || []).filter((s) => s.h !== a.h);
await store.put('users', a.user.emailKey, a.user);
return { status: 200, body: { ok: true } };
}
async function me(authHeader) {
const a = await authenticate(authHeader); if (!a) return unauth;
return { status: 200, body: { user: publicUser(a.user) } };
}
async function changePassword(authHeader, body, ip) {
const a = await authenticate(authHeader); if (!a) return unauth;
if (!loginLimit.hit('pw:' + a.user.id) || !loginLimit.hit('ip:' + ip)) return { status: 429, body: { error: 'rate_limit' } };
if (!(await verifyPassword(String((body && body.currentPassword) || ''), a.user.pw))) return { status: 401, body: { error: 'invalid_credentials' } };
const p = pwProblem(body && body.newPassword); if (p) return { status: 400, body: { error: p } };
a.user.pw = await hashPassword(body.newPassword);
// andere Sessions beenden
for (const s of a.user.sessions || []) if (s.h !== a.h) await store.del('sessions', s.h);
a.user.sessions = (a.user.sessions || []).filter((s) => s.h === a.h);
await store.put('users', a.user.emailKey, a.user);
return { status: 200, body: { ok: true } };
}
async function deleteAccount(authHeader, body, ip) {
const a = await authenticate(authHeader); if (!a) return unauth;
if (!loginLimit.hit('del:' + a.user.id) || !loginLimit.hit('ip:' + ip)) return { status: 429, body: { error: 'rate_limit' } };
const okDel = a.user.pw ? await verifyPassword(String((body && body.password) || ''), a.user.pw) : (body && body.confirm) === 'delete';
if (!okDel) return { status: 401, body: { error: 'invalid_credentials' } };
for (const s of a.user.sessions || []) await store.del('sessions', s.h);
await store.del('sessions', a.h);
await store.del('sync', 'user:' + a.user.id);
await store.del('users', a.user.emailKey);
await store.del('backups', a.user.id);
return { status: 200, body: { ok: true, deleted: true } };
}
return { register, login, logout, me, changePassword, deleteAccount, authenticate, newSession, publicUser, limiter: loginLimit };
}
module.exports = { emailKey, pwProblem, createAuth, createLimiter, hashPassword, verifyPassword, normEmail, validEmail, tokenHash, SESSION_TTL, MIN_PW };