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:
1 parent
4d3100e9ba
commit
b6db9254a9
10 files changed
+1205
No files matched your search
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
-- Draft/Publish-Workflow fuer Ernten: Default true, damit alle VOR dieser
|
||||
-- Migration angelegten Ernten (bislang implizit oeffentlich) sichtbar
|
||||
-- bleiben. Neu aus ApiCore synchronisierte Ernten werden von der
|
||||
-- Sync-Action explizit mit isPublished = false angelegt (siehe
|
||||
-- admin/actions/apicore-sync.ts) und muessen erst von einem Admin ueber
|
||||
-- "Veroeffentlichen" freigegeben werden.
|
||||
ALTER TABLE "harvests" ADD COLUMN "isPublished" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,127 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import {
|
||||
fetchApiCoreHarvests,
|
||||
ApiCoreConfigError,
|
||||
ApiCoreRequestError,
|
||||
type ApiCoreHarvestWithApiaryName,
|
||||
} from "@/lib/apicore-client";
|
||||
import { requireAdmin } from "./_shared";
|
||||
|
||||
/**
|
||||
* Stabiler, eindeutiger Slug aus der ApiCore-Ernte-ID statt aus Typ/Jahr
|
||||
* abgeleitet (wie beim manuellen createHarvestAction) — dient hier zugleich
|
||||
* als Idempotenz-Schluessel: ein wiederholter Sync erkennt an diesem Slug,
|
||||
* ob eine ApiCore-Ernte schon einmal importiert wurde, statt bei jedem Lauf
|
||||
* Duplikate anzulegen.
|
||||
*/
|
||||
function slugFor(sourceId: string): string {
|
||||
return `apicore-${sourceId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wabenhains Harvest.description ist ein Pflichtfeld, ApiCore liefert dafuer
|
||||
* kein Aequivalent — baut stattdessen eine sinnvolle Vorbefuellung aus den
|
||||
* vorhandenen Mengen-/Notizfeldern, die der Admin beim Review verfeinert.
|
||||
*/
|
||||
function buildDescription(source: ApiCoreHarvestWithApiaryName): string {
|
||||
const parts: string[] = [];
|
||||
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
|
||||
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
|
||||
if (source.netWeight != null) parts.push(`Nettogewicht ${source.netWeight} kg`);
|
||||
if (source.notes?.trim()) parts.push(source.notes.trim());
|
||||
const detail = parts.length > 0 ? parts.join(" · ") : "Bitte Beschreibung ergänzen.";
|
||||
return `Automatisch aus ApiCore importiert. ${detail}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zieht Ernten aus ApiCore und legt sie als unveroeffentlichte Entwuerfe an
|
||||
* (isPublished: false) bzw. aktualisiert bereits vorhandene, noch
|
||||
* unveroeffentlichte Entwuerfe. Bereits vom Admin veroeffentlichte,
|
||||
* frueher synchronisierte Ernten werden NICHT angefasst — sonst wuerde ein
|
||||
* erneuter Sync eine bewusste Freigabe- und Bearbeitungsentscheidung des
|
||||
* Admins stillschweigend zuruecksetzen.
|
||||
*
|
||||
* Admin-Berechtigungsfeld "type" bei Wabenhain (Erntesaison, z.B.
|
||||
* "Sommertracht") und ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig")
|
||||
* sind KEINE aequivalenten Taxonomien — es findet bewusst keine automatische
|
||||
* Uebersetzung statt, der Rohwert wird uebernommen und muss vom Admin beim
|
||||
* Review korrigiert werden.
|
||||
*/
|
||||
export async function syncHarvestsFromApiCoreAction(): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
|
||||
try {
|
||||
sourceHarvests = await fetchApiCoreHarvests();
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "Unbekannter Fehler";
|
||||
redirect(`/admin/ernten?syncError=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const source of sourceHarvests) {
|
||||
const slug = slugFor(source.id);
|
||||
const date = new Date(source.date);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
failed++;
|
||||
console.error(`[apicore-sync] Ernte ${source.id} hat ein ungueltiges Datum ("${source.date}") — übersprungen.`);
|
||||
continue;
|
||||
}
|
||||
const type = source.honeyType?.trim() || `Ernte ${source.id.slice(0, 8)}`;
|
||||
|
||||
try {
|
||||
const existing = await prisma.harvest.findUnique({ where: { slug } });
|
||||
|
||||
if (existing?.isPublished) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = {
|
||||
year: date.getFullYear(),
|
||||
type,
|
||||
harvestDate: date,
|
||||
location: source.apiaryName ?? "Altenmünster",
|
||||
description: buildDescription(source),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await prisma.harvest.update({ where: { slug }, data });
|
||||
updated++;
|
||||
} else {
|
||||
await prisma.harvest.create({ data: { ...data, slug, isPublished: false } });
|
||||
created++;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
failed++;
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
// z.B. P2002 (unique constraint year+type), falls zwei ApiCore-Ernten
|
||||
// im selben Jahr denselben honeyType tragen — bewusst nicht fatal fuer
|
||||
// den gesamten Sync-Lauf, siehe Kommentar oben zur Taxonomie-Kollision.
|
||||
console.error(`[apicore-sync] Ernte ${source.id} konnte nicht importiert werden: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "harvest.sync_apicore",
|
||||
entityType: "Harvest",
|
||||
detail: `${created} neu, ${updated} aktualisiert, ${skipped} übersprungen, ${failed} fehlgeschlagen`,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/ernten");
|
||||
redirect(`/admin/ernten?synced=${created}&updated=${updated}&skipped=${skipped}&failed=${failed}`);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl";
|
||||
import { requireAdmin, fail, str } from "./_shared";
|
||||
|
||||
/**
|
||||
* Erstellt per DHL Parcel DE Shipping API eine Versandmarke fuer eine
|
||||
* bezahlte Bestellung und speichert die zurueckgegebene Sendungsnummer im
|
||||
* Shipment-Datensatz (carrier="DHL"). Idempotent: ist bereits eine
|
||||
* Sendungsnummer erfasst, wird KEINE zweite Marke erzeugt (das wuerde bei
|
||||
* DHL echte Kosten verursachen) — die Aktion bricht in dem Fall mit einer
|
||||
* klaren Meldung ab, statt stillschweigend zu ueberschreiben.
|
||||
*/
|
||||
export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const orderId = str(formData, "orderId");
|
||||
const detailPath = `/admin/bestellungen/${orderId}`;
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: {
|
||||
items: { select: { weightGrams: true, quantity: true } },
|
||||
payment: { select: { status: true } },
|
||||
shipment: { select: { trackingNumber: true } },
|
||||
},
|
||||
});
|
||||
if (!order) {
|
||||
fail("/admin/bestellungen", "Bestellung nicht gefunden.");
|
||||
}
|
||||
|
||||
if (order.fulfillmentMethod !== "VERSAND") {
|
||||
fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig.");
|
||||
}
|
||||
if (order.payment?.status !== "SUCCEEDED") {
|
||||
fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden.");
|
||||
}
|
||||
if (order.shipment?.trackingNumber) {
|
||||
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst.");
|
||||
}
|
||||
|
||||
const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0);
|
||||
const company = await getCompanyInfo();
|
||||
|
||||
let result: DhlCreateShipmentResult;
|
||||
try {
|
||||
result = await createDhlShipmentLabel({
|
||||
reference: order.orderNumber,
|
||||
weightGrams: totalWeightGrams,
|
||||
shipper: {
|
||||
companyName: company.name,
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
street: company.address.street,
|
||||
zip: company.address.zip,
|
||||
city: company.address.city,
|
||||
// Firmensitz ist Deutschland (siehe lib/site-config.ts) — bewusst
|
||||
// fest verdrahtet statt aus dem lokalisierten Anzeigetext
|
||||
// ("Deutschland") geparst.
|
||||
country: "DE",
|
||||
phone: company.phone,
|
||||
email: company.email,
|
||||
},
|
||||
receiver: {
|
||||
firstName: order.firstName,
|
||||
lastName: order.lastName,
|
||||
street: order.street,
|
||||
zip: order.zip,
|
||||
city: order.city,
|
||||
country: order.country,
|
||||
phone: order.phone,
|
||||
email: order.email,
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof DhlNotConfiguredError) {
|
||||
fail(detailPath, error.message);
|
||||
}
|
||||
if (error instanceof DhlApiError) {
|
||||
fail(detailPath, error.message);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
|
||||
}
|
||||
|
||||
await prisma.shipment.upsert({
|
||||
where: { orderId },
|
||||
create: { orderId, carrier: "DHL", trackingNumber: result.trackingNumber },
|
||||
update: { carrier: "DHL", trackingNumber: result.trackingNumber },
|
||||
});
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "order.dhl_label_created",
|
||||
entityType: "Order",
|
||||
entityId: orderId,
|
||||
detail: `${order.orderNumber}: DHL-Sendungsnummer ${result.trackingNumber}`,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/bestellungen");
|
||||
revalidatePath(detailPath);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* HTTP-Client fuer die ApiCore-Backend-API (separates Projekt, lokal unter
|
||||
* .../ApiCore/ApiCore-Backend — ein NestJS-Server mit eigener Postgres-DB),
|
||||
* die die "Ernten"-Rohdaten fuer den Admin-Sync bereitstellt
|
||||
* (admin/actions/apicore-sync.ts).
|
||||
*
|
||||
* Auth-Modell von ApiCore: E-Mail/Passwort-Login -> kurzlebiges JWT
|
||||
* (POST /auth/login), Endpunkte pruefen zusaetzlich granulare Berechtigungen
|
||||
* ("harvests:read" fuer GET /harvests). Es gibt dort AKTUELL KEINEN
|
||||
* API-Key-/Service-Account-Mechanismus fuer maschinelle Zugriffe — nur den
|
||||
* interaktiven Login-Flow, optional mit einem zweiten MFA-Schritt
|
||||
* (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen
|
||||
* automatisierten Sync wird dringend ein eigenes, MFA-freies Service-Konto
|
||||
* mit ausschliesslich den Berechtigungen "harvests:read" (und optional
|
||||
* "apiaries:read", fuer Standortnamen) empfohlen statt eines echten
|
||||
* Admin-Zugangs.
|
||||
*
|
||||
* Benoetigte Umgebungsvariablen (siehe .env.example):
|
||||
* APICORE_API_URL z.B. "http://localhost:3001/api" (globaler Prefix
|
||||
* "api" laut ApiCore-Backend/src/app.config.ts)
|
||||
* APICORE_SYNC_EMAIL E-Mail des Service-Kontos
|
||||
* APICORE_SYNC_PASSWORD Passwort des Service-Kontos
|
||||
*
|
||||
* Ohne diese drei Variablen wirft fetchApiCoreHarvests() sofort einen
|
||||
* ApiCoreConfigError — der Aufrufer (apicore-sync.ts) zeigt dessen Message
|
||||
* dem Admin an, statt die Aktion stillschweigend nichts tun zu lassen.
|
||||
*/
|
||||
|
||||
export class ApiCoreConfigError extends Error {}
|
||||
export class ApiCoreRequestError extends Error {}
|
||||
|
||||
const loginResponseSchema = z.union([
|
||||
z.object({ accessToken: z.string().min(1) }),
|
||||
z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }),
|
||||
]);
|
||||
|
||||
const apiarySchema = z.object({ id: z.string(), name: z.string() });
|
||||
|
||||
const harvestSchema = z.object({
|
||||
id: z.string(),
|
||||
apiaryId: z.string(),
|
||||
date: z.string(),
|
||||
honeyType: z.string().nullable().optional(),
|
||||
honeySuperCount: z.number().nullable().optional(),
|
||||
grossWeight: z.number().nullable().optional(),
|
||||
netWeight: z.number().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type ApiCoreHarvest = z.infer<typeof harvestSchema>;
|
||||
|
||||
export interface ApiCoreHarvestWithApiaryName extends ApiCoreHarvest {
|
||||
apiaryName: string | null;
|
||||
}
|
||||
|
||||
function getConfig(): { baseUrl: string; email: string; password: string } {
|
||||
const baseUrl = process.env.APICORE_API_URL;
|
||||
const email = process.env.APICORE_SYNC_EMAIL;
|
||||
const password = process.env.APICORE_SYNC_PASSWORD;
|
||||
if (!baseUrl || !email || !password) {
|
||||
throw new ApiCoreConfigError(
|
||||
"ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL, APICORE_SYNC_EMAIL und APICORE_SYNC_PASSWORD setzen (siehe .env.example)."
|
||||
);
|
||||
}
|
||||
return { baseUrl: baseUrl.replace(/\/$/, ""), email, password };
|
||||
}
|
||||
|
||||
async function login(baseUrl: string, email: string, password: string): Promise<string> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${baseUrl}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiCoreRequestError(`ApiCore-Login fehlgeschlagen (HTTP ${res.status}).`);
|
||||
}
|
||||
const parsed = loginResponseSchema.safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
throw new ApiCoreRequestError("ApiCore-Login: unerwartete Antwortstruktur.");
|
||||
}
|
||||
if ("mfaRequired" in parsed.data) {
|
||||
throw new ApiCoreConfigError(
|
||||
"Das konfigurierte ApiCore-Sync-Konto hat MFA aktiviert. Fuer den automatisierten Sync wird ein MFA-freies Service-Konto benoetigt (siehe Kommentar in apicore-client.ts)."
|
||||
);
|
||||
}
|
||||
return parsed.data.accessToken;
|
||||
}
|
||||
|
||||
/** Best effort: Standortnamen sind eine Komfort-Anreicherung, kein kritischer Pfad. */
|
||||
async function fetchApiaryNames(baseUrl: string, token: string): Promise<Map<string, string>> {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/apiaries`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) return new Map();
|
||||
const parsed = z.array(apiarySchema).safeParse(await res.json());
|
||||
if (!parsed.success) return new Map();
|
||||
return new Map(parsed.data.map((a) => [a.id, a.name]));
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchApiCoreHarvests(): Promise<ApiCoreHarvestWithApiaryName[]> {
|
||||
const { baseUrl, email, password } = getConfig();
|
||||
const token = await login(baseUrl, email, password);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${baseUrl}/harvests`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiCoreRequestError(`ApiCore: Ernten konnten nicht geladen werden (HTTP ${res.status}).`);
|
||||
}
|
||||
const parsed = z.array(harvestSchema).safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Ernten.");
|
||||
}
|
||||
|
||||
const apiaryNames = await fetchApiaryNames(baseUrl, token);
|
||||
return parsed.data.map((harvest) => ({
|
||||
...harvest,
|
||||
apiaryName: apiaryNames.get(harvest.apiaryId) ?? null,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Same-Origin-Pruefung fuer zustandsaendernde app/api/.../route.ts-Handler.
|
||||
*
|
||||
* Next.js' eingebauter CSRF-Schutz (Origin-Header gegen Host geprueft) gilt
|
||||
* nur fuer Server Actions ("use server") — eigene Route-Handler wie
|
||||
* /api/checkout sind normale POST-Endpunkte ohne diesen automatischen Schutz
|
||||
* und muessen ihn sich selbst holen. Ohne diese Pruefung liesse sich ein
|
||||
* state-aendernder POST z.B. per <form enctype="text/plain"> als "einfacher"
|
||||
* (nicht preflight-pflichtiger) Cross-Origin-Request faelschen (bekannte
|
||||
* JSON-CSRF-Umgehung) — bei /api/checkout koennte das, sofern der Besuch von
|
||||
* einer eingeloggten Kundschaft ausgeht, Bestellungen unter deren Konto
|
||||
* anlegen und Lagerbestand reservieren, ohne dass die Kundschaft das wollte.
|
||||
*/
|
||||
export function isTrustedOrigin(request: Request): boolean {
|
||||
const origin = request.headers.get("origin");
|
||||
// Kein Origin-Header: moderne Browser senden ihn bei POST/PUT/DELETE sowohl
|
||||
// fuer Cross-Origin- als auch fuer Same-Origin-Anfragen mit, ein fehlender
|
||||
// Header stammt also nicht von einem regulaeren browserbasierten
|
||||
// Cross-Site-Angriff. Wird hier bewusst durchgelassen, um serverseitige/
|
||||
// Tool-Aufrufe (z.B. Health-Checks) nicht zu blockieren.
|
||||
if (!origin) return true;
|
||||
|
||||
try {
|
||||
const requestHost = new URL(request.url).host;
|
||||
const originHost = new URL(origin).host;
|
||||
return requestHost === originHost;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Client fuer die DHL Parcel DE Shipping API v2 ("Post & Parcel Germany",
|
||||
* developer.dhl.com) — erstellt Versandmarken (Labels) fuer nationale
|
||||
* DHL-Paketsendungen.
|
||||
*
|
||||
* Authentifizierung laut aktueller DHL-Dokumentation (Stand: Recherche
|
||||
* September 2026, developer.dhl.com/api-reference/parcel-de-shipping-post-parcel-germany-v2):
|
||||
* Zwei Faktoren sind noetig, nicht nur ein API-Key:
|
||||
* 1. Ein OAuth2-Access-Token ueber den ROPC-Grant (Resource Owner Password
|
||||
* Credentials) unter POST /parcel/de/account/auth/ropc/v1/token, mit
|
||||
* App-Client-ID/-Secret UND den Zugangsdaten des DHL-Geschaeftskunden-
|
||||
* portals (GKP-Benutzername/-Passwort) im Body.
|
||||
* 2. Der App-API-Key zusaetzlich als "dhl-api-key"-Header auf jedem
|
||||
* eigentlichen API-Aufruf (identifiziert die registrierte App/Quota).
|
||||
* DHL kuendigt an, das aeltere reine Basic-Auth-Schema mittelfristig
|
||||
* abzuschaffen — dieser Client verwendet daher bewusst den OAuth2/ROPC-Weg.
|
||||
*
|
||||
* Sandbox-Basis-URL: https://api-sandbox.dhl.com
|
||||
* Produktions-Basis-URL: https://api-eu.dhl.com
|
||||
*
|
||||
* WICHTIG: Die genauen Feldnamen/Endpunkte wurden anhand der oeffentlichen
|
||||
* API-Referenz und des zugehoerigen OpenAPI-Schemas recherchiert, konnten in
|
||||
* dieser Umgebung aber nicht gegen einen echten Sandbox-Account getestet
|
||||
* werden (keine DHL-Zugangsdaten vorhanden). Vor dem produktiven Einsatz:
|
||||
* einmal mit echten Sandbox-Zugangsdaten (siehe .env.example) gegen
|
||||
* https://developer.dhl.com testen und bei Abweichungen die Request-/
|
||||
* Response-Shape unten anpassen — DHL liefert bei Validierungsfehlern
|
||||
* sprechende Fehlermeldungen, die per DhlApiError durchgereicht werden.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fehlerklassen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Wird geworfen, wenn eine/mehrere der benoetigten DHL_*-Umgebungsvariablen fehlen. */
|
||||
export class DhlNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"DHL ist noch nicht konfiguriert. Bitte DHL_API_KEY, DHL_API_SECRET, DHL_GKP_USERNAME, " +
|
||||
"DHL_GKP_PASSWORD und DHL_ACCOUNT_NUMBER in der .env-Datei setzen (siehe .env.example)."
|
||||
);
|
||||
this.name = "DhlNotConfiguredError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Wird bei einem fehlgeschlagenen Aufruf der DHL-API geworfen (Auth, Validierung, Netzwerk, ...). */
|
||||
export class DhlApiError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "DhlApiError";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Konfiguration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DhlConfig {
|
||||
apiKey: string;
|
||||
apiSecret: string;
|
||||
gkpUsername: string;
|
||||
gkpPassword: string;
|
||||
/**
|
||||
* "billingNumber" — 14-stellig: 10-stellige EKP (Kundennummer) + 2-stelliges
|
||||
* Verfahren + 2-stellige Teilnahme, z.B. "33333333330101" fuer V01PAK.
|
||||
* Wird vom DHL-Vertrieb im Rahmen des Geschaeftskundenvertrags vergeben.
|
||||
*/
|
||||
accountNumber: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
function getDhlConfig(): DhlConfig {
|
||||
const apiKey = process.env.DHL_API_KEY;
|
||||
const apiSecret = process.env.DHL_API_SECRET;
|
||||
const gkpUsername = process.env.DHL_GKP_USERNAME;
|
||||
const gkpPassword = process.env.DHL_GKP_PASSWORD;
|
||||
const accountNumber = process.env.DHL_ACCOUNT_NUMBER;
|
||||
|
||||
if (!apiKey || !apiSecret || !gkpUsername || !gkpPassword || !accountNumber) {
|
||||
throw new DhlNotConfiguredError();
|
||||
}
|
||||
|
||||
const environment = (process.env.DHL_ENVIRONMENT ?? "sandbox").trim().toLowerCase();
|
||||
const baseUrl = environment === "production" ? "https://api-eu.dhl.com" : "https://api-sandbox.dhl.com";
|
||||
|
||||
return { apiKey, apiSecret, gkpUsername, gkpPassword, accountNumber, baseUrl };
|
||||
}
|
||||
|
||||
/** Fuer die bedingte Anzeige/Deaktivierung des "Versandmarke erstellen"-Buttons im Admin-UI. */
|
||||
export function isDhlConfigured(): boolean {
|
||||
try {
|
||||
getDhlConfig();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuth2-Access-Token (ROPC), pro Serverprozess zwischengespeichert
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CachedToken {
|
||||
accessToken: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// Modul-weiter In-Memory-Cache: analog zu lib/rate-limit.ts ausreichend fuer
|
||||
// den Single-Container-Betrieb dieser Seite. Bei horizontaler Skalierung
|
||||
// wuerde jede Instanz ihr eigenes Token ziehen, was fuer DHL unproblematisch
|
||||
// ist (kein geteilter Zustand noetig).
|
||||
let cachedToken: CachedToken | null = null;
|
||||
|
||||
async function fetchAccessToken(config: DhlConfig): Promise<string> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${config.baseUrl}/parcel/de/account/auth/ropc/v1/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "password",
|
||||
username: config.gkpUsername,
|
||||
password: config.gkpPassword,
|
||||
client_id: config.apiKey,
|
||||
client_secret: config.apiSecret,
|
||||
}),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler";
|
||||
throw new DhlApiError(`DHL-Authentifizierung nicht erreichbar: ${message}`);
|
||||
}
|
||||
|
||||
const payload = await safeReadJson(response);
|
||||
if (!response.ok) {
|
||||
throw new DhlApiError(
|
||||
`DHL-Authentifizierung fehlgeschlagen (HTTP ${response.status}): ${extractErrorDetail(payload) ?? "unbekannter Fehler"}`
|
||||
);
|
||||
}
|
||||
|
||||
const accessToken = typeof payload?.access_token === "string" ? payload.access_token : null;
|
||||
if (!accessToken) {
|
||||
throw new DhlApiError("DHL-Authentifizierung lieferte kein Access Token zurück.");
|
||||
}
|
||||
|
||||
const expiresInSeconds = typeof payload?.expires_in === "number" ? payload.expires_in : 3600;
|
||||
// 60 Sekunden Sicherheitsabstand vor dem tatsaechlichen Ablauf, damit kein
|
||||
// knapp abgelaufenes Token noch fuer einen Request verwendet wird.
|
||||
cachedToken = { accessToken, expiresAt: Date.now() + Math.max(expiresInSeconds - 60, 30) * 1000 };
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
async function getAccessToken(config: DhlConfig): Promise<string> {
|
||||
if (cachedToken && cachedToken.expiresAt > Date.now()) return cachedToken.accessToken;
|
||||
return fetchAccessToken(config);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adress-Mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// DHL erwartet Laendercodes nach ISO 3166-1 ALPHA-3, waehrend Order.country
|
||||
// in diesem Projekt ALPHA-2 speichert (siehe prisma/schema.prisma). Bewusst
|
||||
// nur die in einem deutschen Honig-Versandhandel realistischen Zielmaerkte
|
||||
// gelistet — ein nicht gelistetes Land liefert einen klaren Fehler statt
|
||||
// eines falsch geratenen Codes.
|
||||
const COUNTRY_ISO2_TO_ISO3: Record<string, string> = {
|
||||
DE: "DEU",
|
||||
AT: "AUT",
|
||||
CH: "CHE",
|
||||
FR: "FRA",
|
||||
NL: "NLD",
|
||||
BE: "BEL",
|
||||
LU: "LUX",
|
||||
IT: "ITA",
|
||||
ES: "ESP",
|
||||
PL: "POL",
|
||||
DK: "DNK",
|
||||
CZ: "CZE",
|
||||
};
|
||||
|
||||
function toIso3Country(iso2: string): string {
|
||||
const code = COUNTRY_ISO2_TO_ISO3[iso2.trim().toUpperCase()];
|
||||
if (!code) {
|
||||
throw new DhlApiError(
|
||||
`Land "${iso2}" wird vom DHL-Client aktuell nicht unterstützt (siehe COUNTRY_ISO2_TO_ISO3 in lib/dhl.ts).`
|
||||
);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trennt eine deutsche Adresszeile ("Musterstraße 12a") in Straßenname und
|
||||
* Hausnummer fuer die getrennten DHL-Adressfelder (addressStreet/addressHouse).
|
||||
* Ohne erkennbare Hausnummer am Ende landet die komplette Zeile im
|
||||
* Straßenfeld — das Hausnummernfeld ist bei DHL optional, ein falscher
|
||||
* Trennversuch waere schlechter als gar keine Trennung.
|
||||
*/
|
||||
function splitStreet(street: string): { addressStreet: string; addressHouse?: string } {
|
||||
const trimmed = street.trim();
|
||||
const match = trimmed.match(/^(.*\S)\s+(\d+\s*[a-zA-Z]?)$/);
|
||||
if (!match) return { addressStreet: trimmed };
|
||||
return { addressStreet: match[1], addressHouse: match[2].replace(/\s+/g, "") };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Versandmarke erstellen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DhlAddressInput {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
street: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
/** ISO 3166-1 ALPHA-2, z.B. "DE" — wie Order.country in prisma/schema.prisma. */
|
||||
country: string;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
export interface DhlShipperInput extends DhlAddressInput {
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
export interface DhlCreateShipmentInput {
|
||||
/** Wird als DHL-Referenz (refNo) mitgeschickt, z.B. die Wabenhain-Bestellnummer. */
|
||||
reference: string;
|
||||
/** Gesamtgewicht der Sendung in Gramm (Summe aller Positionen inkl. Menge). */
|
||||
weightGrams: number;
|
||||
shipper: DhlShipperInput;
|
||||
receiver: DhlAddressInput;
|
||||
}
|
||||
|
||||
export interface DhlCreateShipmentResult {
|
||||
trackingNumber: string;
|
||||
/** Base64-kodiertes PDF-Label — wird aktuell nicht persistiert (siehe README/Bericht). */
|
||||
labelPdfBase64: string;
|
||||
}
|
||||
|
||||
// Deutsches Standard-Inlandspaket. Andere Produkte (z.B. Warenpost/Kleinpaket
|
||||
// fuer kleine, leichte Sendungen) sind bewusst nicht abgebildet — YAGNI,
|
||||
// bis der Bedarf konkret entsteht.
|
||||
const DHL_PRODUCT_NATIONAL_PARCEL = "V01PAK";
|
||||
|
||||
function buildDhlContact(address: DhlAddressInput, name1: string) {
|
||||
const { addressStreet, addressHouse } = splitStreet(address.street);
|
||||
return {
|
||||
name1,
|
||||
phone: address.phone || undefined,
|
||||
email: address.email || undefined,
|
||||
address: {
|
||||
addressStreet,
|
||||
addressHouse,
|
||||
postalCode: address.zip,
|
||||
city: address.city,
|
||||
country: toIso3Country(address.country),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDhlShipmentLabel(
|
||||
input: DhlCreateShipmentInput
|
||||
): Promise<DhlCreateShipmentResult> {
|
||||
const config = getDhlConfig();
|
||||
const accessToken = await getAccessToken(config);
|
||||
|
||||
const requestBody = {
|
||||
shipments: [
|
||||
{
|
||||
product: DHL_PRODUCT_NATIONAL_PARCEL,
|
||||
billingNumber: config.accountNumber,
|
||||
// DHL erlaubt max. 35 Zeichen fuer refNo.
|
||||
refNo: input.reference.slice(0, 35),
|
||||
shipper: buildDhlContact(input.shipper, input.shipper.companyName),
|
||||
consignee: buildDhlContact(input.receiver, `${input.receiver.firstName} ${input.receiver.lastName}`.trim()),
|
||||
details: {
|
||||
weight: { uom: "g", value: Math.max(Math.round(input.weightGrams), 1) },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${config.baseUrl}/parcel/de/shipping/v2/orders`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"dhl-api-key": config.apiKey,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler";
|
||||
throw new DhlApiError(`DHL-API nicht erreichbar: ${message}`);
|
||||
}
|
||||
|
||||
const payload = await safeReadJson(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new DhlApiError(
|
||||
`DHL-Versandmarke konnte nicht erstellt werden (HTTP ${response.status}): ${extractErrorDetail(payload) ?? "unbekannter Fehler"}`
|
||||
);
|
||||
}
|
||||
|
||||
const item = Array.isArray(payload?.items) ? payload.items[0] : undefined;
|
||||
const validationError = Array.isArray(item?.validationMessages)
|
||||
? item.validationMessages.find((entry: { validationState?: string }) => entry?.validationState === "Error")
|
||||
: undefined;
|
||||
if (validationError) {
|
||||
throw new DhlApiError(`DHL meldet einen Fehler in den Sendungsdaten: ${validationError.validationMessage ?? "unbekannt"}`);
|
||||
}
|
||||
|
||||
const trackingNumber = typeof item?.shipmentNo === "string" ? item.shipmentNo : null;
|
||||
const labelPdfBase64 = typeof item?.label?.b64 === "string" ? item.label.b64 : null;
|
||||
if (!trackingNumber || !labelPdfBase64) {
|
||||
throw new DhlApiError("DHL-Antwort enthielt keine Sendungsnummer oder kein Label — bitte Sandbox-Response pruefen.");
|
||||
}
|
||||
|
||||
return { trackingNumber, labelPdfBase64 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hilfsfunktionen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function safeReadJson(response: Response): Promise<Record<string, unknown> | undefined> {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Extrahiert eine lesbare Fehlermeldung aus gaengigen DHL-Fehlerformaten (RFC7807-aehnlich). */
|
||||
function extractErrorDetail(payload: Record<string, unknown> | undefined): string | undefined {
|
||||
if (!payload) return undefined;
|
||||
if (typeof payload.detail === "string") return payload.detail;
|
||||
if (typeof payload.title === "string") return payload.title;
|
||||
if (typeof payload.error_description === "string") return payload.error_description;
|
||||
if (typeof payload.message === "string") return payload.message;
|
||||
const status = payload.status as { detail?: string; title?: string } | undefined;
|
||||
if (status?.detail) return status.detail;
|
||||
if (status?.title) return status.title;
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* OIDC-Client fuer "Mit Microsoft anmelden" (Entra ID / Azure AD) —
|
||||
* OAuth2 Authorization Code Flow mit PKCE gegen die Microsoft Identity
|
||||
* Platform, siehe learn.microsoft.com/entra/identity-platform/
|
||||
* v2-oauth2-auth-code-flow und .../v2-protocols-oidc (Stand der Recherche:
|
||||
* September 2026).
|
||||
*
|
||||
* Endpunkte (single-tenant, {tenant} = ENTRA_TENANT_ID):
|
||||
* Autorisierung: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize
|
||||
* Token: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
|
||||
* JWKS: https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys
|
||||
* Issuer (iss-Claim im ID-Token): https://login.microsoftonline.com/{tenant}/v2.0
|
||||
*
|
||||
* response_type=code (reiner Authorization-Code-Flow, kein Hybrid-Flow) —
|
||||
* das ID-Token kommt ausschliesslich ueber den serverseitigen Token-
|
||||
* Endpunkt zurueck (Backchannel, per client_secret authentifiziert), nicht
|
||||
* ueber die Redirect-URL. Ein "nonce"-Parameter ist daher nicht noetig
|
||||
* (der schuetzt beim Hybrid-/Implicit-Flow vor Replay eines im Front-
|
||||
* channel/URL-Fragment sichtbaren ID-Tokens).
|
||||
*/
|
||||
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { jwtVerify, createRemoteJWKSet, type JWTPayload } from "jose";
|
||||
|
||||
export class EntraNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Microsoft-Anmeldung (SSO) ist noch nicht konfiguriert. Bitte ENTRA_TENANT_ID, ENTRA_CLIENT_ID, " +
|
||||
"ENTRA_CLIENT_SECRET und ENTRA_REDIRECT_URI in der .env-Datei setzen (siehe .env.example)."
|
||||
);
|
||||
this.name = "EntraNotConfiguredError";
|
||||
}
|
||||
}
|
||||
|
||||
export class EntraAuthError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "EntraAuthError";
|
||||
}
|
||||
}
|
||||
|
||||
interface EntraConfig {
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
function getEntraConfig(): EntraConfig {
|
||||
const tenantId = process.env.ENTRA_TENANT_ID;
|
||||
const clientId = process.env.ENTRA_CLIENT_ID;
|
||||
const clientSecret = process.env.ENTRA_CLIENT_SECRET;
|
||||
const redirectUri = process.env.ENTRA_REDIRECT_URI;
|
||||
if (!tenantId || !clientId || !clientSecret || !redirectUri) {
|
||||
throw new EntraNotConfiguredError();
|
||||
}
|
||||
return { tenantId, clientId, clientSecret, redirectUri };
|
||||
}
|
||||
|
||||
/** Fuer die bedingte Anzeige des "Mit Microsoft anmelden"-Buttons im Login-UI. */
|
||||
export function isEntraConfigured(): boolean {
|
||||
try {
|
||||
getEntraConfig();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function authorizeEndpoint(tenantId: string): string {
|
||||
return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`;
|
||||
}
|
||||
function tokenEndpoint(tenantId: string): string {
|
||||
return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
|
||||
}
|
||||
function issuer(tenantId: string): string {
|
||||
return `https://login.microsoftonline.com/${tenantId}/v2.0`;
|
||||
}
|
||||
function jwksUri(tenantId: string): string {
|
||||
return `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKCE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PkcePair {
|
||||
codeVerifier: string;
|
||||
codeChallenge: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* code_verifier: 43-128 Zeichen aus dem PKCE-erlaubten Zeichensatz (RFC 7636).
|
||||
* base64url-Kodierung von 32 Zufallsbytes ergibt 43 Zeichen aus exakt diesem
|
||||
* Zeichensatz ([A-Za-z0-9_-]) — kein zusaetzlicher Zeichensatz-Filter noetig.
|
||||
* code_challenge = base64url(SHA256(code_verifier)), Methode "S256".
|
||||
*/
|
||||
export function generatePkcePair(): PkcePair {
|
||||
const codeVerifier = randomBytes(32).toString("base64url");
|
||||
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
export function generateOAuthState(): string {
|
||||
return randomBytes(24).toString("base64url");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Redirect zu Microsoft
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildEntraAuthorizeUrl(params: { state: string; codeChallenge: string }): string {
|
||||
const config = getEntraConfig();
|
||||
const url = new URL(authorizeEndpoint(config.tenantId));
|
||||
url.searchParams.set("client_id", config.clientId);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("redirect_uri", config.redirectUri);
|
||||
url.searchParams.set("response_mode", "query");
|
||||
// "email" liefert den verifizierten E-Mail-Claim, "profile" Vor-/Nachname
|
||||
// fuer die Konto-Erstellung. Kein "offline_access" — wir brauchen keinen
|
||||
// Refresh-Token, es findet kein weiterer API-Zugriff im Namen des Nutzers statt.
|
||||
url.searchParams.set("scope", "openid profile email");
|
||||
url.searchParams.set("state", params.state);
|
||||
url.searchParams.set("code_challenge", params.codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
// Zeigt immer den Konto-Auswahldialog statt automatischem Single-Sign-On
|
||||
// auf ein evtl. im Browser noch aktives fremdes Microsoft-Konto.
|
||||
url.searchParams.set("prompt", "select_account");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Code gegen Tokens tauschen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TokenResponse {
|
||||
id_token: string;
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
export async function exchangeEntraAuthorizationCode(params: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
}): Promise<TokenResponse> {
|
||||
const config = getEntraConfig();
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(tokenEndpoint(config.tenantId), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
grant_type: "authorization_code",
|
||||
code: params.code,
|
||||
redirect_uri: config.redirectUri,
|
||||
code_verifier: params.codeVerifier,
|
||||
scope: "openid profile email",
|
||||
}),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler";
|
||||
throw new EntraAuthError(`Microsoft-Token-Endpunkt nicht erreichbar: ${message}`);
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown> | undefined;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = undefined;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail =
|
||||
(typeof payload?.error_description === "string" && payload.error_description) ||
|
||||
(typeof payload?.error === "string" && payload.error) ||
|
||||
`HTTP ${response.status}`;
|
||||
throw new EntraAuthError(`Microsoft-Anmeldung fehlgeschlagen: ${detail}`);
|
||||
}
|
||||
|
||||
if (!payload || typeof payload.id_token !== "string") {
|
||||
throw new EntraAuthError("Microsoft-Antwort enthielt kein ID-Token.");
|
||||
}
|
||||
|
||||
return {
|
||||
id_token: payload.id_token,
|
||||
access_token: typeof payload.access_token === "string" ? payload.access_token : undefined,
|
||||
token_type: typeof payload.token_type === "string" ? payload.token_type : undefined,
|
||||
expires_in: typeof payload.expires_in === "number" ? payload.expires_in : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. ID-Token verifizieren (Signatur + Claims) — NIE ungeprueft vertrauen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EntraIdTokenClaims extends JWTPayload {
|
||||
email?: string;
|
||||
preferred_username?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
name?: string;
|
||||
oid?: string;
|
||||
tid?: string;
|
||||
}
|
||||
|
||||
// Ein JWKSet pro Tenant reicht (dieses Projekt bindet genau einen Tenant an) —
|
||||
// jose cached/erneuert die Schluessel intern automatisch (Key-Rollover).
|
||||
let cachedJwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
let cachedJwksTenantId: string | null = null;
|
||||
|
||||
function getJwks(tenantId: string): ReturnType<typeof createRemoteJWKSet> {
|
||||
if (cachedJwks && cachedJwksTenantId === tenantId) return cachedJwks;
|
||||
cachedJwks = createRemoteJWKSet(new URL(jwksUri(tenantId)));
|
||||
cachedJwksTenantId = tenantId;
|
||||
return cachedJwks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifiziert Signatur, issuer, audience und Ablauf des ID-Tokens gegen
|
||||
* Microsofts oeffentliche Signaturschluessel (JWKS) — niemals ein Token
|
||||
* ungeprueft dekodieren und seinen Claims vertrauen, sonst koennte jeder ein
|
||||
* beliebiges Konto durch ein selbstgebasteltes JWT vortaeuschen. Gibt bei
|
||||
* Erfolg die geprueften Claims zurueck, wirft sonst EntraAuthError.
|
||||
*/
|
||||
export async function verifyEntraIdToken(idToken: string): Promise<EntraIdTokenClaims> {
|
||||
const config = getEntraConfig();
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify<EntraIdTokenClaims>(idToken, getJwks(config.tenantId), {
|
||||
issuer: issuer(config.tenantId),
|
||||
audience: config.clientId,
|
||||
algorithms: ["RS256"],
|
||||
});
|
||||
return payload;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
throw new EntraAuthError(`ID-Token-Prüfung fehlgeschlagen: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert die verifizierte E-Mail-Adresse aus den ID-Token-Claims.
|
||||
* Bewusst NUR der "email"-Claim (aus dem signierten Token, nie ein vom
|
||||
* Client mitgeschicktes Feld) — "preferred_username" ist bei Microsoft-
|
||||
* Konten zwar meist ebenfalls die E-Mail/UPN, aber laut Microsofts eigener
|
||||
* Dokumentation nicht garantiert ident/verifiziert und kann sich zudem
|
||||
* aendern. Fehlt der email-Claim (z.B. Konto ohne hinterlegte Mailbox),
|
||||
* wird die Anmeldung bewusst abgelehnt statt zu raten.
|
||||
*/
|
||||
export function getVerifiedEmail(claims: EntraIdTokenClaims): string | null {
|
||||
if (typeof claims.email === "string" && claims.email.includes("@")) {
|
||||
return claims.email.trim().toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getContentValues } from "@/lib/content";
|
||||
|
||||
const SOCIAL_KEYS = ["social.tiktok", "social.youtube", "social.instagram"] as const;
|
||||
|
||||
export interface SocialLinks {
|
||||
tiktok: string;
|
||||
youtube: string;
|
||||
instagram: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-gepflegte Social-Media-Links (siehe admin/inhalte, Gruppe "Social
|
||||
* Media") — nicht lokalisiert, wie Firmendaten (company-info.ts). Ein leerer
|
||||
* String bedeutet "kein Profil hinterlegt"; der Footer rendert dann kein
|
||||
* Icon fuer diese Plattform (siehe components/site-chrome.tsx Footer()).
|
||||
*/
|
||||
export async function getSocialLinks(): Promise<SocialLinks> {
|
||||
const v = await getContentValues([...SOCIAL_KEYS], "de");
|
||||
return {
|
||||
tiktok: v["social.tiktok"],
|
||||
youtube: v["social.youtube"],
|
||||
instagram: v["social.instagram"],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user