87 lines
2.7 KiB
TypeScript
87 lines
2.7 KiB
TypeScript
/**
|
|
* POST /api/auth/login
|
|
*
|
|
* Login mit Rate-Limiting (plan.md Abschnitt 11).
|
|
* Fehlermeldung ist bewusst generisch, um Account-Enumeration
|
|
* zu verhindern.
|
|
*/
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { prisma } from '@/lib/db/client';
|
|
import { verifyPassword } from '@/lib/auth/password';
|
|
import { createSessionToken, setSessionCookie } from '@/lib/auth/session';
|
|
import { loginSchema } from '@/lib/validation';
|
|
import { errorResponse } from '@/lib/api/responses';
|
|
import {
|
|
checkRateLimit,
|
|
recordRateLimitHit,
|
|
resetRateLimit,
|
|
getClientIp,
|
|
} from '@/lib/security/rate-limit';
|
|
|
|
/**
|
|
* Maximal 5 Fehlversuche pro E-Mail und Zeitfenster (In-Memory, MVP).
|
|
* Zusaetzlich ein IP-Limit: verhindert, dass ein Angreifer mit vielen
|
|
* erfundenen E-Mails den Speicher flutet oder verteiltes Brute-Forcing
|
|
* gegen ein einzelnes Konto betreibt.
|
|
*/
|
|
const MAX_ATTEMPTS = 5;
|
|
const ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
|
|
const MAX_IP_ATTEMPTS = 30;
|
|
const IP_WINDOW_MS = 15 * 60 * 1000;
|
|
|
|
function isRateLimited(email: string, ip: string): boolean {
|
|
return (
|
|
!checkRateLimit(`login:email:${email}`, MAX_ATTEMPTS, ATTEMPT_WINDOW_MS)
|
|
.allowed ||
|
|
!checkRateLimit(`login:ip:${ip}`, MAX_IP_ATTEMPTS, IP_WINDOW_MS).allowed
|
|
);
|
|
}
|
|
|
|
function recordFailedAttempt(email: string, ip: string): void {
|
|
recordRateLimitHit(`login:email:${email}`, ATTEMPT_WINDOW_MS);
|
|
recordRateLimitHit(`login:ip:${ip}`, IP_WINDOW_MS);
|
|
}
|
|
|
|
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
try {
|
|
const body = loginSchema.parse(await request.json());
|
|
const ip = getClientIp(request);
|
|
|
|
if (isRateLimited(body.email, ip)) {
|
|
return NextResponse.json(
|
|
{ error: 'Zu viele Anmeldeversuche. Bitte spaeter erneut.' },
|
|
{ status: 429 },
|
|
);
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { email: body.email },
|
|
select: { id: true, email: true, role: true, passwordHash: true },
|
|
});
|
|
|
|
// Auch bei unbekannter E-Mail bcrypt ausfuehren (Timing-Abgleich).
|
|
const passwordHash =
|
|
user?.passwordHash ??
|
|
'$2a$12$C6UzMDM.H6dfI/f/IKcEeO7ZBpQqQqQqQqQqQqQqQqQqQqQqQqQqQ';
|
|
const passwordValid = await verifyPassword(body.password, passwordHash);
|
|
|
|
if (!user || !passwordValid) {
|
|
recordFailedAttempt(body.email, ip);
|
|
return NextResponse.json(
|
|
{ error: 'E-Mail oder Passwort ist falsch.' },
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
|
|
resetRateLimit(`login:email:${body.email}`);
|
|
|
|
const token = await createSessionToken(user);
|
|
await setSessionCookie(token);
|
|
|
|
return NextResponse.json({
|
|
user: { id: user.id, email: user.email, role: user.role },
|
|
});
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
} |