Files
Lageplan/src/lib/auth.ts
Pepe Ziberi f588b6d361 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>
2026-07-23 18:06:24 +02:00

173 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
import { prisma } from './db'
import bcrypt from 'bcryptjs'
import { JWT_SECRET } from './jwt-secret'
export interface UserPayload {
id: string
email: string
name: string
role: 'SERVER_ADMIN' | 'TENANT_ADMIN' | 'OPERATOR' | 'VIEWER'
tenantId?: string
tenantSlug?: string
emailVerified?: boolean
}
// Zentrale Session-Laufzeiten (Sekunden) EINE Quelle für Token-Ablauf UND Cookie-maxAge,
// damit beide nie auseinanderdriften.
export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 // 24 Stunden (Standard)
export const SESSION_MAX_AGE_REMEMBER_SECONDS = 60 * 60 * 24 * 14 // 14 Tage ("angemeldet bleiben")
export async function createToken(user: UserPayload, rememberMe = false): Promise<string> {
const maxAge = rememberMe ? SESSION_MAX_AGE_REMEMBER_SECONDS : SESSION_MAX_AGE_SECONDS
return await new SignJWT({ user })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(`${maxAge}s`)
.sign(JWT_SECRET)
}
export async function verifyToken(token: string): Promise<UserPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET)
return payload.user as UserPayload
} catch {
return null
}
}
export async function getSession(): Promise<UserPayload | null> {
const cookieStore = await cookies()
const token = cookieStore.get('auth-token')?.value
if (!token) return null
return await verifyToken(token)
}
/**
* 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: { id: userId },
select: { id: true, email: true, name: true, role: true, emailVerified: true, isActive: true },
}) as any)
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.' }
}
const emailVerified = user.emailVerified !== false
let tenantId: string | undefined
let tenantSlug: string | undefined
if ((user.role as string) !== 'SERVER_ADMIN') {
const membership = await (prisma as any).tenantMembership.findFirst({
where: { userId: user.id },
include: { tenant: true },
orderBy: { createdAt: 'asc' },
})
if (membership) {
if (!membership.tenant.isActive) {
return { success: false, error: 'Ihr Mandant wurde gesperrt. Bitte kontaktieren Sie den Administrator.' }
}
tenantId = membership.tenantId
tenantSlug = membership.tenant.slug
}
}
const userPayload: UserPayload = {
id: user.id,
email: user.email,
name: user.name,
role: (user.role === 'ADMIN' ? 'SERVER_ADMIN' : user.role) as UserPayload['role'],
tenantId,
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)
}
export function canEdit(role: string): boolean {
return role === 'SERVER_ADMIN' || role === 'TENANT_ADMIN' || role === 'OPERATOR'
}
export function isAdmin(role: string): boolean {
return role === 'SERVER_ADMIN' || role === 'TENANT_ADMIN'
}
export function isServerAdmin(role: string): boolean {
return role === 'SERVER_ADMIN'
}
export function isTenantAdmin(role: string): boolean {
return role === 'TENANT_ADMIN'
}