211 lines
13 KiB
JavaScript
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 };
|