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:
74
package-lock.json
generated
74
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.7.3",
|
"version": "1.7.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.7.3",
|
"version": "1.7.8",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"minio": "^7.1.3",
|
"minio": "^7.1.3",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"nodemailer": "^6.9.8",
|
"nodemailer": "^6.9.8",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"rdndmb-html5-to-touch": "^9.0.0",
|
"rdndmb-html5-to-touch": "^9.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -1624,6 +1625,56 @@
|
|||||||
"node": ">=12.4.0"
|
"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": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.139.0",
|
"version": "0.139.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||||
@@ -8653,6 +8704,17 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/overlap-area": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/overlap-area/-/overlap-area-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/overlap-area/-/overlap-area-1.1.0.tgz",
|
||||||
@@ -10582,6 +10644,14 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/through2": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.7.8",
|
"version": "1.8.0",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
"minio": "^7.1.3",
|
"minio": "^7.1.3",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"nodemailer": "^6.9.8",
|
"nodemailer": "^6.9.8",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"rdndmb-html5-to-touch": "^9.0.0",
|
"rdndmb-html5-to-touch": "^9.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
@@ -185,6 +185,22 @@ async function migrate() {
|
|||||||
`CREATE INDEX IF NOT EXISTS legal_acceptances_doc_idx ON legal_acceptances("documentType", "documentVersion")`,
|
`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 "ipAddress" TEXT`,
|
||||||
`ALTER TABLE legal_acceptances ADD COLUMN IF NOT EXISTS "userAgent" 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) {
|
for (const sql of tableMigrations) {
|
||||||
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ }
|
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ }
|
||||||
|
|||||||
@@ -126,6 +126,12 @@ model User {
|
|||||||
resetToken String? @unique
|
resetToken String? @unique
|
||||||
resetTokenExpiry DateTime?
|
resetTokenExpiry DateTime?
|
||||||
lastLoginAt 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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -135,10 +141,29 @@ model User {
|
|||||||
upgradeRequests UpgradeRequest[]
|
upgradeRequests UpgradeRequest[]
|
||||||
rapports Rapport[]
|
rapports Rapport[]
|
||||||
legalAcceptances LegalAcceptance[]
|
legalAcceptances LegalAcceptance[]
|
||||||
|
webauthnCredentials WebAuthnCredential[]
|
||||||
|
|
||||||
@@map("users")
|
@@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 ──────────
|
// ─── Rechtsdokumente & revisionssichere Zustimmungen ──────────
|
||||||
// Dokumenttypen (als String gehalten, damit die idempotente Raw-SQL-Migration einfach bleibt):
|
// Dokumenttypen (als String gehalten, damit die idempotente Raw-SQL-Migration einfach bleibt):
|
||||||
// TERMS | PRIVACY | ORGANIZATION_DECLARATION | RESPONSIBLE_USE | DATA_PROCESSING_AGREEMENT
|
// TERMS | PRIVACY | ORGANIZATION_DECLARATION | RESPONSIBLE_USE | DATA_PROCESSING_AGREEMENT
|
||||||
|
|||||||
38
src/app/api/admin/users/[id]/mfa-reset/route.ts
Normal file
38
src/app/api/admin/users/[id]/mfa-reset/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { cookies } from 'next/headers'
|
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 { loginSchema } from '@/lib/validations'
|
||||||
import { prisma } from '@/lib/db'
|
import { prisma } from '@/lib/db'
|
||||||
import { loginLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
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
|
// Update lastLoginAt
|
||||||
try {
|
try {
|
||||||
await (prisma as any).user.update({
|
await (prisma as any).user.update({
|
||||||
|
|||||||
64
src/app/api/auth/mfa-login/verify/route.ts
Normal file
64
src/app/api/auth/mfa-login/verify/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/app/api/auth/mfa/status/route.ts
Normal file
28
src/app/api/auth/mfa/status/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/app/api/auth/mfa/totp/confirm/route.ts
Normal file
46
src/app/api/auth/mfa/totp/confirm/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/api/auth/mfa/totp/disable/route.ts
Normal file
22
src/app/api/auth/mfa/totp/disable/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/api/auth/mfa/totp/setup/route.ts
Normal file
31
src/app/api/auth/mfa/totp/setup/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,10 @@ function LoginForm() {
|
|||||||
const [resendSuccess, setResendSuccess] = useState(false)
|
const [resendSuccess, setResendSuccess] = useState(false)
|
||||||
const [tenantLogo, setTenantLogo] = useState<string | null>(null)
|
const [tenantLogo, setTenantLogo] = useState<string | null>(null)
|
||||||
const [tenantName, setTenantName] = 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 router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
@@ -52,6 +55,18 @@ function LoginForm() {
|
|||||||
}
|
}
|
||||||
}, [tenantSlug])
|
}, [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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -59,26 +74,11 @@ function LoginForm() {
|
|||||||
const result = await login(email, password, rememberMe)
|
const result = await login(email, password, rememberMe)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast({
|
toast({ title: 'Erfolgreich angemeldet', description: 'Willkommen bei Lageplan!' })
|
||||||
title: 'Erfolgreich angemeldet',
|
await doRedirect()
|
||||||
description: 'Willkommen bei Lageplan!',
|
} else if (result.mfaRequired && result.mfaToken) {
|
||||||
})
|
// Zweiter Faktor nötig — Code-Schritt anzeigen.
|
||||||
// Redirect: use explicit redirect param, or tenant slug, or /app
|
setMfaToken(result.mfaToken)
|
||||||
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')
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: 'Anmeldung fehlgeschlagen',
|
title: 'Anmeldung fehlgeschlagen',
|
||||||
@@ -90,6 +90,20 @@ function LoginForm() {
|
|||||||
setIsLoading(false)
|
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 (
|
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="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">
|
<div className="w-full max-w-md">
|
||||||
@@ -142,7 +156,32 @@ function LoginForm() {
|
|||||||
</div>
|
</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">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="email">E-Mail</Label>
|
<Label htmlFor="email">E-Mail</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useToast } from '@/components/ui/use-toast'
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, Building2, Trash2, AlertTriangle, Loader2, Shield, Users, FileText,
|
ArrowLeft, Building2, Trash2, AlertTriangle, Loader2, Shield, Users, FileText,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { MfaSettings } from '@/components/settings/mfa-settings'
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [tenant, setTenant] = useState<any>(null)
|
const [tenant, setTenant] = useState<any>(null)
|
||||||
@@ -120,6 +121,9 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Zwei-Faktor-Authentisierung */}
|
||||||
|
<MfaSettings />
|
||||||
|
|
||||||
{/* Danger Zone */}
|
{/* 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">
|
<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">
|
<h2 className="text-lg font-semibold text-red-700 dark:text-red-400 mb-2 flex items-center gap-2">
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ interface AuthContextType {
|
|||||||
user: User | null
|
user: User | null
|
||||||
tenant: TenantInfo | null
|
tenant: TenantInfo | null
|
||||||
loading: boolean
|
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>
|
logout: () => Promise<void>
|
||||||
canEdit: () => boolean
|
canEdit: () => boolean
|
||||||
isAdmin: () => boolean
|
isAdmin: () => boolean
|
||||||
@@ -77,12 +78,35 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return { success: true }
|
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' }
|
return { success: false, error: data.error || 'Login fehlgeschlagen' }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: 'Verbindungsfehler' }
|
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 () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' })
|
await fetch('/api/auth/logout', { method: 'POST' })
|
||||||
@@ -106,7 +130,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
135
src/components/settings/mfa-settings.tsx
Normal file
135
src/components/settings/mfa-settings.tsx
Normal 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
|
||||||
|
}
|
||||||
@@ -46,42 +46,26 @@ export async function getSession(): Promise<UserPayload | null> {
|
|||||||
return await verifyToken(token)
|
return await verifyToken(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(
|
/**
|
||||||
email: string,
|
* Baut die Session-Payload für einen (bereits authentifizierten) Benutzer per ID.
|
||||||
password: string
|
* 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 }> {
|
): Promise<{ success: boolean; user?: UserPayload; error?: string }> {
|
||||||
const user = await (prisma.user.findUnique({
|
const user = await (prisma.user.findUnique({
|
||||||
where: { email },
|
where: { id: userId },
|
||||||
select: {
|
select: { id: true, email: true, name: true, role: true, emailVerified: true, isActive: true },
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
name: true,
|
|
||||||
password: true,
|
|
||||||
role: true,
|
|
||||||
emailVerified: true,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
}) as any)
|
}) as any)
|
||||||
|
|
||||||
if (!user) {
|
if (!user) return { success: false, error: 'Benutzer nicht gefunden' }
|
||||||
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.isActive === false) {
|
if (user.isActive === false) {
|
||||||
return { success: false, error: 'Ihr Konto wurde deaktiviert. Bitte kontaktieren Sie den Administrator.' }
|
return { success: false, error: 'Ihr Konto wurde deaktiviert. Bitte kontaktieren Sie den Administrator.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track email verification status (allow login regardless)
|
const emailVerified = user.emailVerified !== false
|
||||||
const emailVerified = (user as any).emailVerified !== false
|
|
||||||
|
|
||||||
// Get first tenant membership for non-server-admins
|
|
||||||
let tenantId: string | undefined
|
let tenantId: string | undefined
|
||||||
let tenantSlug: string | undefined
|
let tenantSlug: string | undefined
|
||||||
if ((user.role as string) !== 'SERVER_ADMIN') {
|
if ((user.role as string) !== 'SERVER_ADMIN') {
|
||||||
@@ -91,7 +75,6 @@ export async function login(
|
|||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
})
|
})
|
||||||
if (membership) {
|
if (membership) {
|
||||||
// Check if tenant is active
|
|
||||||
if (!membership.tenant.isActive) {
|
if (!membership.tenant.isActive) {
|
||||||
return { success: false, error: 'Ihr Mandant wurde gesperrt. Bitte kontaktieren Sie den Administrator.' }
|
return { success: false, error: 'Ihr Mandant wurde gesperrt. Bitte kontaktieren Sie den Administrator.' }
|
||||||
}
|
}
|
||||||
@@ -109,10 +92,65 @@ export async function login(
|
|||||||
tenantSlug,
|
tenantSlug,
|
||||||
emailVerified,
|
emailVerified,
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, user: userPayload }
|
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> {
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
return await bcrypt.hash(password, 12)
|
return await bcrypt.hash(password, 12)
|
||||||
}
|
}
|
||||||
|
|||||||
54
src/lib/mfa.ts
Normal file
54
src/lib/mfa.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ const PUBLIC_API_PREFIXES = [
|
|||||||
'/api/auth/verify-email',
|
'/api/auth/verify-email',
|
||||||
'/api/auth/resend-verification',
|
'/api/auth/resend-verification',
|
||||||
'/api/auth/logout',
|
'/api/auth/logout',
|
||||||
|
'/api/auth/mfa-login/',
|
||||||
'/api/contact',
|
'/api/contact',
|
||||||
'/api/demo',
|
'/api/demo',
|
||||||
'/api/donate',
|
'/api/donate',
|
||||||
|
|||||||
Reference in New Issue
Block a user