import { randomBytes } from "node:crypto"; import { NextResponse } from "next/server"; import { withFlash } from "@/lib/flash"; import { prisma } from "@/lib/prisma"; import { getBaseUrl } from "@/lib/url"; import { createSession, hashPassword, getEntraOAuthCookie, clearEntraOAuthCookie, setTwoFactorChallengeCookie, } from "@/lib/auth"; import { signTwoFactorChallenge } from "@/lib/session"; import { isEntraConfigured, exchangeEntraAuthorizationCode, verifyEntraIdToken, getVerifiedEmail, EntraAuthError, EntraNotConfiguredError, type EntraIdTokenClaims, } from "@/lib/entra"; import { rateLimit, getClientIp } from "@/lib/rate-limit"; const CALLBACK_ATTEMPTS_PER_WINDOW = 20; const CALLBACK_WINDOW_MS = 15 * 60 * 1000; function firstNameFromClaims(claims: EntraIdTokenClaims): string { const value = claims.given_name?.trim() || claims.name?.trim().split(/\s+/)[0] || "Kundin/Kunde"; return value.slice(0, 100); } function lastNameFromClaims(claims: EntraIdTokenClaims): string { const fromName = claims.name?.trim().split(/\s+/).slice(1).join(" "); const value = claims.family_name?.trim() || (fromName && fromName.length > 0 ? fromName : "—"); return value.slice(0, 100); } /** * Callback fuer "Mit Microsoft anmelden" — tauscht den Autorisierungscode * gegen Tokens, verifiziert das ID-Token serverseitig (Signatur/issuer/ * audience, siehe lib/entra.ts) und meldet die Kundin/den Kunden an. Legt * bei erstmaliger Anmeldung automatisch ein Customer-Konto (Rolle KUNDE) an. * * Sicherheitskritisch: Es wird AUSSCHLIESSLICH die verifizierte E-Mail aus * dem signierten ID-Token verwendet, nie ein Query-Parameter oder anderer * Client-Wert — sonst koennte sich jemand als beliebige E-Mail-Adresse * ausgeben und ein fremdes Konto uebernehmen/anlegen. */ export async function GET(request: Request) { const baseUrl = getBaseUrl(); function redirectWithError(message: string): NextResponse { return withFlash(NextResponse.redirect(new URL("/konto", baseUrl)), "error", message); } if (!isEntraConfigured()) { return redirectWithError("SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an."); } const ip = await getClientIp(); if (!rateLimit(`entra-callback:${ip}`, CALLBACK_ATTEMPTS_PER_WINDOW, CALLBACK_WINDOW_MS)) { return redirectWithError("Zu viele Anmeldeversuche. Bitte versuche es später erneut."); } const requestUrl = new URL(request.url); const providerError = requestUrl.searchParams.get("error"); const code = requestUrl.searchParams.get("code"); const returnedState = requestUrl.searchParams.get("state"); // Das state/code_verifier-Cookie ist Einmalgebrauch — sofort loeschen, // unabhaengig davon, ob der Rest der Pruefung gleich erfolgreich ist. const stored = await getEntraOAuthCookie(); await clearEntraOAuthCookie(); if (providerError) { return redirectWithError("Die Microsoft-Anmeldung wurde abgebrochen."); } if (!code || !returnedState || !stored || stored.state !== returnedState) { return redirectWithError("Die Anmeldung ist abgelaufen oder ungültig. Bitte versuche es erneut."); } let claims: EntraIdTokenClaims; try { const tokens = await exchangeEntraAuthorizationCode({ code, codeVerifier: stored.codeVerifier }); claims = await verifyEntraIdToken(tokens.id_token); } catch (error: unknown) { const message = error instanceof EntraAuthError || error instanceof EntraNotConfiguredError ? error.message : "Microsoft-Anmeldung fehlgeschlagen."; return redirectWithError(message); } const email = getVerifiedEmail(claims); if (!email) { return redirectWithError( "Dein Microsoft-Konto hat keine verifizierte E-Mail-Adresse hinterlegt. Bitte melde dich mit E-Mail und Passwort an oder wende dich an uns." ); } let customer = await prisma.customer.findUnique({ where: { email } }); if (customer?.blockedAt) { return redirectWithError("Dieses Konto wurde gesperrt. Bitte kontaktiere uns."); } if (!customer) { // SSO-Konten haben kein von uns gesetztes Passwort. Statt passwordHash // per Migration nullable zu machen (und damit ueberall im Code, wo // Customer.passwordHash als garantiert vorhanden gilt — Login, // Passwort-Reset, Datenexport, 2FA — Nullable-Handling nachzuruesten), // erhaelt das Konto denselben "unbenutzbaren Zufalls-Hash" wie von // Admins ohne Passwort angelegte Konten (siehe // admin/actions/customers.ts createCustomerAction): ein bcrypt-Hash // ueber 32 zufaellige Bytes, die nirgends gespeichert werden. Ein Login // per Passwort ist damit praktisch unmoeglich; Zugang bleibt // ausschliesslich ueber "Mit Microsoft anmelden" (oder "Passwort // vergessen", falls spaeter ein echtes Passwort gewuenscht ist). const unusablePassword = randomBytes(32).toString("hex"); customer = await prisma.customer.create({ data: { email, firstName: firstNameFromClaims(claims), lastName: lastNameFromClaims(claims), passwordHash: await hashPassword(unusablePassword), // Microsoft hat die Adresse bereits verifiziert (signierter // "email"-Claim) — eine zusaetzliche eigene Bestaetigungsmail waere // redundant. emailVerifiedAt: new Date(), }, }); } else if (!customer.emailVerifiedAt) { // Bestehendes Konto (z.B. aus einer fruehen Registrierung oder // Gastbestellung mit spaeter angelegtem Konto) meldet sich erstmals per // SSO an — Microsoft hat die Adresse soeben verifiziert, das muss nicht // zusaetzlich per E-Mail-Link bestaetigt werden. customer = await prisma.customer.update({ where: { id: customer.id }, data: { emailVerifiedAt: new Date() }, }); } // Fuer KUNDENkonten wird die Anmeldung per SSO bewusst als vollwertig // authentifiziert behandelt und ueberspringt die App-eigene 2FA-Abfrage // (falls fuer das Konto aktiviert): Microsoft ist hier die pruefende // Authentifizierungsstelle, deren eigene Absicherung (bis hin zu Microsofts // eigener MFA) die Rolle unseres TOTP-Schritts uebernimmt. // // Fuer ADMINkonten gilt das NICHT. requireAdmin (admin/actions/_shared.ts) // laesst eine Admin-Aktion nur zu, wenn am Konto twoFactorEnabled steht — // dieses Flag soll zusichern, dass hinter jeder Rueckerstattung und jedem // Blick in die Kundendaten tatsaechlich ein zweiter Faktor stand. Ueber // diesen Weg war das nur noch eine Annahme ueber die MFA-Richtlinie des // Entra-Mandanten, die dieser Code nicht nachpruefen kann: ein Flag, das // "geprueft" behauptet, obwohl hier nichts geprueft wurde. Deshalb laeuft // ein Admin denselben TOTP-Schritt wie beim Passwort-Login. Ausgesperrt // werden kann dabei niemand — twoFactorEnabled heisst, dass der zweite // Faktor bereits eingerichtet ist. if (customer.role === "ADMIN" && customer.twoFactorEnabled) { const challengeToken = await signTwoFactorChallenge(customer.id); await setTwoFactorChallengeCookie(challengeToken); return NextResponse.redirect(new URL("/konto/zwei-faktor-code", baseUrl)); } await createSession(customer.id, customer.role, customer.sessionVersion); return NextResponse.redirect(new URL("/konto", baseUrl)); }