#41 (critical): Replace plain Error throws in authorize() with CredentialsSignin
subclasses (MfaRequiredError / MfaRequiredSetupError / InvalidTotpError).
Auth.js v5 forwards CredentialsSignin.code to the client via SignInResponse.code;
plain throws become CallbackRouteError and the message is never visible.
Signin page now checks result.code ?? result.error for exact code matching.
#38: MfaPromptBanner converted to fully client-side component via
trpc.user.getMfaStatus.useQuery() — disappears immediately after MFA enable
without requiring page reload. Snooze key remains userId-scoped via useSession().
Server-side prisma.user.findUnique call removed from (app)/layout.tsx.
#40: NEXTAUTH_URL default fallback removed from docker-compose.yml.
The variable is now required (:?) — docker compose up fails with a descriptive
error if the value is missing, preventing silent localhost redirect bugs.
Tests: auth.test.ts (5), MfaPromptBanner.test.ts (7), reset-password.test.ts (6)
All new tests green. pnpm --filter @capakraken/web exec tsc --noEmit clean.
Co-Authored-By: claude-flow <ruv@ruv.net>
8 offene Security-Tickets aus dem OWASP-Audit. Pro Ticket: Implementation + Unit-Tests (happy path + negative/edge cases) + E2E-Tests wo sinnvoll.
Nach der Implementierung der MFA-Tickets (#28–#35) wurden 5 Folgefehler identifiziert, die den Login-Flow für MFA-Nutzer vollständig blockieren. Betroffen sind `apps/web` und `packages/api`. Kein Prisma-Schema-Change nötig.
**Reihenfolge nach Risiko und Aufwand:**
**Kritischer Pfad:**#41 muss zuerst rein — solange Auth.js v5 alle `throw`s aus `authorize()` als `CallbackRouteError` wrappt, ist der MFA-Login für jeden Nutzer gebrochen. Alle anderen Bugs sind unabhängig davon lösbar, aber #41 ist Voraussetzung für testbare Akzeptanzkriterien von #38 und #39.
| Ticket | Thema | Severity | Aufwand |
|--------|-------|----------|---------|
| #28 | TOTP `verifyTotp` Rate Limiting | High | S |
| #29 | `/api/reports/allocations` Rollencheck | Medium | S |
#### Block A — #41: Auth.js v5 MFA-Signal-Fix (KRITISCH)
### Akzeptanzkriterien
- [ ]**Task A1:** Custom Error-Klassen in `apps/web/src/server/auth.ts` definieren.
Importiere `CredentialsSignin` aus `"next-auth"` und erstelle 3 Subklassen direkt in der Datei:
```typescript
class MfaRequiredError extends CredentialsSignin {
code = "MFA_REQUIRED" as const;
}
class MfaRequiredSetupError extends CredentialsSignin {
code = "MFA_REQUIRED_SETUP" as const;
}
class InvalidTotpError extends CredentialsSignin {
code = "INVALID_TOTP" as const;
}
```
Ersetze alle 3 `throw new Error(...)` in `authorize()`:
- `throw new Error("MFA_REQUIRED:...")` → `throw new MfaRequiredError()`
- `throw new Error("MFA_REQUIRED_SETUP:...")` → `throw new MfaRequiredSetupError()`
- `throw new Error("INVALID_TOTP")` → `throw new InvalidTotpError()`
- `throw new Error("Too many login attempts...")` → bleibt als normaler `throw` (nicht durch MFA verursacht, `return null` wäre die sauberere Variante — aber Audit-Eintrag muss davor kommen)
→ Datei: `apps/web/src/server/auth.ts`
- [ ] Neuer Admin-User sieht nach Login das Default-Dashboard (stat-cards)
- [ ] Modal zeigt alle 11 Widgets an
- [ ] Gewählter Widget erscheint sofort auf dem Dashboard
- [ ] Nach Page-Reload ist der Widget weiterhin sichtbar
- [ ] Cross-User-Bleed: User B erbt nicht das Layout von User A
Rate-Limit-Fall: `return null` statt `throw` macht in `authorize()` aus `.includes("Too many")`-Prüfung ein separates Problem — hier stattdessen im `!isValid`-Pfad bleiben und für Rate-Limit `result.error` = `"CredentialsSignin"` annehmen (der Nutzer sieht keinen spezifischen Text mehr, was ok ist — Rate-Limit-Feedback ist absichtlich vage).
→ Datei: `apps/web/src/app/auth/signin/page.tsx`
- [ ] **Task A3:** Unit-Test für Auth-Fehler-Signale.
Teste `authorize()` direkt mit einem gemockten prisma: MFA-aktivierter User ohne `totp`-Feld → wirft `MfaRequiredError`. Falsches TOTP → wirft `InvalidTotpError`. Richtiger TOTP-Code → gibt User-Objekt zurück.
→ Datei: `apps/web/src/server/auth.test.ts` (neu)
---
## Plan: In-Review-Tickets verifizieren und in main integrieren
#### Block B — #38: MFA-Banner als Client-Component
Statt `userId`-Prop: eigene tRPC-Query `trpc.user.getMfaStatus.useQuery()`. Der Banner rendert sich selbst unsichtbar (`return null`) solange der Query lädt oder `totpEnabled === true`. Kein `userId`-Prop mehr nötig.
8 Tickets sind `in-review`. Ziel: jeden Ticket-Scope im Code verifizieren, Gitea-Kommentar
mit Bewertung (✅ akzeptiert / ⚠️ Nachbesserung nötig) hinterlassen, dann alle offenen
Changes in einem sauberen Commit auf `main` integrieren.
- [ ] **Task B2:** `(app)/layout.tsx` bereinigen.
Den `prisma.user.findUnique`-Call für `showMfaPrompt` entfernen. Den `prisma`-Import entfernen. `MfaPromptBanner` ohne Props rendern — immer eingebunden für `MFA_PROMPT_ROLES` (oder direkt bedingungslos, da der Banner selbst den Status kennt).
→ Datei: `apps/web/src/app/(app)/layout.tsx`
**Umfang der Verifikation pro Ticket:**
- Code-Artefakte im Repo prüfen (Dateien, Tests)
- Quality-Gates (tsc, test:unit) müssen grün sein
- Akzeptanzkriterien aus dem Ticket-Body abgleichen
- Gitea-Kommentar: Strukturiertes Urteil mit Befunden
---
**Integration-Strategie:**
- Alle ungestagten Änderungen (`git status`) gehören zu den Tickets #20, #22, #25, #26
- Ein einziger Commit auf `main` mit klarer Commit-Message
- plan.md und docs/ mitcommiten
#### Block C — #39: Dashboard-Widgets nach MFA-Aktivierung (Investigation)
### Betroffene Pakete & Dateien
- [ ] **Task C1:** Nach #41-Fix: Login mit MFA-Nutzer testen und prüfen, ob Dashboard-Widgets laden.
Hypothese: Die Widgets schlugen fehl weil die Session nach MFA-Aktivierung als ungültig galt (der active-session-Check in `auth.ts` könnte nach einem tRPC-Call eine veraltete Session sehen). Nach #41-Fix und erneutem Login sollte die Session gültig sein.
Falls das Problem dann noch besteht: Untersuche ob `enableTotp` die laufende Session invalidiert (fehlende Session-Neuausstellung nach MFA-Aktivierung).
→ Kein Code-Change bis C1-Prüfung abgeschlossen. Falls nötig: `user.enableTotp` gibt nach Erfolg `{ requireRelogin: true }` zurück und das Frontend leitet auf `/auth/signin` weiter.
- [ ]**Q-2:**`pnpm --filter @capakraken/api test:unit` — alle Tests grün
- [ ]**Q-3:**`pnpm --filter @capakraken/web test:unit` — alle Tests grün
#### Block E — #42: Admin-Password-Reset-Mutation
**Phase 3 — Git-Integration**
- [ ] **Task E1:** `resetUserPassword`-Funktion in `user-procedure-support.ts` hinzufügen.
Die vorhandene `setUserPassword` setzt das Passwort beim User-Create. Eine neue Funktion `resetUserPassword(ctx, input: { userId, newPassword })` hasht `newPassword` mit argon2id und schreibt `passwordHash`. Nur erreichbar via `adminProcedure`.
- [ ] **Task E4:** Admin-UI — Password-Reset-Button in der User-Verwaltung.
Im Admin-Bereich (vermutlich `apps/web/src/app/(app)/admin/users/`) Button "Reset Password" mit einem Modal, das ein neues Passwort verlangt (min. 8 Zeichen). Kein separates Ticket nötig — gehört zu #42.
→ Datei: Admin-User-List-Page (Pfad nach aktuellem Stand ermitteln)
---
### Abhängigkeiten
- V-1 bis V-8 sind unabhängig voneinander, aber sequenziell (ein Kommentar pro Ticket)
- Q-1 bis Q-3 können nach allen V-Tasks parallel laufen
- G-1/G-2/G-3 müssen nach Q-1..3 erfolgen (kein Commit bei roten Tests)
```
A1 → A2 → A3 (Sequential: A2 braucht den finalen Code aus A1)
- [ ] Alle 8 in-review-Tickets haben einen Bewertungskommentar
- [ ]`pnpm test:unit` läuft grün (beide Pakete)
- [ ]`tsc --noEmit` ohne Fehler
- [ ] Alle neuen Dateien in einem sauberen Commit auf `main`
- [ ]`git status` danach: working tree clean
- [ ] `pnpm test:unit` läuft grün
- [ ] `pnpm --filter @capakraken/web exec tsc --noEmit` — keine neuen Fehler
- [ ] Login mit `hn@hartmut-noerenberg.com` + `Admin12345!` zeigt TOTP-Eingabe (nicht "Invalid email or password")
- [ ] Korrekter TOTP-Code → erfolgreicher Login
- [ ] Nach MFA-Aktivierung via UI verschwindet der Banner sofort ohne Reload
- [ ] Nach Logout von `capakraken.hartmut-noerenberg.com` landet man auf der Login-Seite der gleichen Domain
- [ ] `docker compose up` ohne `NEXTAUTH_URL` schlägt mit lesebarer Fehlermeldung fehl
- [ ] Admin kann Passwort eines anderen Users über die UI zurücksetzen
---
### Risiken & offene Fragen
- **Ticket #23 (Active-Session):** Die Implementierung in früheren Sessions könnte durch spätere Auth-Fixes (jti→sid) überholt worden sein — genau prüfen
- **Ticket #24 vs. #25:** Überlappen in `docker-compose.yml` — beide Tickets betreffen dieselbe Datei; ein Commit deckt beide ab
- **Push auf main:** Direkt auf `main` — kein PR da kein Remote-Review-Prozess konfiguriert. Sicherstellen dass alle Tests grün sind
1. **Auth.js beta.25 vs beta.30 `result.error` vs `result.code`:** Die genaue Rückgabestruktur von `signIn({ redirect: false })` variiert zwischen beta-Versionen. Task A2 muss nach A1 kurz manuell verifiziert werden — ggf. beide Felder (`result.error` UND `result.code`) prüfen.
---
2. **#39 Dashboard-Bug:** Ursache noch unklar. Nach #41-Fix ist zu prüfen, ob der Fehler von selbst verschwindet (Session-Neustart beim Re-Login). Falls nicht: Session-Re-Issue nach `enableTotp` nötig (Breaking Change in der MFA-Setup-UX).
---
## Ticket #25 — Docker/Env/Migration-Strategie
### Anforderungsanalyse
Ziel: Docker-Container-Lifecycle ohne manuelle Eingriffe.
Konkrete Mängel:
1.`REDIS_URL` in `docker-compose.yml` nutzt `${REDIS_URL:-redis://redis:6379}` — Host-Env-Var kann Docker-internen Wert überschreiben (gleiche Klasse wie das behobene `DATABASE_URL`-Problem)
2. Migration-Strategy undokumentiert: DB per `db push` aufgebaut, dann Migration hinzugefügt → `migrate deploy` scheitert mit P3005, erforderte `migrate resolve --applied`
3. Kein Developer-Runbook (Setup, Restart, DB-Ops fehlen)
### Betroffene Dateien
| Datei | Änderung |
|---|---|
| `docker-compose.yml` | `REDIS_URL` hardcoden |
| `docs/developer-runbook.md` | create |
### Task-Liste
- [ ]**#25-T1:** `docker-compose.yml` — `REDIS_URL: redis://redis:6379` (Literal, kein `${}`)
- [ ]**#26-T4:** Viewer-Describe-Block (4 Tests: alle → FORBIDDEN)
- [ ]**#26-T5:** Smoke-Run: `pnpm exec playwright test e2e/dev-system/rbac-data-access.spec.ts --config=playwright.dev.config.ts`
### Risiken
-`allocation.listView` könnte ein Pflicht-Input-Objekt erfordern → Falls `BAD_REQUEST` statt FORBIDDEN, Schema im Router prüfen und minimalen Input übergeben
- Viewer-Permissions aus DB-Seed (SystemRoleConfig) — prüfen ob VIEWER tatsächlich kein `VIEW_PLANNING` hat
---
---
## Anforderungsanalyse
Alle 6 Issues stammen aus einem Security-Audit und einem Plattform-Review.
5 davon sind Security-Findings (OWASP A02/A05/A07/A09), 1 ist ein Plattform-Thema (Docker-Reproduzierbarkeit).
Die Security-Issues sind weitgehend unabhängig voneinander; lediglich #23 greift in den tRPC-Request-Pfad ein und sollte zuletzt umgesetzt werden, da es den Haupt-Auth-Pfad berührt.
Gefundene Violations entweder beheben (Move to external file / Nonce) oder als Known Exception dokumentieren.
---
### Issue #23 — Active-Session-Registry bei jedem Request prüfen
- [ ]**Task 11:** Im tRPC-Route-Handler (`apps/web/src/app/api/trpc/[trpc]/route.ts`) nach dem `auth()`-Call die `jti` aus dem Session-Token lesen und gegen `prisma.activeSession` validieren:
```ts
const jti = session?.user?.jti as string | undefined;
if (jti) {
const active = await prisma.activeSession.findUnique({ where: { jti } });
- #20 (Tasks 6–8) || #22 (Tasks 9–10) — erst nachdem #21/#19 fertig sind (separater Branch)
- #23 (Tasks 11–12) — zuletzt, da Auth-Pfad berührt
```
---
## Akzeptanzkriterien
- [ ] `pnpm test:unit` läuft grün
- [ ] `pnpm --filter @capakraken/web exec tsc --noEmit` — keine neuen Errors
- [ ] `pnpm lint` — sauber
- [ ] **#21:** `/api/perf` ohne `CRON_SECRET` gibt 401/403, Query-Token wird nicht mehr akzeptiert
- [ ] **#19:** `MfaSetup` macht keinen Request an `qrserver.com` oder `chart.googleapis.com`
- [ ] **#20:** Webhook-Save und Dispatch für `http://127.0.0.1/…` gibt `BAD_REQUEST`
- [ ] **#22:** Browser-DevTools zeigt keine CSP-Violations für Haupt-User-Flows in Production-Mode
- [ ] **#23:** Request mit gelöschter `ActiveSession`-`jti` gibt 401
- [ ] **#24:** `docker compose up --build` auf sauberem Checkout bootet die App ohne Host-Abhängigkeiten
---
## Risiken & offene Fragen
| Risiko | Einschätzung | Maßnahme |
|--------|-------------|----------|
| CSP-Nonces in Next.js 15: `<Script>` und Tailwind CSS benötigen ggf. Nonces | Mittel | Vor Task 9 in Next.js-15-Doku recherchieren; ggf. nur `unsafe-eval` entfernen als erster Schritt |
| Session-Registry-Check (#23) erhöht DB-Load: jeder tRPC-Request = 1 DB-Read | Mittel | Redis-Cache mit kurzer TTL (30s) als Opt-in; erst messen ob nötig |
| SSRF-Guard DNS-Lookup: async, könnte Race-Condition durch DNS-Rebinding haben | Niedrig | Nach DNS-Lookup Socket-Verbindung ebenfalls gegen IP prüfen (defense-in-depth) |
| Docker #24: `node_modules`-Volume-Semantik bei pnpm-Workspaces komplex | Mittel | Symlink-Struktur von pnpm in Container testen; ggf. `--shamefully-hoist` Flag |
| `jti` im Session-Token: Auth.js-Version muss `jti` ins JWT schreiben | Offen | In `auth.ts` prüfen ob `token.jti` tatsächlich im JWT-Callback persistiert wird |
3. **MFA_REQUIRED enthält `userId` im aktuellen throw:** Die userId war im `"MFA_REQUIRED:userId"`-String für eine geplante Re-Auth-Mechanik. Mit `CredentialsSignin` entfällt dieses Encoding — die userId wird in der TOTP-Verifikation direkt aus der Session gelesen (was korrekt ist, da die Session beim ersten erfolgreichen Password-Check bereits existiert). Kein Datenverlust.
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.