diff --git a/package-lock.json b/package-lock.json index 27acfbc..e0dccc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0e1ed82..2b4c3dc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrate.js b/prisma/migrate.js index 3298549..89cf478 100644 --- a/prisma/migrate.js +++ b/prisma/migrate.js @@ -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 */ } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ea5fe8f..7af6ea1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/api/admin/users/[id]/mfa-reset/route.ts b/src/app/api/admin/users/[id]/mfa-reset/route.ts new file mode 100644 index 0000000..1a2c202 --- /dev/null +++ b/src/app/api/admin/users/[id]/mfa-reset/route.ts @@ -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 }) + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index f07a0c0..5af1fa4 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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({ diff --git a/src/app/api/auth/mfa-login/verify/route.ts b/src/app/api/auth/mfa-login/verify/route.ts new file mode 100644 index 0000000..0c3a646 --- /dev/null +++ b/src/app/api/auth/mfa-login/verify/route.ts @@ -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 }) + } +} diff --git a/src/app/api/auth/mfa/status/route.ts b/src/app/api/auth/mfa/status/route.ts new file mode 100644 index 0000000..22522a2 --- /dev/null +++ b/src/app/api/auth/mfa/status/route.ts @@ -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 }) + } +} diff --git a/src/app/api/auth/mfa/totp/confirm/route.ts b/src/app/api/auth/mfa/totp/confirm/route.ts new file mode 100644 index 0000000..28d22e6 --- /dev/null +++ b/src/app/api/auth/mfa/totp/confirm/route.ts @@ -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 }) + } +} diff --git a/src/app/api/auth/mfa/totp/disable/route.ts b/src/app/api/auth/mfa/totp/disable/route.ts new file mode 100644 index 0000000..dcaab6c --- /dev/null +++ b/src/app/api/auth/mfa/totp/disable/route.ts @@ -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 }) + } +} diff --git a/src/app/api/auth/mfa/totp/setup/route.ts b/src/app/api/auth/mfa/totp/setup/route.ts new file mode 100644 index 0000000..8f17088 --- /dev/null +++ b/src/app/api/auth/mfa/totp/setup/route.ts @@ -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 }) + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 745314b..e045505 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -28,7 +28,10 @@ function LoginForm() { const [resendSuccess, setResendSuccess] = useState(false) const [tenantLogo, setTenantLogo] = useState(null) const [tenantName, setTenantName] = useState(null) - const { login } = useAuth() + // MFA-Zwischenschritt + const [mfaToken, setMfaToken] = useState(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 (
@@ -142,7 +156,32 @@ function LoginForm() {
)} -
+ {mfaToken && ( + +
+ + setMfaCode(e.target.value)} + disabled={isLoading} + /> +

Code aus deiner Authenticator-App. Kein Zugriff darauf? Verwende einen deiner Backup-Codes.

+
+ + +
+ )} + +
(null) @@ -120,6 +121,9 @@ export default function SettingsPage() {
+ {/* Zwei-Faktor-Authentisierung */} + + {/* Danger Zone */}

diff --git a/src/components/providers/auth-provider.tsx b/src/components/providers/auth-provider.tsx index 047c01e..2dd8aaa 100644 --- a/src/components/providers/auth-provider.tsx +++ b/src/components/providers/auth-provider.tsx @@ -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 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 ( - + {children} ) diff --git a/src/components/settings/mfa-settings.tsx b/src/components/settings/mfa-settings.tsx new file mode 100644 index 0000000..59a15fd --- /dev/null +++ b/src/components/settings/mfa-settings.tsx @@ -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(null) + const [setup, setSetup] = useState<{ qr: string; secret: string } | null>(null) + const [code, setCode] = useState('') + const [busy, setBusy] = useState(false) + const [backupCodes, setBackupCodes] = useState(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 ( +
+

+ + Zwei-Faktor-Authentisierung (2FA) +

+

+ Zusätzlicher Schutz beim Login mit einer Authenticator-App. Empfohlen — besonders für Administratoren. +

+ + {/* Einmalig angezeigte Backup-Codes */} + {backupCodes && ( +
+

Backup-Codes — jetzt sicher speichern!

+

Jeder Code funktioniert einmal, falls du keinen Zugriff auf die App hast. Sie werden nur dieses eine Mal angezeigt.

+
+ {backupCodes.map(c => {c})} +
+
+ + +
+
+ )} + + {!status ? ( +
+ ) : status.totpEnabled ? ( +
+
+ + Authenticator-App aktiv + + {status.backupCodesRemaining} Backup-Codes übrig +
+ +
+ ) : setup ? ( +
+

1. Scanne den QR-Code mit Google Authenticator, Microsoft Authenticator, Authy o. Ä.:

+ {setup.qr && ( + // eslint-disable-next-line @next/next/no-img-element + TOTP QR-Code + )} +

Manuell: {setup.secret}

+

2. Gib den angezeigten 6-stelligen Code ein:

+
+ setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} className="font-mono tracking-widest w-40" /> + + +
+
+ ) : ( + + )} +
+ ) +} + +// Vermeidet Namenskollision mit dem lokalen confirm()-State-Setter oben. +function confirm2(msg: string): boolean { + return typeof window !== 'undefined' ? window.confirm(msg) : true +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index ef7cd6e..aa13da4 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -46,42 +46,26 @@ export async function getSession(): Promise { 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 { + 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 { + 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 { return await bcrypt.hash(password, 12) } diff --git a/src/lib/mfa.ts b/src/lib/mfa.ts new file mode 100644 index 0000000..b394eb7 --- /dev/null +++ b/src/lib/mfa.ts @@ -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 { + 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 +} diff --git a/src/middleware.ts b/src/middleware.ts index 3be3d87..1166d0a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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',