import type { NextRequest } from "next/server"; import { z } from "zod"; import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor"; import { describePasswordRules, hashPassword, MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, needsRehash, verifyPassword, } from "@/lib/auth/password"; import { createSession, destroyAllSessions, destroySession, } from "@/lib/auth/session"; import { clearAttempts, requestOrigin, tooManyAttempts, } from "@/lib/auth/throttle"; import { prisma } from "@/lib/db"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { QrRequestError } from "@/lib/qr/type-module"; export const dynamic = "force-dynamic"; /** Sperrdauer nach zu vielen Fehlversuchen. */ const MAX_ATTEMPTS = 8; const LOCK_MINUTES = 15; /** * Wird gegen einen nicht vorhandenen Benutzer gerechnet, damit die * Antwortzeit nicht verrät, ob es die Adresse gibt. Die Parameter entsprechen * denen echter Hashes — sonst wäre die Rechnung zu schnell. */ const DUMMY_HASH = "scrypt$131072$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAA"; const emailSchema = z .string() .trim() .toLowerCase() .min(1, { error: "Bitte gib deine E-Mail-Adresse ein." }) .max(254, { error: "Die Adresse ist zu lang." }) .refine((value) => /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(value), { error: "Das ist keine gültige E-Mail-Adresse.", }); const loginSchema = z.object({ action: z.literal("login"), email: emailSchema, password: z .string() .min(1, { error: "Bitte gib dein Passwort ein." }) .max(MAX_PASSWORD_LENGTH, { error: "Das Passwort ist zu lang." }), }); const setupSchema = z.object({ action: z.literal("setup"), email: emailSchema, name: z.string().trim().max(120).optional(), password: z .string() .min(MIN_PASSWORD_LENGTH, { error: `Das Passwort ist zu kurz. ${describePasswordRules()}`, }) .max(MAX_PASSWORD_LENGTH, { error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`, }), }); const logoutSchema = z.object({ action: z.literal("logout") }); const changePasswordSchema = z.object({ action: z.literal("change-password"), currentPassword: z .string() .min(1, { error: "Bitte gib dein bisheriges Passwort ein." }) .max(MAX_PASSWORD_LENGTH, { error: "Das Passwort ist zu lang." }), newPassword: z .string() .min(MIN_PASSWORD_LENGTH, { error: `Das neue Passwort ist zu kurz. ${describePasswordRules()}`, }) .max(MAX_PASSWORD_LENGTH, { error: `Das Passwort darf höchstens ${MAX_PASSWORD_LENGTH} Zeichen lang sein.`, }), }); const bodySchema = z.discriminatedUnion("action", [ loginSchema, setupSchema, logoutSchema, changePasswordSchema, ]); /** Wer ist angemeldet? Von der Oberfläche für den Kopfbereich genutzt. */ export async function GET(): Promise { try { const actor = await getActor(); return Response.json({ authEnabled: isAuthEnabled(), setupOpen: await isSetupOpen(), user: actor.kind === "user" ? { id: actor.user.id, email: actor.user.email, name: actor.user.name, isInstanceAdmin: actor.user.isInstanceAdmin, memberships: actor.user.memberships, } : null, }); } catch (error) { return errorResponse(error); } } export async function POST(request: NextRequest): Promise { try { const parsed = bodySchema.safeParse(await request.json()); if (!parsed.success) { return jsonError(firstZodMessage(parsed.error)); } if (parsed.data.action === "logout") { await destroySession(); return Response.json({ ok: true }); } if (!isAuthEnabled()) { throw new QrRequestError( "Die Anmeldung ist ausgeschaltet. Setze AUTH_ENABLED=true in der .env, um sie zu aktivieren.", ); } // Das `await` ist hier nicht optional: ohne es verlässt das Promise den // try-Block, bevor es sich auflöst — eine Ablehnung käme dann nie im // catch an und würde als nackter 500er beim Aufrufer landen. if (parsed.data.action === "change-password") { return await handleChangePassword(parsed.data); } if (parsed.data.action === "setup") { return await handleSetup(parsed.data); } return await handleLogin(parsed.data, requestOrigin(request.headers)); } catch (error) { return errorResponse(error); } } /** * Eigenes Passwort ändern. Das bisherige muss stimmen — sonst könnte jemand * an einem unbeaufsichtigten Rechner das Konto übernehmen. */ async function handleChangePassword( input: z.infer, ): Promise { const actor = await getActor(); if (actor.kind !== "user") { throw new QrRequestError("Bitte melde dich an.", 401); } const user = await prisma.user.findUnique({ where: { id: actor.user.id } }); if (!user) { throw new QrRequestError("Dieses Konto existiert nicht mehr.", 404); } // Konten aus dem externen Verzeichnis haben hier kein Passwort — das // wird beim Anbieter gewechselt, nicht hier. if (!user.passwordHash) { throw new QrRequestError( "Dieses Konto meldet sich über den externen Anbieter an. Das Passwort wird dort geändert.", 400, ); } if (!(await verifyPassword(input.currentPassword, user.passwordHash))) { throw new QrRequestError("Das bisherige Passwort stimmt nicht.", 403); } if (input.currentPassword === input.newPassword) { throw new QrRequestError( "Das neue Passwort ist mit dem bisherigen identisch.", ); } await prisma.user.update({ where: { id: user.id }, data: { passwordHash: await hashPassword(input.newPassword), failedLogins: 0, lockedUntil: null, }, }); // Alle Sitzungen beenden — auch die eigene — und sofort eine neue // aufmachen. So fliegen andere Geräte raus, ohne dass man sich hier neu // anmelden muss. await destroyAllSessions(user.id); await createSession(user.id); return Response.json({ ok: true }); } /** Legt den ersten Benutzer an — nur solange es noch keinen gibt. */ async function handleSetup( input: z.infer, ): Promise { if (!(await isSetupOpen())) { throw new QrRequestError( "Die Ersteinrichtung ist bereits abgeschlossen. Bitte melde dich an.", 403, ); } // Das Hashen dauert; erst danach in die Transaktion, damit sie kurz bleibt. const passwordHash = await hashPassword(input.password); // Serialisierbar, weil zwischen der Prüfung „gibt es schon jemanden?" und // dem Anlegen sonst ein zweiter Aufruf dazwischenkommen könnte — und dann // gäbe es zwei Administratoren, von denen einer ungebeten ist. const user = await prisma.$transaction( async (tx) => { if ((await tx.user.count()) > 0) { throw new QrRequestError( "Die Ersteinrichtung ist bereits abgeschlossen. Bitte melde dich an.", 403, ); } return tx.user.create({ data: { email: input.email, name: input.name && input.name.length > 0 ? input.name : null, passwordHash, isInstanceAdmin: true, }, }); }, { isolationLevel: "Serializable" }, ); await createSession(user.id); return Response.json({ ok: true, email: user.email }, { status: 201 }); } async function handleLogin( input: z.infer, absender: string, ): Promise { // Vor allem anderen: Wer viele Konten durchprobiert, löst nie eine // Kontosperre aus — dagegen hilft nur eine Bremse je Absender. const wartezeit = tooManyAttempts(`login:${absender}`); if (wartezeit !== null) { throw new QrRequestError( `Zu viele Anmeldeversuche. Bitte warte ${Math.ceil(wartezeit / 60)} Minute${wartezeit > 60 ? "n" : ""}.`, 429, ); } const user = await prisma.user.findUnique({ where: { email: input.email } }); // Immer dieselbe Meldung, egal ob die Adresse existiert — sonst ließe sich // damit herausfinden, welche Konten es gibt. const invalid = new QrRequestError( "E-Mail-Adresse oder Passwort stimmt nicht.", 401, ); if (!user) { // Trotzdem einmal rechnen, damit die Antwortzeit nichts verrät. await verifyPassword(input.password, DUMMY_HASH); throw invalid; } if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) { const minutes = Math.ceil( (user.lockedUntil.getTime() - Date.now()) / 60000, ); throw new QrRequestError( `Zu viele Fehlversuche. Bitte versuche es in ${minutes} Minute${minutes === 1 ? "" : "n"} erneut.`, 429, ); } // Konten aus dem Verzeichnis haben hier kein Passwort. Bewusst dieselbe // Meldung wie bei falschen Zugangsdaten: Ein eigener Text würde verraten, // dass es diese Adresse gibt. Der Knopf für den Anbieter steht daneben. if (!user.passwordHash) { await verifyPassword(input.password, DUMMY_HASH); throw invalid; } if (!(await verifyPassword(input.password, user.passwordHash))) { const failedLogins = user.failedLogins + 1; await prisma.user.update({ where: { id: user.id }, data: { failedLogins, lockedUntil: failedLogins >= MAX_ATTEMPTS ? new Date(Date.now() + LOCK_MINUTES * 60 * 1000) : null, }, }); throw invalid; } // Erst nach geprüftem Passwort: Wer bis hierher kommt, hat das Konto // nachweislich in der Hand — ihm darf man sagen, warum es nicht weitergeht. if (!user.isActive) { throw new QrRequestError( "Dieses Konto ist deaktiviert. Bitte wende dich an einen Administrator.", 403, ); } // Der Hash stammt womöglich aus einer Zeit mit schwächeren Parametern. // Jetzt ist der einzige Moment, in dem das Klartextpasswort vorliegt. const passwordHash = needsRehash(user.passwordHash) ? await hashPassword(input.password) : undefined; await prisma.user.update({ where: { id: user.id }, data: { failedLogins: 0, lockedUntil: null, lastLoginAt: new Date(), ...(passwordHash ? { passwordHash } : {}), }, }); clearAttempts(`login:${absender}`); await createSession(user.id); return Response.json({ ok: true, email: user.email }); }