Spec: Mitarbeiter-2FA per E-Mail-Code + Wechsel Ticketsystem/Projekt-Admin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3a3e43d5ec
commit
3af8a2f2ad
1 file changed
+169
@@ -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 <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.
|
||||
Reference in new issue
Block a user