feat(auth): Zwei-Faktor-Authentisierung — TOTP (Authenticator-App) (v1.8.0)

Freiwillige MFA, du kannst sie für dich aktivieren; Login verlangt danach den zweiten Faktor.

- Schema/Migration: User.totpSecret/totpConfirmedAt/mfaBackupCodes + Tabelle webauthn_credentials
  (WebAuthn folgt), otplib v12
- lib/mfa.ts: TOTP-Secret/QR-URI/Verify + bcrypt-gehashte Backup-Codes
- lib/auth.ts: buildSessionForUser, createMfaToken/verifyMfaToken (10-Min-Zwischentoken), userHasMfa
- Login-Flow: nach Passwort -> bei aktiver MFA kein Cookie, sondern mfaToken; öffentlicher
  /api/auth/mfa-login/verify prüft TOTP ODER Backup-Code und setzt dann die Session (Rate-Limit)
- Einrichtung in /settings: QR scannen, Code bestätigen, Backup-Codes (einmalig), deaktivieren
- Admin-Reset-API: /api/admin/users/[id]/mfa-reset (SERVER_ADMIN alle, TENANT_ADMIN eigene Org)
- Login-Seite: zweiter Schritt für Code/Backup-Code
- SMS bewusst weggelassen (Kosten/SIM-Swap)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pepe Ziberi
2026-07-23 18:06:24 +02:00
parent fa1e911ecd
commit f588b6d361
18 changed files with 710 additions and 57 deletions

View File

@@ -185,6 +185,22 @@ async function migrate() {
`CREATE INDEX IF NOT EXISTS legal_acceptances_doc_idx ON legal_acceptances("documentType", "documentVersion")`,
`ALTER TABLE legal_acceptances ADD COLUMN IF NOT EXISTS "ipAddress" TEXT`,
`ALTER TABLE legal_acceptances ADD COLUMN IF NOT EXISTS "userAgent" TEXT`,
// MFA / Zwei-Faktor
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "totpSecret" TEXT`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "totpConfirmedAt" TIMESTAMP(3)`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "mfaBackupCodes" TEXT[] NOT NULL DEFAULT '{}'`,
`CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
"credentialId" TEXT NOT NULL UNIQUE,
"publicKey" TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
transports TEXT,
"deviceName" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastUsedAt" TIMESTAMP(3)
)`,
`CREATE INDEX IF NOT EXISTS webauthn_credentials_user_idx ON webauthn_credentials("userId")`,
]
for (const sql of tableMigrations) {
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ }

View File

@@ -126,6 +126,12 @@ model User {
resetToken String? @unique
resetTokenExpiry DateTime?
lastLoginAt DateTime?
// ─── Zwei-Faktor-Authentisierung (MFA) ───
// TOTP (Authenticator-App): Secret gesetzt = Einrichtung begonnen; confirmedAt gesetzt = aktiv.
totpSecret String?
totpConfirmedAt DateTime?
// Einmalige Backup-Codes (bcrypt-gehasht), Fallback wenn kein Faktor verfügbar.
mfaBackupCodes String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -135,10 +141,29 @@ model User {
upgradeRequests UpgradeRequest[]
rapports Rapport[]
legalAcceptances LegalAcceptance[]
webauthnCredentials WebAuthnCredential[]
@@map("users")
}
// WebAuthn/FIDO2 (YubiKey, Passkeys, Touch-ID) — registrierte Sicherheitsschlüssel je Benutzer.
model WebAuthnCredential {
id String @id @default(uuid())
userId String
credentialId String @unique
publicKey String
counter Int @default(0)
transports String?
deviceName String?
createdAt DateTime @default(now())
lastUsedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("webauthn_credentials")
}
// ─── Rechtsdokumente & revisionssichere Zustimmungen ──────────
// Dokumenttypen (als String gehalten, damit die idempotente Raw-SQL-Migration einfach bleibt):
// TERMS | PRIVACY | ORGANIZATION_DECLARATION | RESPONSIBLE_USE | DATA_PROCESSING_AGREEMENT