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:
| Stufe | Bedeutung |
|---|---|
aal1 | Nur ein Faktor bestanden (Passwort). |
aal2 | Zwei 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— deraal-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) einenverified-Faktor enthält, sonstaal1.
Aus dem Zusammenspiel dieser beiden Werte leiten sich die drei relevanten Zustände ab:
currentLevel | nextLevel | Bedeutung | Aktion |
|---|---|---|---|
aal1 | aal1 | Kein Faktor eingerichtet | Zwangs-Enrollment (/mfa/enroll) |
aal1 | aal2 | Faktor vorhanden, diese Session hat ihn noch nicht bestätigt | Zwangs-Challenge (/mfa/challenge) |
aal2 | aal2 | Vollständig authentifiziert | Zugriff 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)
- Beim Laden werden zuerst verwaiste, unbestätigte Faktoren aus vorherigen abgebrochenen Versuchen aufgeräumt (Reload, geschlossener Tab, …) — siehe Fallstrick in Abschnitt 6.
auth.mfa.enroll({ factorType: "totp" })legt serverseitig einen neuen, noch unverifizierten Faktor an und gibt QR-Code (totp.qr_code, eindata:-URI, direkt als<img src>nutzbar) sowie das Secret im Klartext zur manuellen Eingabe zurück.- Nutzer scannt den QR-Code mit einer Authenticator-App und gibt den ersten generierten Code ein.
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 mitaal2zurück, die der Client automatisch übernimmt und in die Cookies schreibt (via@supabase/ssr).- 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
| Datei | Zweck |
|---|---|
src/lib/supabase/middleware.ts | AAL-Durchsetzung auf jeder Anfrage |
src/app/mfa/enroll/page.tsx + src/components/auth/mfa-enroll-form.tsx | Erstmaliges Einrichten (QR-Code, Verifikation) |
src/app/mfa/challenge/page.tsx + src/components/auth/mfa-challenge-form.tsx | Code-Abfrage bei jedem weiteren Login |
src/app/(app)/settings/security/page.tsx + src/components/settings/security-settings-form.tsx | Faktor-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, redundantenenroll()-Aufruf führen, dessen Fehlermeldung neben dem gültigen QR-Code des ersten Aufrufs aufblitzt — rein kosmetisch in der Wirkung, aber verwirrend. EinuseRef-Flag inmfa-enroll-form.tsxverhindert 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.