/** * POST /api/auth/register * * Registrierung (plan.md Abschnitt 11). Zwei Wege: * 1. Mit Invite-Token -> User wird dem Kalender des Invites zugeordnet. * 2. Ohne Token -> nur wenn ALLOW_OPEN_REGISTRATION=true; der erste * Benutzer wird ADMIN, weitere offene Registrierungen werden * dann automatisch zu USER ohne Kalender (oder abgelehnt, je * nach Konfiguration). * * Sicherheit: * - Rate-Limit pro IP (Brute-Force-/Enumeration-Schutz). * - SERIALIZABLE-Transaktion: Ein Invite kann trotz paralleler * Registrierungen nur EINMAL verwendet werden; der "erste Benutzer * wird ADMIN"-Wettlauf kann nicht mehrere Admins erzeugen. * - Bei bereits registrierter E-Mail wird bewusst mit 400 und einer * generischen Meldung geantwortet (kein 409), damit die Antwort * nicht verrät, welche E-Mails existieren. */ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db/client'; import { hashPassword } from '@/lib/auth/password'; import { createSessionToken, setSessionCookie } from '@/lib/auth/session'; import { registerSchema } from '@/lib/validation'; import { hashInviteToken, isInviteUsable } from '@/lib/invites/tokens'; import { getConfig } from '@/lib/config'; import { errorResponse } from '@/lib/api/responses'; import { checkRateLimit, recordRateLimitHit, getClientIp, } from '@/lib/security/rate-limit'; import { withSerializableTx } from '@/lib/db/tx'; /** Maximal 10 Registrierungsversuche pro IP und Zeitfenster. */ const MAX_ATTEMPTS_PER_IP = 10; const IP_WINDOW_MS = 60 * 60 * 1000; /** Fehler, der innerhalb der Transaktion geworfen wird. */ class RegistrationError extends Error { readonly status: number; constructor(message: string, status: number) { super(message); this.name = 'RegistrationError'; this.status = status; } } export async function POST(request: NextRequest): Promise { try { const ip = getClientIp(request); if ( !checkRateLimit(`register:ip:${ip}`, MAX_ATTEMPTS_PER_IP, IP_WINDOW_MS) .allowed ) { return NextResponse.json( { error: 'Zu viele Anmeldeversuche. Bitte spaeter erneut.' }, { status: 429 }, ); } const body = registerSchema.parse(await request.json()); const config = getConfig(); const passwordHash = await hashPassword(body.password); const createdUser = await withSerializableTx(async (tx) => { let calendarId: string | null = null; if (body.inviteToken) { const tokenHash = hashInviteToken(body.inviteToken); const invite = await tx.invite.findUnique({ where: { tokenHash }, select: { id: true, calendarId: true, expiresAt: true, usedAt: true }, }); if (!invite || !isInviteUsable(invite)) { throw new RegistrationError( 'Dieser Einladungslink ist ungueltig oder abgelaufen.', 400, ); } calendarId = invite.calendarId; // Bedingungsloses Update: schlaegt fehl, wenn eine parallele // Transaktion den Invite inzwischen verbraucht hat (SERIALIZABLE // bricht eine der beiden ab; der Retry sieht dann usedAt und // bricht hier sauber ab). const updated = await tx.invite.updateMany({ where: { id: invite.id, usedAt: null }, data: { usedAt: new Date() }, }); if (updated.count === 0) { throw new RegistrationError( 'Dieser Einladungslink ist ungueltig oder abgelaufen.', 400, ); } } else if (!config.allowOpenRegistration) { throw new RegistrationError( 'Registrierung ist nur ueber Einladungslinks moeglich.', 403, ); } const userCount = await tx.user.count(); // Der allererste Benutzer ist Admin (Betriebs-Setup). Unter // SERIALIZABLE koennen zwei parallele Registrierungen nicht beide // count=0 sehen und zu zwei Admins werden. const role = userCount === 0 ? 'ADMIN' : 'USER'; return tx.user.create({ data: { email: body.email, passwordHash, role, ...(body.username ? { username: body.username } : {}), ...(calendarId ? { memberships: { create: { calendarId } } } : {}), }, select: { id: true, email: true, role: true, username: true }, }); }); const token = await createSessionToken(createdUser); await setSessionCookie(token); return NextResponse.json( { user: { id: createdUser.id, email: createdUser.email, role: createdUser.role, username: createdUser.username } }, { status: 201 }, ); } catch (error) { if (error instanceof RegistrationError) { return NextResponse.json( { error: error.message }, { status: error.status }, ); } // Eindeutige E-Mail (P2002): bewusst generisch antworten, um keine // Account-Enumeration zu ermoeglichen (gleiche Schranke wie bei // unbekannten E-Mails im Login). if ( typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === 'P2002' ) { recordRateLimitHit(`register:ip:${getClientIp(request)}`, IP_WINDOW_MS); return NextResponse.json( { error: 'Registrierung nicht moeglich. E-Mail oder Benutzername ist bereits in Verwendung.', }, { status: 400 }, ); } return errorResponse(error); } }