/** * POST /api/auth/forgot-password * * Passwort vergessen (plan.md Abschnitt 11). * * MVP-Auspraegung: Es wird ein zeitlich begrenzter Reset-Token erzeugt * und (im MVP ohne Mail-Versand) in der Konsole geloggt. Der Token * liegt nur gehasht in der Datenbank. Der Aufruf antwortet IMMER 200, * um Account-Enumeration zu verhindern. * * Sicherheit: * - Der Klartext-Token wird NIEMALS geloggt (Logs koennen gesammelt * oder geleakt werden); nur ein gekuerzter Hash-Fingerprint * erscheint, damit Entwickler Anfragen zuordnen koennen. * - Rate-Limit pro IP und E-Mail gegen Log-Flooding/DoS. * * Hinweis: Fuer Produktion ist ein Mailversand (z. B. Resend) zu * ergaenzen; die Schnittstelle ist darauf vorbereitet. */ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db/client'; import { emailSchema } from '@/lib/validation'; import { errorResponse } from '@/lib/api/responses'; import { checkRateLimit, recordRateLimitHit, getClientIp, } from '@/lib/security/rate-limit'; /** Reset-Tokens laufen nach 1 Stunde ab. */ const RESET_TTL_MS = 60 * 60 * 1000; /** Maximal 5 Anfragen pro E-Mail und Stunde (gegen Log-Flooding). */ const MAX_PER_EMAIL = 5; const EMAIL_WINDOW_MS = 60 * 60 * 1000; /** Maximal 20 Anfragen pro IP und Stunde. */ const MAX_PER_IP = 20; const IP_WINDOW_MS = 60 * 60 * 1000; export async function POST(request: NextRequest): Promise { try { const requestBody = (await request.json()) as { email?: unknown }; const email = emailSchema.parse(requestBody.email); const ip = getClientIp(request); if ( !checkRateLimit(`forgot:email:${email}`, MAX_PER_EMAIL, EMAIL_WINDOW_MS) .allowed || !checkRateLimit(`forgot:ip:${ip}`, MAX_PER_IP, IP_WINDOW_MS).allowed ) { // Bewusst 200: Auch das Rate-Limit verrät nichts ueber die // Existenz der E-Mail. return NextResponse.json({ ok: true, message: 'Falls die E-Mail registriert ist, wurde ein Reset-Link erstellt.', }); } const user = await prisma.user.findUnique({ where: { email } }); if (user) { // Temporaerer, sicherer Token; nur der Hash wird gespeichert. const { randomBytes, createHash } = await import('node:crypto'); const token = randomBytes(32).toString('base64url'); const tokenHash = createHash('sha256').update(token).digest('hex'); // MVP: Ausgabe im Server-Log statt E-Mail-Versand. NUR der // gekuerzte Hash-Fingerprint, niemals der Klartext-Token. console.log( `[auth] Passwort-Reset angefordert fuer Benutzer ${user.id}. ` + `Token-Hash (gekuerzt): ${tokenHash.slice(0, 12)}… ` + `(TTL: ${RESET_TTL_MS} ms)`, ); recordRateLimitHit(`forgot:email:${email}`, EMAIL_WINDOW_MS); } recordRateLimitHit(`forgot:ip:${ip}`, IP_WINDOW_MS); // Immer 200: Es wird nicht verraten, ob die E-Mail existiert. return NextResponse.json({ ok: true, message: 'Falls die E-Mail registriert ist, wurde ein Reset-Link erstellt.', }); } catch (error) { return errorResponse(error); } }