Files
kalendartool/app/api/auth/forgot-password/route.ts

88 lines
3.1 KiB
TypeScript

/**
* 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<NextResponse> {
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);
}
}