import type { APIRoute } from 'astro'; import { randomBytes, createHash } from 'node:crypto'; import { one, query } from '../../../lib/db'; import { hashPassword, bumpSessionEpoch } from '../../../lib/auth'; import { sendMail, mailLayout, mailReady, istMail, esc } from '../../../lib/mail'; import { zuOft, echteIp } from '../../../lib/limit'; export const prerender = false; const json = (b: unknown, s = 200) => new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); const GUELTIG_MINUTEN = 60; const hashToken = (t: string) => createHash('sha256').update(t).digest('hex'); const basis = () => (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); /** * Rücksetz-Link anfordern (`{ email }`) oder neues Passwort setzen * (`{ token, password }`). * * Wichtig: Beim Anfordern antwortet die Route **immer gleich**, egal ob es die * Adresse gibt. Sonst ließe sich hier ablesen, wer ein Konto hat. */ export const POST: APIRoute = async ({ request, clientAddress }) => { const ip = echteIp(request, clientAddress); const b = await request.json().catch(() => ({})); // --- Neues Passwort setzen ------------------------------------------------- if (b.token) { const token = String(b.token); const pw = String(b.password || ''); if (pw.length < 10) return json({ error: 'Das Passwort muss mindestens 10 Zeichen haben.' }, 400); if (zuOft('reset-einloesen:' + ip, 20, 15 * 60_000)) return json({ error: 'Zu viele Versuche. Bitte später erneut.' }, 429); const r = await one<{ user_id: string }>( `SELECT user_id FROM password_resets WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()`, [hashToken(token)]); if (!r) return json({ error: 'Dieser Link ist abgelaufen oder wurde schon benutzt.' }, 400); await query('UPDATE users SET password_hash=$2 WHERE id=$1', [r.user_id, await hashPassword(pw)]); // Einmalnutzung, und alle anderen offenen Links desselben Kontos verfallen mit. await query('UPDATE password_resets SET used_at = now() WHERE user_id=$1 AND used_at IS NULL', [r.user_id]); // Entscheidend: **alle bestehenden Sitzungen entwerten**. Wer das Passwort // zurücksetzt, tut das meist, weil jemand anderes hineingekommen ist — ohne // diesen Schritt bliebe dessen Cookie bis zu 30 Tage gültig und der ganze // Vorgang wäre wirkungslos. await bumpSessionEpoch(r.user_id); return json({ ok: true }); } // --- Rücksetz-Link anfordern ---------------------------------------------- const email = String(b.email || '').trim().toLowerCase(); if (!istMail(email)) return json({ error: 'Bitte eine gültige E-Mail-Adresse eingeben.' }, 400); // Zwei Grenzen: je Absender **und** je Zieladresse. Ohne die zweite ließe sich // ein bestimmtes Postfach von wechselnden Adressen aus zumüllen — auf Kosten // des Zustellrufs der eigenen Domain. if (zuOft('reset-ip:' + ip, 5, 15 * 60_000)) return json({ ok: true }); if (zuOft('reset-mail:' + email, 3, 60 * 60_000)) return json({ ok: true }); if (!(await mailReady())) return json({ error: 'Auf diesem Server ist kein E-Mail-Versand eingerichtet.' }, 503); const u = await one<{ id: string; display_name: string | null; username: string }>( 'SELECT id, display_name, username FROM users WHERE lower(email) = $1', [email]); if (u) { const token = randomBytes(32).toString('base64url'); await query( `INSERT INTO password_resets (token_hash, user_id, expires_at) VALUES ($1, $2, now() + ($3 || ' minutes')::interval)`, [hashToken(token), u.id, String(GUELTIG_MINUTEN)]); const url = `${basis()}/passwort?token=${token}`; const name = u.display_name || u.username; // Nicht abwarten: Ein SMTP-Versand dauert Hunderte Millisekunden. Wartet die // Antwort darauf, verrät allein die Dauer, ob es das Konto gibt — und die // gleichlautende Antwort unten wäre wertlos. void sendMail({ to: email, subject: 'Passwort für Klarbild zurücksetzen', text: `Hallo ${name},\n\nüber den folgenden Link kannst du dir ein neues Passwort für Klarbild setzen. ` + `Der Link gilt ${GUELTIG_MINUTEN} Minuten und lässt sich nur einmal verwenden.\n\n${url}\n\n` + `Wenn du das nicht angefordert hast, kannst du diese Nachricht ignorieren — es ändert sich dann nichts.`, html: mailLayout('Neues Passwort setzen', [ `Hallo ${esc(name)},`, `über den Knopf unten setzt du dir ein neues Passwort für Klarbild. Der Link gilt ${GUELTIG_MINUTEN} Minuten und lässt sich nur einmal verwenden.`, `Wenn du das nicht angefordert hast, kannst du diese Nachricht ignorieren — es ändert sich dann nichts.`, ], { text: 'Neues Passwort setzen', url }), }).catch((e) => console.error('[reset] Mail fehlgeschlagen:', e?.message || e)); } // Immer dieselbe Antwort — sonst verrät sie, welche Adressen ein Konto haben. return json({ ok: true }); };