Alle Dokumente

Technisches Konzept: Verpflichtende Zwei-Faktor-Authentifizierung (2FA)

Die Anforderung für die Verwendung einer 2FA Technologie wurde explizit durch unser Kundenteam gefordert. Daraus resultierte das Features erst.


1. Grundidee: Passwort allein reicht nicht mehr

Bisher lief die Anmeldung ausschließlich über supabase.auth.signInWithPassword() — ein kompromittiertes Passwort genügte für vollen Zugriff. 2FA ergänzt einen zweiten, unabhängigen Faktor („etwas, das man besitzt" — hier: ein Smartphone mit Authenticator-App), sodass ein gestohlenes Passwort allein nicht mehr ausreicht.

Für FleetFlow ist 2FA verpflichtend für jedes Konto, kein Opt-in. Diese Entscheidung spiegelt sich direkt in der Middleware wider (Abschnitt 4) — es gibt keinen Codepfad, der eine Session ohne bestandenen zweiten Faktor an die geschützten Seiten durchlässt.

2. Warum TOTP und keine eigene OTP-Lösung

TOTP (Time-based One-Time Password, RFC 6238) erzeugt aus einem geheimen Schlüssel (dem Secret) und der aktuellen Uhrzeit alle 30 Sekunden einen neuen 6-stelligen Code. Der Algorithmus ist standardisiert und wird von praktisch jeder Authenticator-App (Google Authenticator, 1Password, Authy, …) unterstützt — es ist kein eigener SMS-Versand, kein E-Mail-Codepfad und keine eigene Kryptographie nötig.

Supabase Auth (GoTrue) implementiert TOTP-Enrollment, -Challenge und -Verifikation bereits vollständig serverseitig. Die im Projekt verwendeten Client-Versionen (@supabase/supabase-js@2.45.4, @supabase/ssr@0.5.2) unterstützen die komplette MFA-API (auth.mfa.enroll/challenge/verify/unenroll, getAuthenticatorAssuranceLevel()) — es musste weder eine neue Abhängigkeit installiert noch ein eigenes Backend für Secret-Generierung, QR-Code-Rendering oder Code-Prüfung gebaut werden. Das Secret verlässt den Supabase-Auth-Server nur einmalig beim Enrollment (im QR-Code / zur manuellen Eingabe) und wird serverseitig gespeichert; verifiziert wird ausschließlich über den generierten Code, nie über das Secret selbst.

3. Kernbegriff: Authenticator Assurance Level (AAL)

Supabase modelliert den "wie stark ist diese Session authentifiziert"-Zustand über zwei Stufen:

StufeBedeutung
aal1Nur ein Faktor bestanden (Passwort).
aal2Zwei Faktoren bestanden (Passwort und TOTP-Code in dieser Session).

Der aktuelle AAL-Zustand steckt als aal-Claim direkt im JWT (Access Token) der Session — er wird bei der Token-Ausstellung festgeschrieben, nicht bei jeder Anfrage neu berechnet. supabase.auth.mfa.getAuthenticatorAssuranceLevel() liefert zwei Werte zurück:

  • currentLevel — der aal-Claim aus dem aktuellen, in den Cookies liegenden JWT.
  • nextLevel — der höchste AAL, den dieser Nutzer erreichen könnte: aal2, falls das bereits geladene Session-Objekt (session.user.factors) einen verified-Faktor enthält, sonst aal1.

Aus dem Zusammenspiel dieser beiden Werte leiten sich die drei relevanten Zustände ab:

currentLevelnextLevelBedeutungAktion
aal1aal1Kein Faktor eingerichtetZwangs-Enrollment (/mfa/enroll)
aal1aal2Faktor vorhanden, diese Session hat ihn noch nicht bestätigtZwangs-Challenge (/mfa/challenge)
aal2aal2Vollständig authentifiziertZugriff auf die App

Wichtiger Implementierungs-Fallstrick: Beide Werte werden aus dem bereits geladenen Session-Objekt berechnet (siehe auth-js-Quellcode, _getAuthenticatorAssuranceLevel()) — nicht durch eine frische Datenbankabfrage. Wird ein Faktor per unenroll() entfernt, bleibt das lokale Session-Objekt (und damit das im Cookie liegende JWT) so lange auf dem alten Stand, bis explizit ein neuer Token angefordert wird. Details dazu in Abschnitt 6.

4. Durchsetzung in der Middleware

src/lib/supabase/middleware.ts läuft laut src/middleware.ts-Matcher auf praktisch jeder Anfrage (außer statischen Assets) — inklusive API-Routen. Das ist bewusst so gewählt: die Durchsetzung passiert vor jedem Server Component/Route Handler, nicht erst im App-Layer. Direkter Aufruf einer geschützten URL ohne bestandenen zweiten Faktor (z. B. /vehicles per Lesezeichen) wird dadurch genauso abgefangen wie ein regulärer Klick in der Navigation.

Ablauf pro Request (vereinfacht):

kein User eingeloggt?          → redirect /login
User eingeloggt, nextLevel=aal1 (kein Faktor)   → redirect /mfa/enroll
User eingeloggt, aal1→aal2 möglich, nicht bestanden → redirect /mfa/challenge
User bereits voll authentifiziert, aber auf /mfa/* → redirect /  (kein Zurückspringen ins Enrollment)
sonst                            → Request durchlassen

/login, /auth und /api/register bleiben als öffentliche Pfade von der AAL-Prüfung ausgenommen (Erstregistrierung braucht noch keine Session); /mfa/enroll und /mfa/challenge selbst sind von der Weiterleitung ins Enrollment/Challenge ausgenommen — sonst könnte man den Enrollment-Flow nie erreichen.

5. Die drei Nutzer-Flows

5.1 Enrollment (/mfa/enroll, mfa-enroll-form.tsx)

  1. Beim Laden werden zuerst verwaiste, unbestätigte Faktoren aus vorherigen abgebrochenen Versuchen aufgeräumt (Reload, geschlossener Tab, …) — siehe Fallstrick in Abschnitt 6.
  2. auth.mfa.enroll({ factorType: "totp" }) legt serverseitig einen neuen, noch unverifizierten Faktor an und gibt QR-Code (totp.qr_code, ein data:-URI, direkt als <img src> nutzbar) sowie das Secret im Klartext zur manuellen Eingabe zurück.
  3. Nutzer scannt den QR-Code mit einer Authenticator-App und gibt den ersten generierten Code ein.
  4. auth.mfa.challenge({ factorId }) erzeugt eine Challenge, auth.mfa.verify({ factorId, challengeId, code }) prüft den Code gegen das Secret. Bei Erfolg liefert Supabase eine neue Session mit aal2 zurück, die der Client automatisch übernimmt und in die Cookies schreibt (via @supabase/ssr).
  5. Danach greift die Middleware nicht mehr — currentLevel === nextLevel === "aal2".

5.2 Challenge (/mfa/challenge, mfa-challenge-form.tsx)

Für Nutzer, die bereits einen verifizierten Faktor besitzen: nach jedem signInWithPassword()-Login (der nur aal1 liefert) muss der TOTP-Code erneut eingegeben werden. Technisch identisch zu Schritt 4 oben (challenge + verify), nur ohne Neu-Enrollment — der existierende Faktor wird über listFactors() gesucht (status === "verified").

5.3 Faktor-Reset (/settings/security, security-settings-form.tsx)

2FA lässt sich nicht dauerhaft deaktivieren — nur zurücksetzen (z. B. bei Geräteverlust). auth.mfa.unenroll({ factorId }) entfernt den bestehenden Faktor; anschließend zwingt die Middleware die Session automatisch zurück in den Enrollment-Flow, da nextLevel dann wieder aal1 ist. Der entscheidende technische Kniff hier ist refreshSession() — siehe nächster Abschnitt.

6. Zwei reale Fallstricke (aus der Implementierung, nicht theoretisch)

Diese beiden Punkte haben beim Testen des Feature echte Bugs verursacht und sind beim Weiterentwickeln relevant:

6.1 unenroll() aktualisiert die Session nicht automatisch

Wie in Abschnitt 3 beschrieben, liest getAuthenticatorAssuranceLevel() aus dem bereits geladenen Session-Objekt, nicht aus einer Live-Abfrage. auth.mfa.unenroll() gibt zwar serverseitig den Faktor frei, aktualisiert aber nicht automatisch das im Cookie liegende JWT des Browser-Clients. Ohne Gegenmaßnahme hält die Middleware die Session weiterhin für aal2-vollständig und wirft den Nutzer direkt aus /mfa/enroll wieder heraus, bevor er einen neuen Faktor einrichten kann — der Reset-Button würde de facto nichts bewirken.

Lösung: Nach unenroll() explizit supabase.auth.refreshSession() aufrufen. Das fordert einen neuen Access Token an, der den aktuellen Faktor-Stand korrekt widerspiegelt, und schreibt ihn über den @supabase/ssr-Cookie-Sync sofort in die Cookies — erst danach darf navigiert werden (security-settings-form.tsx).

6.2 listFactors().totp enthält nur verifizierte Faktoren

Die SDK-Methode listFactors() liefert drei Felder zurück: all, totp, phone. Intuitiv würde man erwarten, dass totp „alle TOTP-Faktoren" enthält — tatsächlich filtert die SDK-Implementierung (GoTrueClient._listFactors()) totp und phone bereits auf status === "verified". Unbestätigte (gerade erst angelegte, nie verifizierte) Faktoren stecken ausschließlich in all.

Das hat beim Aufräumen verwaister Enrollment-Versuche (Abschnitt 5.1, Schritt 1) zu einem stillen Bug geführt: Ein Filter auf existing.totp.filter(f => f.status === "unverified") ist strukturell immer leer, das Aufräumen lief also faktisch nie. Da Supabase pro Nutzer einen eindeutigen friendly_name je Faktor erzwingt (Default: leerer String "") und ein abgebrochener Enrollment-Versuch einen unbestätigten Faktor mit genau diesem leeren Namen hinterlässt, schlug jeder folgende enroll()-Aufruf mit "A factor with the friendly name "" for this user already exists" fehl — bis der Nutzer seinen einzigen Weg zurück in die App verlor.

Lösung: Verwaiste Faktoren aus existing.all filtern (f.factor_type === "totp" && f.status === "unverified"), nicht aus existing.totp.

7. Datei-Übersicht

DateiZweck
src/lib/supabase/middleware.tsAAL-Durchsetzung auf jeder Anfrage
src/app/mfa/enroll/page.tsx + src/components/auth/mfa-enroll-form.tsxErstmaliges Einrichten (QR-Code, Verifikation)
src/app/mfa/challenge/page.tsx + src/components/auth/mfa-challenge-form.tsxCode-Abfrage bei jedem weiteren Login
src/app/(app)/settings/security/page.tsx + src/components/settings/security-settings-form.tsxFaktor-Status anzeigen, Zurücksetzen

8. Sicherheitsaspekte

  • Kein Secret im Klartext im eigenen Backend. Enrollment, Challenge und Verify laufen ausschließlich über die Supabase-Auth-API; FleetFlow selbst sieht das TOTP-Secret nie serverseitig, nur der Browser während des Enrollment-Bildschirms.
  • React Strict Mode und doppelte enroll()-Aufrufe: Next.js führt Effects im Dev-Modus zweimal aus. Ohne Schutzmaßnahme würde das zu einem zweiten, redundanten enroll()-Aufruf führen, dessen Fehlermeldung neben dem gültigen QR-Code des ersten Aufrufs aufblitzt — rein kosmetisch in der Wirkung, aber verwirrend. Ein useRef-Flag in mfa-enroll-form.tsx verhindert den doppelten Aufruf pro Mount.
  • Middleware statt App-Layer-Check. Die AAL-Prüfung sitzt bewusst in der Middleware (läuft vor jedem Matched Request) statt in einzelnen Server Components — ein neu hinzugefügter geschützter Bereich ist automatisch abgesichert, ohne dass man daran denken muss, den Check dort erneut einzubauen.