fix: fehlende Dateien aus vorherigem Commit nachreichen

CSRF-Schutz, DHL-Versandmarken, Entra-ID-SSO, ApiCore-Sync und die
Harvest-isPublished-Migration waren gestaged aber nicht im letzten
Commit enthalten — Gitea-Actions-Build brach mit "Module not found"
fuer genau diese Dateien ab.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-03 20:10:50 +02:00
1 parent 4d3100e9ba
commit b6db9254a9
10 files changed
+1205

No files matched your search

+145
View File
@@ -0,0 +1,145 @@
import { randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getBaseUrl } from "@/lib/url";
import { createSession, hashPassword, getEntraOAuthCookie, clearEntraOAuthCookie } from "@/lib/auth";
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 {
const url = new URL("/konto", baseUrl);
url.searchParams.set("error", message);
return NextResponse.redirect(url);
}
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() },
});
}
// Anmeldung per SSO wird 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.
await createSession(customer.id, customer.role, customer.sessionVersion);
return NextResponse.redirect(new URL("/konto", baseUrl));
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { getBaseUrl } from "@/lib/url";
import { setEntraOAuthCookie } from "@/lib/auth";
import { signEntraOAuthState } from "@/lib/session";
import { isEntraConfigured, buildEntraAuthorizeUrl, generatePkcePair, generateOAuthState } from "@/lib/entra";
/**
* Startet die Microsoft-Anmeldung ("Mit Microsoft anmelden" auf /konto):
* erzeugt PKCE-Paar + CSRF-state, hinterlegt beides in einem kurzlebigen,
* signierten Cookie (siehe lib/session.ts ENTRA_OAUTH_COOKIE) und leitet zu
* Microsofts /authorize-Endpunkt weiter.
*/
export async function GET() {
if (!isEntraConfigured()) {
const url = new URL("/konto", getBaseUrl());
url.searchParams.set("error", "SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an.");
return NextResponse.redirect(url);
}
const { codeVerifier, codeChallenge } = generatePkcePair();
const state = generateOAuthState();
const cookieToken = await signEntraOAuthState(state, codeVerifier);
await setEntraOAuthCookie(cookieToken);
return NextResponse.redirect(buildEntraAuthorizeUrl({ state, codeChallenge }));
}