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>
173 lines
5.6 KiB
TypeScript
173 lines
5.6 KiB
TypeScript
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'
|
||
}
|