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

74
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "lageplan",
"version": "1.7.3",
"version": "1.7.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lageplan",
"version": "1.7.3",
"version": "1.7.8",
"hasInstallScript": true,
"dependencies": {
"@dnd-kit/core": "^6.1.0",
@@ -38,6 +38,7 @@
"minio": "^7.1.3",
"next": "^15.1.0",
"nodemailer": "^6.9.8",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"rdndmb-html5-to-touch": "^9.0.0",
"react": "^19.0.0",
@@ -1624,6 +1625,56 @@
"node": ">=12.4.0"
}
},
"node_modules/@otplib/core": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
"license": "MIT"
},
"node_modules/@otplib/plugin-crypto": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1"
}
},
"node_modules/@otplib/plugin-thirty-two": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"thirty-two": "^1.0.2"
}
},
"node_modules/@otplib/preset-default": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@otplib/preset-v11": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
@@ -8653,6 +8704,17 @@
"node": ">= 0.8.0"
}
},
"node_modules/otplib": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/preset-default": "^12.0.1",
"@otplib/preset-v11": "^12.0.1"
}
},
"node_modules/overlap-area": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/overlap-area/-/overlap-area-1.1.0.tgz",
@@ -10582,6 +10644,14 @@
"node": ">=0.8"
}
},
"node_modules/thirty-two": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
"engines": {
"node": ">=0.2.6"
}
},
"node_modules/through2": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "lageplan",
"version": "1.7.8",
"version": "1.8.0",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true,
"scripts": {
@@ -54,6 +54,7 @@
"minio": "^7.1.3",
"next": "^15.1.0",
"nodemailer": "^6.9.8",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"rdndmb-html5-to-touch": "^9.0.0",
"react": "^19.0.0",

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

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
// POST: setzt die MFA eines Benutzers zurück (TOTP + Backup-Codes + WebAuthn entfernen).
// SERVER_ADMIN: jeden Benutzer. TENANT_ADMIN: nur Benutzer der eigenen Organisation.
export async function POST(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const session = await getSession()
if (!session || !isAdmin(session.role)) {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
// TENANT_ADMIN darf nur eigene Organisationsmitglieder zurücksetzen.
if (session.role !== 'SERVER_ADMIN') {
if (!session.tenantId) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const membership = await (prisma as any).tenantMembership.findFirst({
where: { userId: id, tenantId: session.tenantId },
select: { id: true },
})
if (!membership) return NextResponse.json({ error: 'Benutzer nicht in Ihrer Organisation' }, { status: 403 })
}
await (prisma as any).$transaction([
(prisma as any).webAuthnCredential.deleteMany({ where: { userId: id } }),
(prisma as any).user.update({
where: { id },
data: { totpSecret: null, totpConfirmedAt: null, mfaBackupCodes: [] },
}),
])
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error resetting MFA:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { login, createToken, SESSION_MAX_AGE_SECONDS, SESSION_MAX_AGE_REMEMBER_SECONDS } from '@/lib/auth'
import { login, createToken, createMfaToken, userHasMfa, SESSION_MAX_AGE_SECONDS, SESSION_MAX_AGE_REMEMBER_SECONDS } from '@/lib/auth'
import { loginSchema } from '@/lib/validations'
import { prisma } from '@/lib/db'
import { loginLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
@@ -36,6 +36,23 @@ export async function POST(request: NextRequest) {
)
}
// Zweiter Faktor erforderlich? Dann noch KEINE Session setzen, sondern MFA-Zwischentoken liefern.
if (await userHasMfa(result.user.id)) {
const totpEnabled = await (prisma as any).user.findUnique({
where: { id: result.user.id },
select: { totpConfirmedAt: true, _count: { select: { webauthnCredentials: true } } },
})
const mfaToken = await createMfaToken(result.user.id, rememberMe)
return NextResponse.json({
mfaRequired: true,
mfaToken,
methods: {
totp: !!totpEnabled?.totpConfirmedAt,
webauthn: (totpEnabled?._count?.webauthnCredentials ?? 0) > 0,
},
})
}
// Update lastLoginAt
try {
await (prisma as any).user.update({

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { prisma } from '@/lib/db'
import { verifyMfaToken, buildSessionForUser, createToken, SESSION_MAX_AGE_SECONDS, SESSION_MAX_AGE_REMEMBER_SECONDS } from '@/lib/auth'
import { verifyTotp, verifyBackupCode } from '@/lib/mfa'
import { loginLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
// POST (öffentlich): zweiter Faktor per TOTP-Code ODER Backup-Code. Setzt bei Erfolg die Session.
// body: { mfaToken, code }
export async function POST(req: NextRequest) {
try {
const rl = loginLimiter.check(getClientIp(req))
if (!rl.success) return rateLimitResponse(rl.resetAt)
const body = await req.json().catch(() => ({}))
const mfa = await verifyMfaToken(String(body.mfaToken || ''))
if (!mfa) return NextResponse.json({ error: 'Sitzung abgelaufen. Bitte erneut anmelden.' }, { status: 401 })
const code = String(body.code || '')
const user = await (prisma as any).user.findUnique({
where: { id: mfa.userId },
select: { id: true, totpSecret: true, totpConfirmedAt: true, mfaBackupCodes: true },
})
if (!user) return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 401 })
let ok = false
// 1) TOTP
if (user.totpConfirmedAt && user.totpSecret && verifyTotp(user.totpSecret, code)) {
ok = true
} else {
// 2) Backup-Code — bei Treffer entwerten
const idx = await verifyBackupCode(user.mfaBackupCodes || [], code)
if (idx >= 0) {
ok = true
const remaining = [...(user.mfaBackupCodes as string[])]
remaining.splice(idx, 1)
await (prisma as any).user.update({ where: { id: user.id }, data: { mfaBackupCodes: remaining } })
}
}
if (!ok) return NextResponse.json({ error: 'Code ungültig.' }, { status: 401 })
const session = await buildSessionForUser(user.id)
if (!session.success || !session.user) {
return NextResponse.json({ error: session.error || 'Anmeldung fehlgeschlagen' }, { status: 401 })
}
try { await (prisma as any).user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }) } catch {}
const token = await createToken(session.user, mfa.rememberMe)
;(await cookies()).set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: mfa.rememberMe ? SESSION_MAX_AGE_REMEMBER_SECONDS : SESSION_MAX_AGE_SECONDS,
path: '/',
})
return NextResponse.json({ user: session.user })
} catch (error) {
console.error('MFA verify error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
// GET: MFA-Status des angemeldeten Benutzers für die Einstellungen.
export async function GET() {
try {
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const user = await (prisma as any).user.findUnique({
where: { id: session.id },
select: { totpConfirmedAt: true, mfaBackupCodes: true, webauthnCredentials: { select: { id: true, deviceName: true, createdAt: true, lastUsedAt: true } } },
})
const totpEnabled = !!user?.totpConfirmedAt
const credentials = user?.webauthnCredentials || []
return NextResponse.json({
totpEnabled,
backupCodesRemaining: (user?.mfaBackupCodes || []).length,
webauthn: credentials,
hasMfa: totpEnabled || credentials.length > 0,
})
} catch (error) {
console.error('Error fetching MFA status:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { verifyTotp, generateBackupCodes } from '@/lib/mfa'
// POST: bestätigt die TOTP-Einrichtung mit einem Code aus der App. Bei Erfolg wird TOTP aktiviert
// und einmalige Backup-Codes werden erzeugt (Klartext nur hier einmalig zurückgegeben).
export async function POST(req: NextRequest) {
try {
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const body = await req.json().catch(() => ({}))
const code = String(body.code || '')
const user = await (prisma as any).user.findUnique({
where: { id: session.id },
select: { totpSecret: true, totpConfirmedAt: true, mfaBackupCodes: true },
})
if (!user?.totpSecret) {
return NextResponse.json({ error: 'Keine Einrichtung offen. Bitte erneut starten.' }, { status: 400 })
}
if (!verifyTotp(user.totpSecret, code)) {
return NextResponse.json({ error: 'Code ungültig. Bitte erneut versuchen.' }, { status: 400 })
}
// Backup-Codes nur erzeugen, wenn noch keine existieren (Neuaktivierung).
let plainCodes: string[] | null = null
let hashed = user.mfaBackupCodes as string[]
if (!user.totpConfirmedAt || !hashed || hashed.length === 0) {
const gen = await generateBackupCodes(10)
plainCodes = gen.plain
hashed = gen.hashed
}
await (prisma as any).user.update({
where: { id: session.id },
data: { totpConfirmedAt: new Date(), mfaBackupCodes: hashed },
})
return NextResponse.json({ success: true, backupCodes: plainCodes })
} catch (error) {
console.error('Error confirming TOTP:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
// POST: deaktiviert TOTP für den angemeldeten Benutzer. Backup-Codes werden nur entfernt,
// wenn auch kein WebAuthn mehr aktiv ist (sonst bleiben sie als Fallback bestehen).
export async function POST() {
try {
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const cnt = await (prisma as any).webAuthnCredential.count({ where: { userId: session.id } })
const data: any = { totpSecret: null, totpConfirmedAt: null }
if (cnt === 0) data.mfaBackupCodes = []
await (prisma as any).user.update({ where: { id: session.id }, data })
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error disabling TOTP:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server'
import QRCode from 'qrcode'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { generateTotpSecret, totpKeyUri } from '@/lib/mfa'
// POST: startet die TOTP-Einrichtung — erzeugt ein Secret (noch nicht bestätigt) und liefert
// otpauth-URI + QR-Code zum Scannen. Aktiv wird TOTP erst nach /confirm.
export async function POST() {
try {
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const secret = generateTotpSecret()
const uri = totpKeyUri(session.email, secret)
// Secret vormerken (unbestätigt). totpConfirmedAt bleibt NULL bis zur Bestätigung.
await (prisma as any).user.update({
where: { id: session.id },
data: { totpSecret: secret, totpConfirmedAt: null },
})
let qr = ''
try { qr = await QRCode.toDataURL(uri, { width: 220, margin: 1 }) } catch { /* QR optional */ }
return NextResponse.json({ secret, uri, qr })
} catch (error) {
console.error('Error setting up TOTP:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -28,7 +28,10 @@ function LoginForm() {
const [resendSuccess, setResendSuccess] = useState(false)
const [tenantLogo, setTenantLogo] = useState<string | null>(null)
const [tenantName, setTenantName] = useState<string | null>(null)
const { login } = useAuth()
// MFA-Zwischenschritt
const [mfaToken, setMfaToken] = useState<string | null>(null)
const [mfaCode, setMfaCode] = useState('')
const { login, verifyMfa } = useAuth()
const router = useRouter()
const searchParams = useSearchParams()
const { toast } = useToast()
@@ -52,6 +55,18 @@ function LoginForm() {
}
}, [tenantSlug])
const doRedirect = async () => {
if (redirectTo) { router.push(redirectTo); return }
try {
const meRes = await fetch('/api/auth/me')
if (meRes.ok) {
const meData = await meRes.json()
if (meData.user?.tenantSlug) { router.push(`/${meData.user.tenantSlug}`); return }
}
} catch {}
router.push('/app')
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
@@ -59,26 +74,11 @@ function LoginForm() {
const result = await login(email, password, rememberMe)
if (result.success) {
toast({
title: 'Erfolgreich angemeldet',
description: 'Willkommen bei Lageplan!',
})
// Redirect: use explicit redirect param, or tenant slug, or /app
if (redirectTo) {
router.push(redirectTo)
} else {
try {
const meRes = await fetch('/api/auth/me')
if (meRes.ok) {
const meData = await meRes.json()
if (meData.user?.tenantSlug) {
router.push(`/${meData.user.tenantSlug}`)
return
}
}
} catch {}
router.push('/app')
}
toast({ title: 'Erfolgreich angemeldet', description: 'Willkommen bei Lageplan!' })
await doRedirect()
} else if (result.mfaRequired && result.mfaToken) {
// Zweiter Faktor nötig — Code-Schritt anzeigen.
setMfaToken(result.mfaToken)
} else {
toast({
title: 'Anmeldung fehlgeschlagen',
@@ -90,6 +90,20 @@ function LoginForm() {
setIsLoading(false)
}
const handleMfaSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!mfaToken) return
setIsLoading(true)
const result = await verifyMfa(mfaToken, mfaCode)
if (result.success) {
toast({ title: 'Erfolgreich angemeldet', description: 'Willkommen bei Lageplan!' })
await doRedirect()
} else {
toast({ title: 'Code ungültig', description: result.error || 'Bitte erneut versuchen.', variant: 'destructive' })
}
setIsLoading(false)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4">
<div className="w-full max-w-md">
@@ -142,7 +156,32 @@ function LoginForm() {
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{mfaToken && (
<form onSubmit={handleMfaSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="mfa-code">Bestätigungscode</Label>
<Input
id="mfa-code"
inputMode="numeric"
autoFocus
autoComplete="one-time-code"
placeholder="6-stelliger Code oder Backup-Code"
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value)}
disabled={isLoading}
/>
<p className="text-xs text-gray-400">Code aus deiner Authenticator-App. Kein Zugriff darauf? Verwende einen deiner Backup-Codes.</p>
</div>
<Button type="submit" className="w-full bg-red-600 hover:bg-red-700" disabled={isLoading || mfaCode.trim().length < 6}>
{isLoading ? (<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Prüfen...</>) : 'Bestätigen'}
</Button>
<button type="button" onClick={() => { setMfaToken(null); setMfaCode('') }} className="w-full text-sm text-gray-400 hover:text-gray-200">
Abbrechen
</button>
</form>
)}
<form onSubmit={handleSubmit} className={`space-y-4 ${mfaToken ? 'hidden' : ''}`}>
<div className="space-y-1.5">
<Label htmlFor="email">E-Mail</Label>
<Input

View File

@@ -9,6 +9,7 @@ import { useToast } from '@/components/ui/use-toast'
import {
ArrowLeft, Building2, Trash2, AlertTriangle, Loader2, Shield, Users, FileText,
} from 'lucide-react'
import { MfaSettings } from '@/components/settings/mfa-settings'
export default function SettingsPage() {
const [tenant, setTenant] = useState<any>(null)
@@ -120,6 +121,9 @@ export default function SettingsPage() {
</div>
</div>
{/* Zwei-Faktor-Authentisierung */}
<MfaSettings />
{/* Danger Zone */}
<div className="bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-800/50 p-5">
<h2 className="text-lg font-semibold text-red-700 dark:text-red-400 mb-2 flex items-center gap-2">

View File

@@ -29,7 +29,8 @@ interface AuthContextType {
user: User | null
tenant: TenantInfo | null
loading: boolean
login: (email: string, password: string, rememberMe?: boolean) => Promise<{ success: boolean; error?: string }>
login: (email: string, password: string, rememberMe?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean; mfaToken?: string; methods?: { totp: boolean; webauthn: boolean } }>
verifyMfa: (mfaToken: string, code: string) => Promise<{ success: boolean; error?: string }>
logout: () => Promise<void>
canEdit: () => boolean
isAdmin: () => boolean
@@ -77,12 +78,35 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return { success: true }
}
// Zweiter Faktor erforderlich — Login noch nicht abgeschlossen.
if (res.ok && data.mfaRequired) {
return { success: false, mfaRequired: true, mfaToken: data.mfaToken, methods: data.methods }
}
return { success: false, error: data.error || 'Login fehlgeschlagen' }
} catch (error) {
return { success: false, error: 'Verbindungsfehler' }
}
}
const verifyMfa = async (mfaToken: string, code: string) => {
try {
const res = await fetch('/api/auth/mfa-login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mfaToken, code }),
})
const data = await res.json()
if (res.ok && data.user) {
setUser(data.user)
return { success: true }
}
return { success: false, error: data.error || 'Code ungültig' }
} catch {
return { success: false, error: 'Verbindungsfehler' }
}
}
const logout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' })
@@ -106,7 +130,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
return (
<AuthContext.Provider value={{ user, tenant, loading, login, logout, canEdit, isAdmin, isServerAdmin }}>
<AuthContext.Provider value={{ user, tenant, loading, login, verifyMfa, logout, canEdit, isAdmin, isServerAdmin }}>
{children}
</AuthContext.Provider>
)

View File

@@ -0,0 +1,135 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { ShieldCheck, Loader2, KeyRound, Copy, Check } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
interface Status { totpEnabled: boolean; backupCodesRemaining: number; webauthn: { id: string; deviceName: string | null }[]; hasMfa: boolean }
export function MfaSettings() {
const { toast } = useToast()
const [status, setStatus] = useState<Status | null>(null)
const [setup, setSetup] = useState<{ qr: string; secret: string } | null>(null)
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
const [copied, setCopied] = useState(false)
const refresh = useCallback(async () => {
try {
const res = await fetch('/api/auth/mfa/status')
if (res.ok) setStatus(await res.json())
} catch { /* ignore */ }
}, [])
useEffect(() => { refresh() }, [refresh])
const startSetup = async () => {
setBusy(true)
try {
const res = await fetch('/api/auth/mfa/totp/setup', { method: 'POST' })
const d = await res.json()
if (res.ok) setSetup({ qr: d.qr, secret: d.secret })
else toast({ title: 'Fehler', description: d.error, variant: 'destructive' })
} finally { setBusy(false) }
}
const confirm = async () => {
setBusy(true)
try {
const res = await fetch('/api/auth/mfa/totp/confirm', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }),
})
const d = await res.json()
if (res.ok) {
setSetup(null); setCode('')
if (d.backupCodes) setBackupCodes(d.backupCodes)
toast({ title: 'Zwei-Faktor aktiviert', description: 'Ab jetzt beim Login erforderlich.' })
refresh()
} else {
toast({ title: 'Code ungültig', description: d.error, variant: 'destructive' })
}
} finally { setBusy(false) }
}
const disable = async () => {
if (!confirm2('Zwei-Faktor wirklich deaktivieren? Dein Konto ist danach nur noch mit Passwort geschützt.')) return
setBusy(true)
try {
const res = await fetch('/api/auth/mfa/totp/disable', { method: 'POST' })
if (res.ok) { toast({ title: 'Deaktiviert', description: 'Authenticator-App entfernt.' }); refresh() }
} finally { setBusy(false) }
}
const copyBackup = async () => {
if (!backupCodes) return
try { await navigator.clipboard.writeText(backupCodes.join('\n')); setCopied(true); setTimeout(() => setCopied(false), 2000) } catch { /* */ }
}
return (
<div className="bg-card rounded-lg border p-5">
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-muted-foreground" />
Zwei-Faktor-Authentisierung (2FA)
</h2>
<p className="text-sm text-muted-foreground mb-4">
Zusätzlicher Schutz beim Login mit einer Authenticator-App. Empfohlen besonders für Administratoren.
</p>
{/* Einmalig angezeigte Backup-Codes */}
{backupCodes && (
<div className="mb-4 rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800 p-4">
<p className="text-sm font-semibold text-amber-800 dark:text-amber-300 mb-2">Backup-Codes jetzt sicher speichern!</p>
<p className="text-xs text-amber-700 dark:text-amber-400 mb-3">Jeder Code funktioniert einmal, falls du keinen Zugriff auf die App hast. Sie werden nur dieses eine Mal angezeigt.</p>
<div className="grid grid-cols-2 gap-1.5 font-mono text-sm">
{backupCodes.map(c => <span key={c} className="bg-white dark:bg-black/30 rounded px-2 py-1 text-center">{c}</span>)}
</div>
<div className="flex gap-2 mt-3">
<Button size="sm" variant="outline" onClick={copyBackup}>{copied ? <Check className="w-4 h-4 mr-1 text-green-600" /> : <Copy className="w-4 h-4 mr-1" />} Kopieren</Button>
<Button size="sm" onClick={() => setBackupCodes(null)}>Gespeichert</Button>
</div>
</div>
)}
{!status ? (
<div className="py-4 flex justify-center"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
) : status.totpEnabled ? (
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="text-sm">
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 text-green-700 dark:bg-green-950/40 dark:text-green-400 px-2.5 py-1 font-medium">
<ShieldCheck className="w-3.5 h-3.5" /> Authenticator-App aktiv
</span>
<span className="ml-3 text-muted-foreground text-xs">{status.backupCodesRemaining} Backup-Codes übrig</span>
</div>
<Button variant="outline" size="sm" onClick={disable} disabled={busy} className="text-destructive hover:text-destructive">Deaktivieren</Button>
</div>
) : setup ? (
<div className="space-y-3">
<p className="text-sm">1. Scanne den QR-Code mit Google Authenticator, Microsoft Authenticator, Authy o. Ä.:</p>
{setup.qr && (
// eslint-disable-next-line @next/next/no-img-element
<img src={setup.qr} alt="TOTP QR-Code" className="w-44 h-44 rounded-lg border border-border" />
)}
<p className="text-xs text-muted-foreground">Manuell: <code className="font-mono">{setup.secret}</code></p>
<p className="text-sm">2. Gib den angezeigten 6-stelligen Code ein:</p>
<div className="flex gap-2">
<Input inputMode="numeric" placeholder="123456" value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} className="font-mono tracking-widest w-40" />
<Button onClick={confirm} disabled={busy || code.length !== 6}>{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Aktivieren'}</Button>
<Button variant="outline" onClick={() => { setSetup(null); setCode('') }}>Abbrechen</Button>
</div>
</div>
) : (
<Button onClick={startSetup} disabled={busy}>
<KeyRound className="w-4 h-4 mr-2" /> Authenticator-App einrichten
</Button>
)}
</div>
)
}
// Vermeidet Namenskollision mit dem lokalen confirm()-State-Setter oben.
function confirm2(msg: string): boolean {
return typeof window !== 'undefined' ? window.confirm(msg) : true
}

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)
}

54
src/lib/mfa.ts Normal file
View File

@@ -0,0 +1,54 @@
import { authenticator } from 'otplib'
import { randomBytes } from 'crypto'
import bcrypt from 'bcryptjs'
import { legal } from '@/config/legal'
// Kleine Toleranz gegen Uhr-Drift (±1 Zeitfenster = ±30s).
authenticator.options = { window: 1 }
/** Neues TOTP-Secret erzeugen (Base32). */
export function generateTotpSecret(): string {
return authenticator.generateSecret()
}
/** otpauth://-URI für den QR-Code (Authenticator-App). */
export function totpKeyUri(email: string, secret: string): string {
const issuer = legal.projectName
return authenticator.keyuri(email, issuer, secret)
}
/** Prüft einen 6-stelligen TOTP-Code gegen das Secret. */
export function verifyTotp(secret: string, token: string): boolean {
if (!secret || !token) return false
const clean = token.replace(/\s+/g, '')
if (!/^\d{6}$/.test(clean)) return false
try {
return authenticator.verify({ token: clean, secret })
} catch {
return false
}
}
/** Erzeugt n Backup-Codes im Format xxxxx-xxxxx (Klartext) + bcrypt-Hashes zur Speicherung. */
export async function generateBackupCodes(n = 10): Promise<{ plain: string[]; hashed: string[] }> {
const plain: string[] = []
for (let i = 0; i < n; i++) {
const raw = randomBytes(5).toString('hex') // 10 Hex-Zeichen
plain.push(`${raw.slice(0, 5)}-${raw.slice(5)}`)
}
const hashed = await Promise.all(plain.map(c => bcrypt.hash(c.replace(/-/g, ''), 10)))
return { plain, hashed }
}
/**
* Prüft einen eingegebenen Backup-Code gegen die gehashte Liste.
* Gibt den Index des passenden Codes zurück (zum Entwerten) oder -1.
*/
export async function verifyBackupCode(hashedList: string[], code: string): Promise<number> {
const clean = (code || '').replace(/[\s-]/g, '').toLowerCase()
if (clean.length < 8) return -1
for (let i = 0; i < hashedList.length; i++) {
if (await bcrypt.compare(clean, hashedList[i])) return i
}
return -1
}

View File

@@ -17,6 +17,7 @@ const PUBLIC_API_PREFIXES = [
'/api/auth/verify-email',
'/api/auth/resend-verification',
'/api/auth/logout',
'/api/auth/mfa-login/',
'/api/contact',
'/api/demo',
'/api/donate',