Initial commit: Kalendartool (Next.js, Prisma, Docker)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kühn
2026-10-08 14:38:04 +02:00
commit bc49c3074e
94 changed files with 12812 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
/**
* 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);
}
}