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

10 KiB
Raw Blame History

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.