From 2bbb38c31a416496056e7183097f1e3db88294f9 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 19 Sep 2026 04:22:53 +0200 Subject: [PATCH] fix: SSO umging die Admin-2FA; perf: Dashboard und fehlende Indizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SICHERHEIT — "Mit Microsoft anmelden" uebersprang die eigene 2FA auch bei Admin-Konten. requireAdmin (admin/actions/_shared.ts) laesst eine Admin-Aktion nur zu, wenn am Konto twoFactorEnabled steht. Das Flag soll zusichern, dass hinter jeder Rueckerstattung und jedem Blick in die Kundendaten tatsaechlich ein zweiter Faktor stand. Der SSO-Rueckweg legte aber direkt eine vollstaendige Session an und lieferte damit ein Flag, das "geprueft" behauptet, obwohl in diesem Anmeldevorgang nichts geprueft wurde — die Zusicherung hing allein an einer Annahme ueber die MFA-Richtlinie des Entra-Mandanten, die dieser Code nicht nachpruefen kann. Ein ADMIN mit aktivierter 2FA laeuft jetzt auch per SSO durch denselben TOTP-Schritt wie beim Passwort-Login. Ausgesperrt wird dabei niemand: twoFactorEnabled heisst, dass der zweite Faktor eingerichtet ist. Fuer KUNDENkonten bleibt der bequeme Weg wie dokumentiert bestehen. PERFORMANCE — zwei unbegrenzte Abfragen auf dem Admin-Dashboard. Die Startseite des Admin-Bereichs lud bei jedem Aufruf 1. JEDE Bestellposition, die es je gab, samt Produkt-Join — um daraus fuenf Zeilen "Top-Produkte" zu rechnen, und 2. JEDE Bestellung, die es je gab — um daraus eine einzige Zahl ("Stammkunden") zu machen. Die Begruendung im Code lautete, Prisma koenne kein HAVING. Es kann. Beides erledigt jetzt die Datenbank per groupBy. Aus "waechst mit jedem Verkauf" wird "eine Zeile je verkauftem Produkt" bzw. "eine Zeile je Stammkundschaft". Die Kategorie haengt am Produkt und laesst sich nicht mitgruppieren; sie kommt aus einer kurzen Nachfrage ueber die Primaerschluessel der tatsaechlich verkauften Produkte. PERFORMANCE — fehlende Indizes auf order_items. PostgreSQL legt fuer Fremdschluessel-Spalten keinen Index an (anders als MySQL/InnoDB). Auf der am schnellsten wachsenden Tabelle des Shops fehlten dadurch zwei: - productId: hasCustomerPurchased (lib/reviews.ts) prueft den "verifizierten Kauf" bei JEDEM Aufruf einer Produktseite durch eine angemeldete Kundschaft — bisher als vollstaendiger Tabellenscan. - batchId: das Loeschen einer Charge prueft ueber onDelete: Restrict gegen dieselbe Tabelle. Migration 20260919010000_order_item_fk_indexes. tsc: 0 Fehler. vitest: 328/328 gruen. eslint: 0 Warnungen. Co-Authored-By: Claude Opus 5 (1M context) --- .../migration.sql | 22 +++++ prisma/schema.prisma | 16 ++++ src/app/admin/page.tsx | 86 ++++++++++++------- src/app/api/auth/entra/callback/route.ts | 36 ++++++-- 4 files changed, 124 insertions(+), 36 deletions(-) create mode 100644 prisma/migrations/20260919010000_order_item_fk_indexes/migration.sql diff --git a/prisma/migrations/20260919010000_order_item_fk_indexes/migration.sql b/prisma/migrations/20260919010000_order_item_fk_indexes/migration.sql new file mode 100644 index 0000000..4c54fc1 --- /dev/null +++ b/prisma/migrations/20260919010000_order_item_fk_indexes/migration.sql @@ -0,0 +1,22 @@ +-- CreateIndex +-- PostgreSQL legt fuer eine Fremdschluessel-Spalte KEINEN Index an (anders als +-- MySQL/InnoDB, das ihn automatisch anlegt). "order_items" ist die am +-- schnellsten wachsende Tabelle des Shops — je Bestellung eine Zeile pro +-- Position, und geloescht wird dort nichts. Ohne diesen Index laeuft jede +-- Abfrage nach productId als vollstaendiger Tabellenscan. +-- +-- Zwei Stellen vertragen das nicht: +-- +-- 1. hasCustomerPurchased (src/lib/reviews.ts) — der Nachweis +-- "verifizierter Kauf". Er laeuft bei JEDEM Aufruf einer Produktseite +-- durch eine angemeldete Kundschaft, also auf dem meistbesuchten +-- Seitentyp ueberhaupt. +-- 2. Das Loeschen eines Produkts im Admin-Bereich: die product-Relation ist +-- "onDelete: Restrict", die Datenbank muss also pruefen, ob noch +-- Positionen daran haengen. +CREATE INDEX "order_items_productId_idx" ON "order_items"("productId"); + +-- CreateIndex +-- Gleicher Grund: die batch-Relation ist ebenfalls "onDelete: Restrict". Das +-- Loeschen einer Charge im Admin-Bereich prueft gegen dieselbe Tabelle. +CREATE INDEX "order_items_batchId_idx" ON "order_items"("batchId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8096fe3..07b8c8b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -491,6 +491,22 @@ model OrderItem { /// Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin, /// Rechnung) ueber die order.items-Relation abgefragt. @@index([orderId]) + /// PostgreSQL legt fuer eine Fremdschluessel-Spalte KEINEN Index an (anders + /// als MySQL/InnoDB) — ohne diesen hier laeuft jede Abfrage nach productId + /// als vollstaendiger Scan ueber die am schnellsten wachsende Tabelle des + /// Shops. Betroffen sind zwei Stellen, die das nicht vertragen: + /// + /// 1. hasCustomerPurchased (lib/reviews.ts) — der Nachweis "verifizierter + /// Kauf", der bei JEDEM Aufruf einer Produktseite durch eine + /// angemeldete Kundschaft ausgefuehrt wird. + /// 2. onDelete: Restrict auf der product-Relation — die Datenbank muss + /// beim Loeschen eines Produkts pruefen, ob noch Positionen daran + /// haengen. + @@index([productId]) + /// Aus demselben Grund wie productId: die batch-Relation ist ebenfalls + /// "Restrict", das Loeschen einer Charge im Admin-Bereich prueft also gegen + /// diese Tabelle. + @@index([batchId]) @@map("order_items") } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 1ee0aa3..640f1ac 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -130,29 +130,37 @@ export default async function AdminDashboardPage() { _count: true, where: { status: { not: "STORNIERT" } }, }), - // Fuer Top-Produkte/Umsatz nach Kategorie: kein Prisma-groupBy ueber - // Relationen moeglich, deshalb Rohdaten laden und im Code aggregieren - // (bei der Groessenordnung dieses Shops unproblematisch). - prisma.orderItem.findMany({ + // Summiert wird in der DATENBANK, nicht hier. Frueher lud diese Abfrage + // JEDE Bestellposition, die es je gab, samt Produkt-Join in den Speicher, + // nur um daraus fuenf Zeilen "Top-Produkte" zu rechnen. "order_items" ist + // die am schnellsten wachsende Tabelle des Shops und wird nie + // aufgeraeumt: die Abfrage wurde mit jedem Verkauf teurer — auf der Seite, + // die im Admin-Bereich als erste geoeffnet wird. + // + // Gruppiert wird nach productId UND productName, weil productName ein + // Schnappschuss vom Bestellzeitpunkt ist: ein spaeter umbenanntes Produkt + // ergibt zwei Gruppen. Sie werden unten wieder nach productId + // zusammengefuehrt — genau wie bisher. Die Kategorie steht nicht an der + // Position und kommt deshalb aus einer zweiten, kurzen Abfrage (siehe + // unten); ueber eine Relation laesst sich nicht gruppieren. + prisma.orderItem.groupBy({ + by: ["productId", "productName"], where: { order: { status: { not: "STORNIERT" } } }, - select: { - productId: true, - productName: true, - quantity: true, - totalCents: true, - product: { select: { category: true } }, - }, + _sum: { quantity: true, totalCents: true }, }), prisma.order.findMany({ where: { createdAt: { gte: daysAgo(REVENUE_TREND_DAYS - 1) }, status: { not: "STORNIERT" } }, select: { createdAt: true, totalCents: true }, }), // Stammkunden: Kund:innen mit mehr als einer nicht-stornierten Bestellung. - // Kein Prisma-groupBy mit HAVING count > 1 in einer Abfrage moeglich, - // deshalb Rohdaten laden und im Code gruppieren. - prisma.order.findMany({ + // Zaehlen und filtern erledigt die Datenbank per HAVING; zurueck kommt + // eine Zeile je Stammkundin/Stammkunde. Frueher wurde hier JEDE Bestellung + // geladen, die es je gab, nur um im Speicher eine einzige Zahl daraus zu + // machen — mit der Begruendung, Prisma koenne kein HAVING. Es kann. + prisma.order.groupBy({ + by: ["customerId"], where: { customerId: { not: null }, status: { not: "STORNIERT" } }, - select: { customerId: true }, + having: { customerId: { _count: { gt: 1 } } }, }), prisma.discountCode.findMany({ where: { usedCount: { gt: 0 } }, @@ -161,12 +169,24 @@ export default async function AdminDashboardPage() { }), ]); - const ordersPerCustomer = new Map(); - for (const order of repeatCustomerGroups) { - if (!order.customerId) continue; - ordersPerCustomer.set(order.customerId, (ordersPerCustomer.get(order.customerId) ?? 0) + 1); - } - const repeatCustomerCount = [...ordersPerCustomer.values()].filter((count) => count > 1).length; + // Eine Zeile je Stammkundschaft — das HAVING oben hat bereits gefiltert. + const repeatCustomerCount = repeatCustomerGroups.length; + + // Die Kategorie haengt am Produkt, nicht an der Bestellposition, und laesst + // sich deshalb nicht mitgruppieren. Diese Nachfrage betrifft nur die + // tatsaechlich verkauften Produkte — eine Handvoll Zeilen ueber den + // Primaerschluessel, nicht die Bestellhistorie. Ein Produkt mit + // Bestellpositionen kann nicht geloescht werden (onDelete: Restrict), es + // gibt hier also zu jeder ID auch eine Zeile. + const soldProductIds = [...new Set(soldItems.map((group) => group.productId))]; + const soldProducts = + soldProductIds.length > 0 + ? await prisma.product.findMany({ + where: { id: { in: soldProductIds } }, + select: { id: true, category: true }, + }) + : []; + const categoryByProductId = new Map(soldProducts.map((product) => [product.id, product.category])); const statLabels: Record = { revenueToday: t.adminDashboardPage.statRevenueToday, @@ -194,20 +214,26 @@ export default async function AdminDashboardPage() { const productTotals = new Map(); const categoryTotals = new Map(); - for (const item of soldItems) { - const product = productTotals.get(item.productId) ?? { - name: item.productName, + for (const group of soldItems) { + // Ein umbenanntes Produkt kommt als zwei Gruppen an (productName ist ein + // Schnappschuss) — hier wieder zu einer Zeile zusammengefuehrt. Der zuerst + // gesehene Name gewinnt, wie zuvor. + const quantity = group._sum.quantity ?? 0; + const revenueCents = group._sum.totalCents ?? 0; + + const product = productTotals.get(group.productId) ?? { + name: group.productName, quantity: 0, revenueCents: 0, }; - product.quantity += item.quantity; - product.revenueCents += item.totalCents; - productTotals.set(item.productId, product); + product.quantity += quantity; + product.revenueCents += revenueCents; + productTotals.set(group.productId, product); - const category = item.product?.category ?? "HONIG"; + const category = categoryByProductId.get(group.productId) ?? "HONIG"; const categoryTotal = categoryTotals.get(category) ?? { quantity: 0, revenueCents: 0 }; - categoryTotal.quantity += item.quantity; - categoryTotal.revenueCents += item.totalCents; + categoryTotal.quantity += quantity; + categoryTotal.revenueCents += revenueCents; categoryTotals.set(category, categoryTotal); } diff --git a/src/app/api/auth/entra/callback/route.ts b/src/app/api/auth/entra/callback/route.ts index 751fdcd..5b34fd1 100644 --- a/src/app/api/auth/entra/callback/route.ts +++ b/src/app/api/auth/entra/callback/route.ts @@ -2,7 +2,14 @@ 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 { + createSession, + hashPassword, + getEntraOAuthCookie, + clearEntraOAuthCookie, + setTwoFactorChallengeCookie, +} from "@/lib/auth"; +import { signTwoFactorChallenge } from "@/lib/session"; import { isEntraConfigured, exchangeEntraAuthorizationCode, @@ -135,11 +142,28 @@ export async function GET(request: Request) { }); } - // 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. + // 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)); }