### Security- und Plausibilitäts-Review: Kundenportal & Authentifizierung Hier ist das detaillierte Sicherheits-, Architektur- und Plausibilitäts-Review für das Kundenportal von **webkulisse**. Das Review analysiert den Ist-Zustand (Authentifizierung auf Basis von Better-Auth und TanStack Start / React Router) und liefert konkrete Handlungsempfehlungen, damit künftige Module (**Rechnungsverwaltung** und **Produktabnahmen**) modular, mandantensicher und ohne Spaghetti-Code integriert werden können. --- ### 1. Priorisierte Findings & Sicherheitsrisiken --- #### 🔴 KRITISCH: Fehlendes Mandanten- & Autorisierungskonzept für Multi-Kunden-Daten (IDOR-Gefahr) * **Dateiverweis:** `src/db/schema.ts`, `src/lib/auth.ts`, `src/lib/session.ts` * **Problem & Risiko:** Das aktuelle Schema speichert lediglich flache `user`-Einträge mit einer einfachen Rolle (`role: 'user' | 'admin'`). Es gibt keine Mandanten- bzw. Kundenentität (`Organization`, `Customer` oder `Tenant`). Wenn Rechnungen und Abnahmen direkt auf `userId` referenzieren: 1. Kann ein Kunde nicht mehrere Mitarbeiter/Ansprechpartner haben (typischer B2B-Anwendungsfall: Inhaber + Buchhaltung). 2. Besteht ohne standardisierte Mandanten-Middleware bei kommenden Server-Funktionen ein extremes Risiko für **IDOR (Insecure Direct Object Reference)** – z. B. wenn ein Kunde über manipulierte IDs Rechnungen oder Abnahmen anderer Kunden abrufen oder freigeben kann. * **Lösungsempfehlung:** Einführung einer expliziten `customer`/`organization`-Tabelle und einer typsicheren Server-Function-Middleware, die bei jedem Datenzugriff den aktuellen Mandantenkontext prüft. ```ts // src/db/schema.ts - Empfohlenes Schema-Fundament export const customer = pgTable('customer', { id: text('id').primaryKey(), name: text('name').notNull(), // Firmenname z. B. 'Praxis Dr. Schmidt' customerNumber: text('customer_number').notNull().unique(), // z. B. KD-2026-001 status: text('status').notNull().default('active'), createdAt: timestamp('created_at').notNull().defaultNow(), }) export const user = pgTable('user', { // ... bestehende Better-Auth Felder customerId: text('customer_id').references(() => customer.id, { onDelete: 'cascade' }), }) ``` --- #### 🔴 KRITISCH: Devtools und sensible Debugger-Panels im Produktions-Bundle * **Dateiverweis:** `src/routes/__root.tsx` (Zeilen 94–104), `vite.config.ts` (Zeile 14) * **Problem & Risiko:** `` und `` werden im `RootDocument` bedingungslos gerendert. Dadurch werden Router-Zustände, Context-Daten und interne Routing-Strukturen im Browser für jeden Besucher sichtbar. * **Lösungsempfehlung:** Devtools nur in der Entwicklungsumgebung laden und im Build strippen: ```tsx // src/routes/__root.tsx {process.env.NODE_ENV !== 'production' ? ( }]} /> ) : null} ``` --- #### 🟠 HOCH: Kein Self-Service-Passwort-Reset / Unsichere initiale Passwort-Weitergabe * **Dateiverweis:** `src/routes/_admin/admin.users.new.tsx` (Zeilen 86–101), `src/routes/login.tsx` * **Problem & Risiko:** 1. Beim Erstellen eines Kunden (`admin.users.new.tsx`) muss der Admin ein Klartext-Passwort vergeben, welches als normales Input-Feld (`type="text"`) sichtbar ist. 2. Admins müssen dieses Initialpasswort unverschlüsselt (per Mail/Chat) an Kunden weitergeben. 3. Es gibt im Kunden-Login keinen "Passwort vergessen?"-Flow (`/reset-password`). 4. Kunden haben keine Möglichkeit, ihr Passwort nach dem Erst-Login selbstständig zu ändern. * **Lösungsempfehlung:** 1. **Einladungs-Link / Magic-Link Flow:** Statt manueller Passwörter generiert der Admin einen zeitlich befristeten Aktivierungslink via Better-Auth Invitation/Reset-Token. 2. Implementierung eines standardisierten `Passwort vergessen`-Flows im `login.tsx`. --- #### 🟠 HOCH: In-Memory Rate Limiting bei verteilten Deployments * **Dateiverweis:** `src/lib/auth.ts` (Zeilen 39–50) * **Problem & Risiko:** Das Rate Limiting ist auf `storage: 'memory'` konfiguriert: ```ts rateLimit: { enabled: true, storage: 'memory', customRules: { '/sign-in/email': { window: 60, max: 5 }, // ... } } ``` In Serverless-, Container- oder Node-Cluster-Umgebungen (z. B. Nitro / Cloudflare / Node Multi-Worker) teilt sich jeder Worker einen eigenen Speicher. Bei Server-Neustarts oder mehreren Instanzen ist der Brute-Force-Schutz wirkungslos oder leicht zu umgehen. * **Lösungsempfehlung:** Anbindung eines persistenten Speichers (z. B. Redis / Upstash / Postgres-basiertes Rate Limiting) oder Absicherung über Cloudflare WAF / Reverse Proxy. --- #### 🟡 MITTEL: Client-seitiges Data-Fetching mit Wasserfällen im Admin- & Kundenbereich * **Dateiverweis:** `src/routes/_admin/admin.index.tsx` (Zeilen 25–40), `src/routes/_admin/admin.users.$userId.tsx` (Zeilen 35–62) * **Problem & Architektur-Defizit:** Daten werden per `useEffect` und lokalem `useState` clientseitig nachgeladen ("Lade Kunden ..."). * Dies widerspricht dem Konzept von TanStack Start / Router (Loader-first SSR). * Führt zu Layout-Shifts, fehlenden SSR-Vorteilen und erschwert Caching, Revalidierung und granulare Fehlergrenzen (`CatchBoundary`). * **Lösungsempfehlung:** Nutzung von `loader` in Kombination mit Server Functions (`createServerFn`): ```tsx // src/routes/_admin/admin.index.tsx export const Route = createFileRoute('/_admin/admin/')({ loader: async () => await listUsersFn(), component: AdminUsersPage, }) function AdminUsersPage() { const users = Route.useLoaderData() // Kein useEffect / manual loading state nötig } ``` --- #### 🟡 MITTEL: Fehlende Sicherheits-Header (CSP, HSTS, X-Frame-Options) * **Dateiverweis:** `vite.config.ts`, `src/routes/__root.tsx` * **Problem & Risiko:** Es sind keine HTTP-Security-Header konfiguriert. Im `__root.tsx` wird ein Inline-Script (`themeItnitScript`) ohne Nonce ausgeführt. * **Lösungsempfehlung:** In Nitro/Vite oder im Webserver (Nginx/Cloudflare) Header setzen: * `Content-Security-Policy` (CSP) * `X-Frame-Options: DENY` (Schutz vor Clickjacking) * `X-Content-Type-Options: nosniff` * `Referrer-Policy: strict-origin-when-cross-origin` * `Strict-Transport-Security: max-age=31536000; includeSubDomains` --- #### 🟢 NIEDRIG: Fehlende Domain-Zentralisierung & Redundante Rollenprüfungen * **Dateiverweis:** `src/components/header.tsx` (Zeilen 22–26), `src/routes/login.tsx` (Zeilen 10, 47), `src/components/app-shell.tsx` (Zeile 58) * **Problem:** Die Weiterleitungs- und Rollenlogik `user.role === 'admin' ? '/admin' : '/dashboard'` ist redundant über mehrere Komponenten verstreut. * **Lösungsempfehlung:** Zentralisierung in einem Auth-Helper, z. B. `getDefaultRedirect(session)` in `src/lib/auth-utils.ts`. --- ### 2. Konkreter Architektur-Blueprint für Rechnungen & Abnahmen Um **Spaghetti-Code** und spätere Umbauten an der Auth-Basis zu vermeiden, sollte die Codebasis jetzt modular strukturiert werden. #### A. Empfohlene Feature-basierte Verzeichnisstruktur ```text src/ ├── db/ │ ├── schema/ │ │ ├── auth.ts # user, session, account, verification │ │ ├── customers.ts # customer / tenant Stammdaten │ │ ├── projects.ts # Webseiten-Projekte │ │ ├── invoices.ts # Rechnungen, Posten, Zahlungsstatus │ │ └── approvals.ts # Produktabnahmen, Feedback-Runden │ └── index.ts # Drizzle DB Instance & Schema-Export ├── server/ │ ├── middleware/ │ │ ├── auth.ts # Authentifizierungs-Guard │ │ └── tenant.ts # Mandanten-Isolation (Tenant Context) │ └── services/ # Pure Business Logic (unabhängig von UI) │ ├── invoice.service.ts │ └── approval.service.ts ├── routes/ │ ├── _authenticated/ │ │ ├── route.tsx # Auth Guard & Portal Layout │ │ ├── dashboard.tsx # Kunden-Übersicht │ │ ├── rechnungen/ # Rechnungsmodul (Kunde) │ │ │ ├── index.tsx │ │ │ └── $invoiceId.tsx │ │ └── abnahmen/ # Produktabnahmen (Kunde) │ │ ├── index.tsx │ │ └── $approvalId.tsx │ └── _admin/ │ ├── admin.tsx # Admin Layout │ ├── rechnungen/ # Rechnungsverwaltung (Admin) │ └── kunden/ # Kundenverwaltung ``` --- #### B. Datenmodell-Erweiterung (`src/db/schema/`) ```ts // 1. Projekte (Klammer um Rechnungen und Abnahmen) export const project = pgTable('project', { id: text('id').primaryKey(), customerId: text('customer_id').notNull().references(() => customer.id, { onDelete: 'cascade' }), title: text('title').notNull(), status: text('status').notNull().default('in_progress'), // 'in_progress' | 'review' | 'live' stagingUrl: text('staging_url'), createdAt: timestamp('created_at').notNull().defaultNow(), }) // 2. Rechnungsmodul export const invoice = pgTable('invoice', { id: text('id').primaryKey(), customerId: text('customer_id').notNull().references(() => customer.id, { onDelete: 'cascade' }), projectId: text('project_id').references(() => project.id, { onDelete: 'set null' }), invoiceNumber: text('invoice_number').notNull().unique(), // z. B. RE-2026-0042 amountGrossInCents: integer('amount_gross_in_cents').notNull(), taxRate: numeric('tax_rate').notNull().default('19.00'), status: text('status').notNull().default('open'), // 'open' | 'paid' | 'overdue' | 'cancelled' pdfStoragePath: text('pdf_storage_path').notNull(), dueDate: timestamp('due_date').notNull(), createdAt: timestamp('created_at').notNull().defaultNow(), }) // 3. Produktabnahme-Modul export const approval = pgTable('approval', { id: text('id').primaryKey(), projectId: text('project_id').notNull().references(() => project.id, { onDelete: 'cascade' }), customerId: text('customer_id').notNull().references(() => customer.id, { onDelete: 'cascade' }), version: text('version').notNull(), // z. B. 'v1.0 - Design-Entwurf' status: text('status').notNull().default('pending'), // 'pending' | 'approved' | 'changes_requested' feedback: text('feedback'), approvedAt: timestamp('approved_at'), approvedByUserId: text('approved_by_user_id').references(() => user.id), createdAt: timestamp('created_at').notNull().defaultNow(), }) ``` --- #### C. Sichere Server-Function-Middleware (Mandanten-Isolation) Mit TanStack Start Middleware wird sichergestellt, dass jede Abfrage automatisch auf den Mandanten des eingeloggten Benutzers eingeschränkt ist: ```ts // src/server/middleware/tenant.ts import { createMiddleware } from '@tanstack/react-start' import { getRequest } from '@tanstack/react-start/server' import { auth } from '#/lib/auth.ts' export const tenantAuthMiddleware = createMiddleware().server(async ({ next }) => { const request = getRequest() const session = await auth.api.getSession({ headers: request.headers }) if (!session || !session.user) { throw new Error('UNAUTHORIZED') } return next({ context: { session, userId: session.user.id, role: session.user.role, // Bei Kunden wird die customerId erzwungen; Admins dürfen filtern customerId: (session.user as any).customerId as string | null, isAdmin: session.user.role === 'admin', }, }) }) ``` **Verwendung in Server Functions (IDOR-sicher):** ```ts // src/server/functions/invoices.ts import { createServerFn } from '@tanstack/react-start' import { tenantAuthMiddleware } from '../middleware/tenant' import { db } from '#/db/index.ts' import { invoice } from '#/db/schema.ts' import { eq, and } from 'drizzle-orm' export const getInvoicesForCurrentCustomer = createServerFn({ method: 'GET' }) .middleware([tenantAuthMiddleware]) .handler(async ({ context }) => { if (!context.customerId && !context.isAdmin) { return [] } // Automatisch auf den Mandanten des eingeloggten Nutzers begrenzt! return await db.query.invoice.findMany({ where: context.isAdmin ? undefined : eq(invoice.customerId, context.customerId!), orderBy: (inv, { desc }) => [desc(inv.createdAt)], }) }) ``` --- ### 3. Zusammenfassung des Handlungsbedarfs | Priorität | Bereich | Maßnahme | | :--- | :--- | :--- | | **Kritisch** | Architektur | `Customer`/`Tenant`-Entität im DB-Schema ergänzen & `tenantAuthMiddleware` etablieren. | | **Kritisch** | Sicherheit | Devtools in `__root.tsx` hinter `process.env.NODE_ENV !== 'production'` kapseln. | | **Hoch** | Auth / UX | Passwort-Reset-Flow & Einladungs-Link für Kunden einrichten (Klartext-Passwortvergabe im Admin ablösen). | | **Mittel** | Performance / Code | Admin- und Kunden-Views von `useEffect`-Fetching auf TanStack Route Loader umstellen. | | **Mittel** | Sicherheit | HTTP-Sicherheitsheader (CSP, HSTS, X-Frame-Options) konfigurieren. | Mit dieser Vorbereitung ist die Basis optimal gehärtet und Rechnungs- sowie Abnahme-Features können sauber als eigenständige Domain-Module angedockt werden.