code-kumpel/brain/features.js

211 lines
13 KiB
JavaScript

/**
* Runde 3: Passwort vergessen, E-Mail-Bestätigung, OAuth (GitHub/Google), Rangliste, Backups mit Verlauf, Fehler-Endpoint.
* Alle Tokens nur als SHA-256-Hash gespeichert.
*/
const crypto = require('crypto');
const A = require('./auth');
const RESET_TTL = 3600 * 1000; // 1 h
const VERIFY_TTL = 48 * 3600 * 1000; // 48 h
const OAUTH_TTL = 10 * 60 * 1000;
const MAX_BACKUPS = 10;
const SNAP_EVERY = 3600 * 1000;
const th = (kind, t) => crypto.createHash('sha256').update('ck-' + kind + ':' + t).digest('hex');
const rnd = () => crypto.randomBytes(32).toString('base64url');
function oauthProviders(env) {
const p = {};
if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) p.github = {
id: env.GITHUB_CLIENT_ID, secret: env.GITHUB_CLIENT_SECRET,
authUrl: 'https://github.com/login/oauth/authorize', tokenUrl: 'https://github.com/login/oauth/access_token', scope: 'read:user user:email',
async email(tok) {
const r = await fetch('https://api.github.com/user/emails', { headers: { Authorization: 'Bearer ' + tok, 'User-Agent': 'code-kumpel', Accept: 'application/vnd.github+json' } });
const list = await r.json(); const e = Array.isArray(list) && (list.find((x) => x.primary && x.verified) || list.find((x) => x.verified));
return e ? e.email : null;
}
};
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) p.google = {
id: env.GOOGLE_CLIENT_ID, secret: env.GOOGLE_CLIENT_SECRET,
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', scope: 'openid email',
async email(tok) {
const r = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { headers: { Authorization: 'Bearer ' + tok } });
const d = await r.json(); return d && d.email && d.email_verified ? d.email : null;
}
};
return p;
}
function createFeatures(kv, auth, mailer, opts = {}) {
const env = opts.env || process.env;
const now = opts.now || Date.now;
const appUrl = String(env.APP_URL || 'https://code-kumpel.onrender.com').replace(/\/$/, '');
const brainUrl = String(env.BRAIN_PUBLIC_URL || 'https://code-kumpel-brain.onrender.com').replace(/\/$/, '');
const allowedReturn = opts.allowedReturn || ((u) => /^https:\/\/code-kumpel\.(vercel\.app|onrender\.com)\/?/.test(u) || /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?\//.test(u));
const providers = opts.providers || oauthProviders(env);
const limit = opts.limiter || A.createLimiter(5, 15 * 60 * 1000, now);
const errLimit = opts.errLimiter || A.createLimiter(20, 10 * 60 * 1000, now);
const fetchFn = opts.fetch || fetch;
const ok = (body = { ok: true }) => ({ status: 200, body });
const err = (status, error) => ({ status, body: { error } });
const config = () => ok({ mail: mailer.enabled, oauth: Object.keys(providers), leaderboard: true, backups: MAX_BACKUPS, sentryDsn: env.SENTRY_DSN_PUBLIC || null });
// ---------- E-Mail-Bestätigung ----------
async function sendVerify(user) {
if (!mailer.enabled) return false; // ohne Mail-Config übersprungen
const t = rnd();
await kv.put('tokens', th('verify', t), { type: 'verify', emailKey: user.emailKey, userId: user.id, exp: now() + VERIFY_TTL });
user.emailVerified = false; await kv.put('users', user.emailKey, user);
await mailer.send(user.email, 'Code-Kumpel: E-Mail bestätigen', `Hallo!\n\nBitte bestätige deine E-Mail-Adresse für Code-Kumpel:\n${appUrl}/#verify=${t}\n\nDer Link ist 48 Stunden gültig. Wenn du dich nicht registriert hast, ignoriere diese Mail.\n\nByte`);
return true;
}
async function verify(body) {
const t = String((body && body.token) || ''); if (t.length < 20) return err(400, 'invalid_token');
const rec = await kv.get('tokens', th('verify', t));
if (!rec || rec.type !== 'verify' || rec.exp <= now()) return err(400, 'invalid_token');
const u = await kv.get('users', rec.emailKey); await kv.del('tokens', th('verify', t));
if (!u || u.id !== rec.userId) return err(400, 'invalid_token');
u.emailVerified = true; await kv.put('users', u.emailKey, u);
return ok({ ok: true, verified: true });
}
async function resendVerify(h, ip) {
const a = await auth.authenticate(h); if (!a) return err(401, 'unauthorized');
if (!mailer.enabled) return err(503, 'mail_disabled');
if (a.user.emailVerified === true) return ok({ ok: true, verified: true });
if (!limit.hit('rv:' + a.user.id) || !limit.hit('ip:' + ip)) return err(429, 'rate_limit');
await sendVerify(a.user); return ok();
}
// ---------- Passwort vergessen ----------
async function forgot(body, ip) {
if (!mailer.enabled) return err(503, 'mail_disabled');
const email = A.normEmail(body && body.email);
if (!limit.hit('fg:' + ip) || !limit.hit('fe:' + email)) return err(429, 'rate_limit');
const u = A.validEmail(email) ? await kv.get('users', A.emailKey(email)) : null;
if (u) {
const t = rnd();
await kv.put('tokens', th('reset', t), { type: 'reset', emailKey: u.emailKey, userId: u.id, exp: now() + RESET_TTL });
try { await mailer.send(u.email, 'Code-Kumpel: Passwort zurücksetzen', `Hallo!\n\nMit diesem Link setzt du dein Code-Kumpel-Passwort zurück:\n${appUrl}/#reset=${t}\n\nDer Link ist 1 Stunde gültig und funktioniert nur einmal. Wenn du das nicht angefordert hast, ignoriere diese Mail.\n\nByte`); }
catch (e) { console.error('[mail]', e && e.message); }
}
return ok({ ok: true }); // immer gleich, keine Konto-Enumeration
}
async function reset(body, ip) {
if (!limit.hit('rs:' + ip)) return err(429, 'rate_limit');
const t = String((body && body.token) || ''); if (t.length < 20) return err(400, 'invalid_token');
const p = A.pwProblem(body && body.password); if (p) return err(400, p);
const k = th('reset', t); const rec = await kv.get('tokens', k);
if (!rec || rec.type !== 'reset' || rec.exp <= now()) return err(400, 'invalid_token');
await kv.del('tokens', k);
const u = await kv.get('users', rec.emailKey); if (!u || u.id !== rec.userId) return err(400, 'invalid_token');
u.pw = await A.hashPassword(body.password);
for (const s of u.sessions || []) await kv.del('sessions', s.h);
u.sessions = []; if (u.emailVerified === false) u.emailVerified = true; // Mail-Besitz bewiesen
await kv.put('users', u.emailKey, u);
return ok({ ok: true });
}
// ---------- OAuth ----------
async function oauthStart(provider, query) {
const p = providers[provider]; if (!p) return err(404, 'oauth_disabled');
const ret = String(query.get('return') || appUrl + '/');
const state = rnd();
await kv.put('tokens', th('ostate', state), { type: 'ostate', provider, ret: allowedReturn(ret) ? ret : appUrl + '/', exp: now() + OAUTH_TTL });
const q = new URLSearchParams({ client_id: p.id, redirect_uri: `${brainUrl}/auth/oauth/${provider}/callback`, scope: p.scope, state, response_type: 'code' });
return { status: 302, location: p.authUrl + '?' + q };
}
async function oauthCallback(provider, query) {
const p = providers[provider]; if (!p) return err(404, 'oauth_disabled');
const state = String(query.get('state') || ''), code = String(query.get('code') || '');
const sk = th('ostate', state); const st = await kv.get('tokens', sk);
if (!st || st.type !== 'ostate' || st.provider !== provider || st.exp <= now()) return { status: 302, location: appUrl + '/#oauth_error=state' };
await kv.del('tokens', sk);
const back = (frag) => ({ status: 302, location: st.ret.split('#')[0] + '#' + frag });
try {
const r = await fetchFn(p.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: new URLSearchParams({ client_id: p.id, client_secret: p.secret, code, redirect_uri: `${brainUrl}/auth/oauth/${provider}/callback`, grant_type: 'authorization_code' }) });
const d = await r.json(); if (!d.access_token) return back('oauth_error=token');
const email = A.normEmail(await p.email(d.access_token)); if (!A.validEmail(email)) return back('oauth_error=email');
const ek = A.emailKey(email);
let u = await kv.get('users', ek);
if (!u) u = { id: crypto.randomUUID(), email, emailKey: ek, pw: null, createdAt: now(), sessions: [], emailVerified: true, providers: [provider] };
else { u.providers = Array.from(new Set((u.providers || []).concat(provider))); u.emailVerified = true; }
await kv.put('users', ek, u);
const s = await auth.newSession(u);
const once = rnd();
await kv.put('tokens', th('oonce', once), { type: 'oonce', token: s.token, expiresAt: s.expiresAt, email, exp: now() + 120000 });
return back('oauth_code=' + once);
} catch (e) { console.error('[oauth]', e && e.message); return back('oauth_error=server'); }
}
async function oauthExchange(body) {
const c = String((body && body.code) || ''); const k = th('oonce', c); const rec = await kv.get('tokens', k);
if (!rec || rec.type !== 'oonce' || rec.exp <= now()) return err(400, 'invalid_token');
await kv.del('tokens', k);
return ok({ token: rec.token, expiresAt: rec.expiresAt, user: { email: rec.email } });
}
// ---------- Rangliste (opt-in) ----------
const cleanName = (n) => String(n || '').replace(/[^\p{L}\p{N} _.\-]/gu, '').trim().slice(0, 20);
async function lbGet() {
const all = (await kv.get('leaderboard', 'all')) || {};
const rows = Object.values(all).sort((a, b) => b.xp - a.xp || b.streak - a.streak).slice(0, 50).map((r, i) => ({ rank: i + 1, name: r.name, xp: r.xp, level: r.level, streak: r.streak }));
return ok({ rows });
}
async function lbPost(h, body) {
const a = await auth.authenticate(h); if (!a) return err(401, 'unauthorized');
const all = (await kv.get('leaderboard', 'all')) || {};
if (!body || body.optIn !== true) { delete all[a.user.id]; await kv.put('leaderboard', 'all', all); return ok({ ok: true, optIn: false }); }
const name = cleanName(body.name); if (name.length < 2) return err(400, 'invalid_name');
const num = (x, max) => Math.max(0, Math.min(max, Math.floor(Number(x) || 0)));
all[a.user.id] = { name, xp: num(body.xp, 1e9), level: num(body.level, 1e4), streak: num(body.streak, 1e5), updatedAt: now() };
await kv.put('leaderboard', 'all', all);
return ok({ ok: true, optIn: true });
}
async function onDelete(userId) {
const all = (await kv.get('leaderboard', 'all')) || {};
if (all[userId]) { delete all[userId]; await kv.put('leaderboard', 'all', all); }
}
// ---------- Backups mit Verlauf ----------
async function snapshot(userId, rec, force) {
if (!rec || !rec.state) return;
const list = (await kv.get('backups', userId)) || [];
if (!force && list[0] && now() - list[0].savedAt < SNAP_EVERY) return; // höchstens 1 Stand pro Stunde automatisch
list.unshift({ version: rec.version, updatedAt: rec.updatedAt, savedAt: now(), state: rec.state });
await kv.put('backups', userId, list.slice(0, MAX_BACKUPS));
}
async function backupsList(h) {
const a = await auth.authenticate(h); if (!a) return err(401, 'unauthorized');
const list = (await kv.get('backups', a.user.id)) || [];
return ok({ backups: list.map((b, i) => ({ index: i, version: b.version, updatedAt: b.updatedAt, savedAt: b.savedAt, xp: (b.state && b.state.xp) || 0, level: (b.state && b.state.level) || null })) });
}
async function backupsRestore(h, body) {
const a = await auth.authenticate(h); if (!a) return err(401, 'unauthorized');
const list = (await kv.get('backups', a.user.id)) || [];
const b = list[Number(body && body.index)]; if (!b) return err(404, 'not_found');
const id = 'user:' + a.user.id; const cur = await kv.get('sync', id);
if (cur) await snapshot(a.user.id, cur, true);
const rec = { version: ((cur && cur.version) || 0) + 1, updatedAt: now(), state: b.state };
await kv.put('sync', id, rec);
return ok({ ok: true, version: rec.version, updatedAt: rec.updatedAt, state: rec.state });
}
// ---------- Fehler-Monitoring ----------
async function reportError(body, ip) {
if (!errLimit.hit('err:' + ip)) return err(429, 'rate_limit');
const s = (x, n) => String(x == null ? '' : x).replace(/(sk-|gh[pousr]_|Bearer\s+)[A-Za-z0-9_\-.]+/g, '$1***').slice(0, n);
const e = { at: now(), msg: s(body && body.message, 500), stack: s(body && body.stack, 2000), url: s(body && body.url, 300).split('#')[0].split('?')[0], ua: s(body && body.ua, 200), platform: s(body && body.platform, 20), version: s(body && body.version, 20) };
if (!e.msg) return err(400, 'bad_request');
console.error('[client-error]', e.platform, e.version, e.msg);
const list = (await kv.get('errors', 'recent')) || [];
list.unshift(e); await kv.put('errors', 'recent', list.slice(0, 100));
return ok({ ok: true });
}
async function listErrors(h) {
if (!env.ADMIN_TOKEN || h !== 'Bearer ' + env.ADMIN_TOKEN) return err(401, 'unauthorized');
return ok({ errors: (await kv.get('errors', 'recent')) || [] });
}
return { config, sendVerify, verify, resendVerify, forgot, reset, oauthStart, oauthCallback, oauthExchange, lbGet, lbPost, onDelete, snapshot, backupsList, backupsRestore, reportError, listErrors, RESET_TTL, MAX_BACKUPS };
}
module.exports = { createFeatures, oauthProviders };