150 lines
7.8 KiB
JavaScript
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 };
|