### 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.