Viele Aktionen gaben keine Rueckmeldung (Retoure annehmen, Archiv, Bewertungen, Adressen, Newsletter, Kundensperre ...) oder meldeten Erfolg, obwohl die zugehoerige E-Mail nie rausging. - lib/flash: Meldung als kurzlebiges Cookie; Toast im Root-Layout (FlashToast). Ersetzt ?message=/?error= in der URL — darueber liess sich bisher beliebiger Text als Systemmeldung im Wabenhain-Layout anzeigen (Phishing-Hebel). Server Components leiten ueber /api/hinweis mit festen Codes und festen Zielen weiter. - fail()/succeed() zentral und asynchron; ein Quelltext-Test stellt sicher, dass jeder Aufruf mit await/return erfolgt (sonst liefe eine Action nach einem Fehler weiter). - sendMail meldet, ob die Mail angenommen wurde; Aktionen mit Mail zeigen ehrlich an, wenn der Versand scheitert (z.B. "Link erneut senden"). - Jede Server Action meldet jetzt Erfolg oder den Grund des Abbruchs. - SubmitFeedback: geklickter Knopf zeigt sofort einen Ladezustand und ist bis zur Antwort gegen Doppelklick gesperrt. - 2FA: nach dem Einrichten zurueck ins Profil statt auf die Seite mit "Deaktivieren". Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
169 lines
7.2 KiB
TypeScript
169 lines
7.2 KiB
TypeScript
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));
|
|
}
|