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

@@ -46,42 +46,26 @@ export async function getSession(): Promise<UserPayload | null> {
return await verifyToken(token)
}
export async function login(
email: string,
password: string
/**
* Baut die Session-Payload für einen (bereits authentifizierten) Benutzer per ID.
* Wird sowohl beim normalen Login (nach Passwortprüfung) als auch nach erfolgreicher
* MFA-Verifikation verwendet, damit die Logik nur an einer Stelle lebt.
*/
export async function buildSessionForUser(
userId: string
): Promise<{ success: boolean; user?: UserPayload; error?: string }> {
const user = await (prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
name: true,
password: true,
role: true,
emailVerified: true,
isActive: true,
},
where: { id: userId },
select: { id: true, email: true, name: true, role: true, emailVerified: true, isActive: true },
}) as any)
if (!user) {
return { success: false, error: 'E-Mail oder Passwort falsch' }
}
const isValidPassword = await bcrypt.compare(password, user.password)
if (!isValidPassword) {
return { success: false, error: 'E-Mail oder Passwort falsch' }
}
// Gesperrte Benutzer dürfen sich nicht anmelden (isActive === false).
// Tolerant: nur blockieren, wenn explizit false (alte Datensätze ohne Wert bleiben aktiv).
if (!user) return { success: false, error: 'Benutzer nicht gefunden' }
if (user.isActive === false) {
return { success: false, error: 'Ihr Konto wurde deaktiviert. Bitte kontaktieren Sie den Administrator.' }
}
// Track email verification status (allow login regardless)
const emailVerified = (user as any).emailVerified !== false
const emailVerified = user.emailVerified !== false
// Get first tenant membership for non-server-admins
let tenantId: string | undefined
let tenantSlug: string | undefined
if ((user.role as string) !== 'SERVER_ADMIN') {
@@ -91,7 +75,6 @@ export async function login(
orderBy: { createdAt: 'asc' },
})
if (membership) {
// Check if tenant is active
if (!membership.tenant.isActive) {
return { success: false, error: 'Ihr Mandant wurde gesperrt. Bitte kontaktieren Sie den Administrator.' }
}
@@ -109,10 +92,65 @@ export async function login(
tenantSlug,
emailVerified,
}
return { success: true, user: userPayload }
}
export async function login(
email: string,
password: string
): Promise<{ success: boolean; user?: UserPayload; error?: string }> {
const user = await (prisma.user.findUnique({
where: { email },
select: { id: true, password: true, isActive: true },
}) as any)
if (!user) {
return { success: false, error: 'E-Mail oder Passwort falsch' }
}
const isValidPassword = await bcrypt.compare(password, user.password)
if (!isValidPassword) {
return { success: false, error: 'E-Mail oder Passwort falsch' }
}
if (user.isActive === false) {
return { success: false, error: 'Ihr Konto wurde deaktiviert. Bitte kontaktieren Sie den Administrator.' }
}
return buildSessionForUser(user.id)
}
// ─── MFA: Zwischen-Token nach Passwort, vor zweitem Faktor ───
/** Kurzlebiges Token (10 Min) zwischen Passwort und zweitem Faktor. Ist KEINE Session. */
export async function createMfaToken(userId: string, rememberMe: boolean): Promise<string> {
return await new SignJWT({ uid: userId, rememberMe, purpose: 'mfa' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('10m')
.sign(JWT_SECRET)
}
export async function verifyMfaToken(token: string): Promise<{ userId: string; rememberMe: boolean } | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET)
if (payload.purpose !== 'mfa' || typeof payload.uid !== 'string') return null
return { userId: payload.uid, rememberMe: !!payload.rememberMe }
} catch {
return null
}
}
/** True, wenn der Benutzer einen zweiten Faktor aktiv hat (TOTP bestätigt oder WebAuthn registriert). */
export async function userHasMfa(userId: string): Promise<boolean> {
const user = await (prisma as any).user.findUnique({
where: { id: userId },
select: { totpConfirmedAt: true, _count: { select: { webauthnCredentials: true } } },
})
if (!user) return false
return !!user.totpConfirmedAt || (user._count?.webauthnCredentials ?? 0) > 0
}
export async function hashPassword(password: string): Promise<string> {
return await bcrypt.hash(password, 12)
}