Files
CapaKraken/plan.md
T
Hartmut e5ecea81c5 fix(auth): resolve MFA post-activation login failures — tickets #38 #40 #41
#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>
2026-04-02 00:20:47 +02:00

176 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CapaKraken — Umsetzungsplan
Gitea-Repo: `https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY`
Stand: 2026-04-02 | Issues: #38#42 (MFA Post-Activation Bugs)
---
## Plan: MFA Post-Activation Bugs #38#42
### Anforderungsanalyse
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.
**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.
---
### Betroffene Pakete & Dateien
| Paket | Datei | Art |
|-------|-------|-----|
| `apps/web` | `src/server/auth.ts` | edit — Custom `CredentialsSignin`-Subklassen |
| `apps/web` | `src/app/auth/signin/page.tsx` | edit — Error-Code-Prüfung anpassen |
| `apps/web` | `src/app/(app)/layout.tsx` | edit — server-seitiges `showMfaPrompt` entfernen |
| `apps/web` | `src/components/security/MfaPromptBanner.tsx` | edit — client-seitig via tRPC |
| `apps/web` | `src/app/api/perf/route.test.ts` | edit — neuer Test für Auth-Fehler-Signale |
| `apps/web` | `docker-compose.yml` | edit — `NEXTAUTH_URL` required machen |
| `packages/api` | `src/router/user.ts` | edit — `resetUserPassword` Admin-Mutation |
| `packages/api` | `src/router/user-procedure-support.ts` | edit — `resetUserPassword`-Support-Fn |
| `packages/api` | `src/__tests__/reset-password.test.ts` | create — Unit-Tests |
---
### Task-Liste
#### Block A — #41: Auth.js v5 MFA-Signal-Fix (KRITISCH)
- [ ] **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`
- [ ] **Task A2:** Signin-Page: Error-Code-Prüfung anpassen.
In Auth.js v5 beta mit `redirect: false` liefert `signIn()` bei `CredentialsSignin`-Subklassen:
`result.error = "<code>"` (also `"MFA_REQUIRED"`, `"MFA_REQUIRED_SETUP"`, `"INVALID_TOTP"`) **oder** `result.error = "CredentialsSignin"` + `result.code = "<code>"` — je nach beta-Version.
**Strategie für beta.25:** Exakte string-Gleichheit statt `.includes()` verwenden:
```typescript
if (result.error === "MFA_REQUIRED_SETUP" || result.code === "MFA_REQUIRED_SETUP") { ... }
if (result.error === "MFA_REQUIRED" || result.code === "MFA_REQUIRED") { ... }
if (result.error === "INVALID_TOTP" || result.code === "INVALID_TOTP") { ... }
```
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)
---
#### Block B — #38: MFA-Banner als Client-Component
- [ ] **Task B1:** `MfaPromptBanner.tsx` zu vollständig client-seitigem Component umbauen.
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.
→ Datei: `apps/web/src/components/security/MfaPromptBanner.tsx`
- [ ] **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`
---
#### Block C — #39: Dashboard-Widgets nach MFA-Aktivierung (Investigation)
- [ ] **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.
---
#### Block D — #40: NEXTAUTH_URL Required
- [ ] **Task D1:** `NEXTAUTH_URL`-Fallback in `docker-compose.yml` entfernen.
```yaml
# vorher:
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3100}
# nachher:
NEXTAUTH_URL: ${NEXTAUTH_URL:?NEXTAUTH_URL must be set in .env}
```
→ Datei: `docker-compose.yml`
- [ ] **Task D2:** `.env.example` aktualisieren — `NEXTAUTH_URL=https://your-domain.com` dokumentieren.
→ Datei: `.env.example` (falls vorhanden, sonst erstellen)
---
#### Block E — #42: Admin-Password-Reset-Mutation
- [ ] **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`.
→ Datei: `packages/api/src/router/user-procedure-support.ts`
- [ ] **Task E2:** Mutation in `user.ts` registrieren.
```typescript
resetPassword: adminProcedure
.input(z.object({ userId: z.string(), newPassword: z.string().min(8) }))
.mutation(({ ctx, input }) => resetUserPassword(ctx, input)),
```
→ Datei: `packages/api/src/router/user.ts`
- [ ] **Task E3:** Unit-Test für `resetUserPassword`.
Happy path: Mutation updated `passwordHash`. Negative cases: non-admin schlägt fehl, leeres Passwort schlägt fehl.
→ Datei: `packages/api/src/__tests__/reset-password.test.ts`
- [ ] **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
```
A1 → A2 → A3 (Sequential: A2 braucht den finalen Code aus A1)
B1 → B2 (Sequential: B2 entfernt was B1 ersetzt)
A3, B2, D1, E3 (Können parallel laufen, sobald A1 fertig)
C1 nach A1+A2 (Test)
E4 nach E1+E2
```
**Reihenfolge:**
1. A1 (kritisch, zuerst — entsperrt alle anderen)
2. A2 (direkt danach, am selben Batch)
3. Parallel: B1+B2, D1+D2, E1+E2
4. A3, E3, E4
5. C1 (manuelle Verifikation nach Deploy)
---
### Akzeptanzkriterien
- [ ] `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
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).
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.