88 lines
3.1 KiB
TypeScript
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);
|
|
}
|
|
}
|