From 3af8a2f2ad5d0c726dd8a795c6f8e04254bcddfa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 10:19:19 +0000 Subject: [PATCH] Spec: Mitarbeiter-2FA per E-Mail-Code + Wechsel Ticketsystem/Projekt-Admin Co-Authored-By: Claude Opus 5.5 --- ...9-30-mitarbeiter-2fa-und-wechsel-design.md | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-30-mitarbeiter-2fa-und-wechsel-design.md diff --git a/docs/superpowers/specs/2026-09-30-mitarbeiter-2fa-und-wechsel-design.md b/docs/superpowers/specs/2026-09-30-mitarbeiter-2fa-und-wechsel-design.md new file mode 100644 index 0000000..6e96eda --- /dev/null +++ b/docs/superpowers/specs/2026-09-30-mitarbeiter-2fa-und-wechsel-design.md @@ -0,0 +1,169 @@ +# 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 `. +- 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_` 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: }`. +- `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 `: 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.