Files
tickte-system/docs/superpowers/specs/2026-09-30-mitarbeiter-2fa-und-wechsel-design.md
T

170 lines
10 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.
# Mitarbeiter-2FA per E-Mail-Code + Wechsel Ticketsystem ⇄ Projekt-Admin
Stand: 2026-09-30 · Status: Design vom Nutzer freigegeben (Teil 1 + Teil 2), Spec wartet auf Review
## Ziel
1. Jeder Mitarbeiter-Login (Ticketsystem ticket.webklar.com **und** Projekt-Admin project.webklar.com)
verlangt nach dem Passwort einen 6-stelligen Code, der per E-Mail kommt. Pflicht für alle
Mitarbeiter. Kunden bleiben unverändert.
2. Aus der Seitenleiste des Ticketsystems springt man ohne zweiten Login ins Projekt-Admin; aus dem
Projekt-Admin führt ein Button zurück ins Ticketsystem.
Entscheidungen des Nutzers: E-Mail-Code statt TOTP/KeePass; 2FA über Appwrite selbst (Variante A),
nicht als reine Oberflächen-Sperre; Pflicht für alle Mitarbeiter.
## Ausgangslage (geprüft)
- Beide Systeme nutzen dieselben Appwrite-Konten (Appwrite **1.8.1**, Projekt `6a1058610003c5a13a05`).
- Appwrite verschickt bereits Mails: `_APP_SMTP_HOST=mail.your-server.de:587`, Absender
`WEBklar <noreply@webklar.com>`.
- Appwrite-MFA (1.8.1): Ist `user.mfa = true` und die E-Mail bestätigt (`emailVerification = true`),
braucht jede Sitzung 2 Faktoren. Eine Passwort-Sitzung hat nur den Faktor `password`; jeder
Nicht-MFA-Aufruf liefert dann `401 user_more_factors_required`. Das gilt auch für **bereits
bestehende** Sitzungen und für JWTs aus solchen Sitzungen.
- `POST /v1/account/mfa/challenges` `{factor:'email'}` → `{ $id, expire }`, schickt die Mail
(Vorlage `mfaChallenge`). Code gilt **1 Stunde**. Limit 10 Anfragen pro Nutzer und Zeitfenster.
- `PUT /v1/account/mfa/challenges` `{challengeId, otp}` → Sitzung bekommt Faktor `email`.
Falscher/abgelaufener Code: `401 user_invalid_token`.
- Server-API mit Key: `PATCH /v1/users/:id/mfa {mfa}`, `PATCH /v1/users/:id/verification {emailVerification}`.
- Ticketsystem: Appwrite-Web-SDK **13.0.2** (kennt kein MFA). AuthContext meldet per
`createEmailPasswordSession` an und prüft mit `account.get()`.
- Projekt-Admin: `POST /api/auth/login` ruft serverseitig `/account/sessions/email` ohne API-Key auf,
nimmt nur die `userId` und setzt die Portal-Sitzung (`role: 'admin'`) für Mitarbeiter
(`resolveStaffAccount`). **Mit MFA wäre das eine Umgehung**, weil die halbe Sitzung reicht.
- `requireAppwriteStaff` (JWT-Prüfung für `/api/admin/*` aus dem Ticketsystem) verlangt das
Label `admin`. Mitarbeiter ohne Admin-Label kommen per Portal-Login trotzdem ins Projekt-Admin
(`staff.isAdmin = false`).
- Einmal-Link-Mechanismus existiert: `services/staffPreviewTokens.js` (Map im Speicher, 60 s, einmalig),
genutzt von `/api/admin/preview-open` → `/api/auth/staff-preview`.
- Projekt-Admin (`public/admin.html`) hat keine Seitenleiste, sondern Kopfzeile + Tabs.
## Teil 1: Anmeldung mit E-Mail-Code
### Ablauf für Mitarbeiter
1. E-Mail + Passwort.
2. Anzeige „Wir haben dir einen Code an k…@webklar.com geschickt“ + 6-stelliges Codefeld
(`inputmode="numeric"`, `autocomplete="one-time-code"`), Link „Code erneut senden“, Link „Abbrechen“
(meldet die halbe Sitzung ab, zurück zur Passwort-Eingabe).
3. Richtiger Code → angemeldet. Falscher/abgelaufener Code → „Code falsch oder abgelaufen“, Feld bleibt.
### Ticketsystem (Repo JUSN/tickte-system)
- **Kein SDK-Upgrade.** Neues Modul `src/lib/mfa.js` mit zwei Funktionen über `client.call(...)` des
vorhandenen SDK-Clients (nutzt dieselbe Sitzung/Cookie-Fallback wie das SDK):
`createEmailChallenge()` → `challengeId`; `completeChallenge(challengeId, otp)`.
Beide senden `X-Appwrite-Locale: de`, damit die deutsche Vorlage greift.
- `AuthContext`:
- neuer Zustand `mfaPending` (`{ challengeId, email }` oder `null`).
- `login()`: nach der Passwort-Sitzung `account.get()`; bei `user_more_factors_required` →
Challenge anlegen, `mfaPending` setzen, `{ success: false, mfaRequired: true }` zurück.
- `checkUser()` beim Laden: gleicher Fall (bestehende halbe Sitzung, z. B. direkt nach dem
Einschalten) → **keine** neue Mail automatisch, sondern Code-Schritt mit Knopf „Code senden“.
Grund: jedes Neuladen würde sonst eine Mail auslösen und das Limit (10) aufbrauchen.
- `verifyCode(otp)`, `resendCode()`, `cancelMfa()` (= `deleteSession('current')`).
- Nach erfolgreichem Code wie bisher `account.get()` + `ensureEmployeeExists`.
- `LoginPage.jsx`: zweiter Schritt „Code eingeben“; Routing: solange `mfaPending`, führt jede
geschützte Route auf `/login`.
- Fehlertexte: `user_invalid_token` → „Code falsch oder abgelaufen“; `429`/`general_rate_limit_exceeded`
→ „Zu viele Versuche, bitte einige Minuten warten“; `user_email_not_verified` →
„2FA ist für dein Konto noch nicht eingerichtet – bitte Kenso Bescheid geben“.
### Projekt-Admin (Repo Kundenportal, project.webklar.com)
- `loginWithAppwrite` liest zusätzlich die Sitzung aus der Antwort (`set-cookie`
`a_session_<projectId>` via `headers.getSetCookie()`), damit der Server im Namen dieser Sitzung
weiterarbeiten kann.
- `POST /api/auth/login` für **Mitarbeiter**: Ist `account.mfa === true` (aus `getUserById`), wird
**keine** Portal-Sitzung gesetzt. Stattdessen:
- Challenge über die Sitzung anlegen, Eintrag in einer Map im Speicher
`{ userId, email, appwriteSessionCookie, challengeId, expiresAt: +10 min, attempts: 0 }`
unter zufälligem Schlüssel; Schlüssel als signiertes, httpOnly-Cookie `webklar_portal_mfa`.
- Antwort `{ mfaRequired: true, email: <maskiert> }`.
- `POST /api/auth/login/mfa` `{ otp }`: prüft den Code bei Appwrite (`PUT /account/mfa/challenges`
mit der gespeicherten Sitzung). Erfolg → exakt der bisherige Mitarbeiter-Zweig (Portal-Sitzung +
Preview-Cookie), Map-Eintrag + Cookie löschen. Max. 5 Fehlversuche pro Eintrag, dann Eintrag weg
→ neu anmelden.
- `POST /api/auth/login/mfa/resend`: neue Challenge für denselben Eintrag.
- Serverneustart verwirft offene Einträge → Nutzer meldet sich neu an (akzeptiert).
- Kunden-Zweig bleibt unverändert (Kunden haben `mfa = false`).
- `public/login.html` + `app.js`: zweiter Schritt mit Codefeld, gleiche Texte wie im Ticketsystem.
### Einmalige Einrichtung (Skript, nach dem Livegang beider Teile)
- Skript `scripts/mitarbeiter-2fa.mjs` im Kundenportal-Repo (läuft im Container mit dem Server-Key):
- `--status`: listet alle Mitarbeiter (Collection `employees` → `userId`) mit `emailVerification`
und `mfa`.
- `--enable [userId|alle]`: setzt `emailVerification = true`, dann `mfa = true`.
- `--disable <userId>`: Notfall-Schalter, wenn bei einer Person keine Mail ankommt.
- Vor dem Einschalten wird die E-Mail-Adresse jedes Kontos angezeigt; nur Adressen, die
tatsächlich Post empfangen, werden bestätigt.
- Wirkung: Alle bestehenden Mitarbeiter-Sitzungen fragen beim nächsten Aufruf nach dem Code.
- Keine Wiederherstellungscodes (Faktor ist das Postfach; Notfall = `--disable`).
### Code-Mail
- Appwrite-Vorlage `mfaChallenge`, Sprache `de`, als eigene Vorlage im Projekt hinterlegt
(Betreff „Dein Anmeldecode für WEBklar“, Inhalt im Stil der WEBklar-Mailvorlagen: Logo, Code groß,
„gilt 1 Stunde“, „Nicht du? Dann ändere bitte dein Passwort.“).
- Wird über die Appwrite-Console gesetzt (Auth → Templates) – der Nutzer ist dort eingeloggt; ich
bereite HTML und Betreff vor.
## Teil 2: Wechsel zwischen Ticketsystem und Projekt-Admin
### Hinweg (Ticketsystem → Projekt-Admin, ohne zweiten Login)
- `Navbar.jsx`: neuer Eintrag **„Projekt-Admin“** mit Symbol `IconExternalLink` nach „Finanzen“ /
„Admin“, für alle Mitarbeiter sichtbar. Klick → `openProjectAdmin()` → `window.location.assign(openUrl)`
(selber Tab). Während des Ladens deaktiviert; bei Fehler Fallback auf
`https://project.webklar.com/login.html`.
- Kundenportal: `POST /api/admin/switch-link` (Bearer-JWT aus dem Ticketsystem):
- eigene Prüfung statt `requireAppwriteStaff`: JWT gültig (`/account`) **und** `resolveStaffAccount`
findet einen Mitarbeiter – so kommen auch Mitarbeiter ohne Admin-Label rüber, wie beim normalen
Login.
- Antwort `{ openUrl: 'https://project.webklar.com/api/auth/staff-switch?token=…' }`, Token über
`createStaffPreviewToken` (60 s, einmalig) mit `{ userId, email, name, isAdmin, purpose: 'switch' }`.
- `GET /api/auth/staff-switch?token=…`: `consumeStaffPreviewToken`; nur `purpose === 'switch'`
akzeptiert (und `staff-preview` akzeptiert umgekehrt keine Switch-Tokens). Setzt Portal-Sitzung +
Preview-Cookie wie der Mitarbeiter-Login, Redirect `/admin.html`. Ungültig/abgelaufen → Redirect
`/login.html?error=Link abgelaufen, bitte anmelden`.
- 2FA bleibt gewahrt: Das JWT stammt aus einer Sitzung, die nach Teil 1 nur mit Code voll gültig ist.
### Rückweg (Projekt-Admin → Ticketsystem)
- `admin.html` Kopfzeile: Button **„← Ticketsystem“** (Link `https://ticket.webklar.com/tickets`)
vor „Abmelden“. Nur in der Admin-Ansicht; im Modus „Als Kunde ansehen“ und bei Kunden nicht.
- Kein automatischer Login in diese Richtung: Ist die Ticketsystem-Sitzung noch aktiv, ist man direkt
drin, sonst Login mit Code.
## Tests
Mit nachgebautem Appwrite (Mock nach 1.8.1-Verhalten, keine echten Daten):
- Ticketsystem: Login ohne MFA unverändert; Login mit MFA → Code-Schritt; richtiger Code;
falscher Code; abgelaufener Code; erneut senden; Abbrechen; halbe Sitzung beim Neuladen (keine
automatische Mail); 429-Text; Seitenleisten-Eintrag öffnet `openUrl`, Fehler → Fallback.
- Projekt-Admin: Kunden-Login unverändert; Mitarbeiter ohne MFA unverändert; Mitarbeiter mit MFA
bekommt **keine** Portal-Sitzung vor dem Code; richtiger/falscher Code; 5 Fehlversuche; resend;
switch-link ohne/mit ungültigem JWT (401), mit halber Sitzung (401), Kunde (403), Mitarbeiter ohne
Admin-Label (200); staff-switch einmalig, abgelaufen, falscher `purpose`; Rückweg-Button nur für
Mitarbeiter und nicht im „Als Kunde ansehen“-Modus.
- Bestehende Testsuites beider Repos grün.
## Livegang (Reihenfolge)
1. Kundenportal live (Push nach `main` durch den Nutzer bzw. auf sein OK).
2. Ticketsystem live (Push nach `test` durch den Nutzer).
3. Code-Mail-Vorlage in der Appwrite-Console hinterlegen.
4. `mitarbeiter-2fa.mjs --status`, dann zuerst nur für Kenso `--enable`, einmal in beiden Systemen
durchspielen, dann `--enable alle`.
Ohne Schritt 4 ändert sich für niemanden etwas (MFA aus → Login wie bisher); Teil 2 funktioniert
schon nach Schritt 1+2.
## Nicht enthalten
- TOTP/KeePass, SMS, Wiederherstellungscodes, 2FA für Kunden.
- Automatischer Login vom Projekt-Admin zurück ins Ticketsystem.
- SDK-Upgrade des Ticketsystems.