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>
122 lines
3.7 KiB
TypeScript
122 lines
3.7 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import { jwtVerify } from 'jose'
|
|
import { JWT_SECRET } from './lib/jwt-secret'
|
|
|
|
// Routes that require authentication
|
|
const PROTECTED_ROUTES = ['/app', '/settings', '/admin', '/konto']
|
|
|
|
// Routes that should redirect to /app if already logged in
|
|
const AUTH_ROUTES = ['/login', '/register']
|
|
|
|
// API routes that are public (no auth needed)
|
|
const PUBLIC_API_PREFIXES = [
|
|
'/api/auth/login',
|
|
'/api/auth/register',
|
|
'/api/auth/forgot-password',
|
|
'/api/auth/reset-password',
|
|
'/api/auth/verify-email',
|
|
'/api/auth/resend-verification',
|
|
'/api/auth/logout',
|
|
'/api/auth/mfa-login/',
|
|
'/api/contact',
|
|
'/api/demo',
|
|
'/api/donate',
|
|
'/api/rapports/',
|
|
'/api/tenants/by-slug/',
|
|
'/api/share/',
|
|
]
|
|
|
|
export async function middleware(req: NextRequest) {
|
|
const { pathname } = req.nextUrl
|
|
const token = req.cookies.get('auth-token')?.value
|
|
|
|
// Verify token if present
|
|
let user: any = null
|
|
if (token) {
|
|
try {
|
|
const { payload } = await jwtVerify(token, JWT_SECRET)
|
|
user = payload.user
|
|
} catch {
|
|
// Invalid/expired token — clear it
|
|
const response = NextResponse.redirect(new URL('/login', req.url))
|
|
response.cookies.delete('auth-token')
|
|
// Only redirect if accessing protected routes
|
|
if (PROTECTED_ROUTES.some(r => pathname.startsWith(r))) {
|
|
return response
|
|
}
|
|
}
|
|
}
|
|
|
|
// Protected routes: redirect to login if not authenticated
|
|
if (PROTECTED_ROUTES.some(r => pathname.startsWith(r))) {
|
|
if (!user) {
|
|
const loginUrl = new URL('/login', req.url)
|
|
loginUrl.searchParams.set('redirect', pathname)
|
|
return NextResponse.redirect(loginUrl)
|
|
}
|
|
|
|
// Admin routes: only SERVER_ADMIN and TENANT_ADMIN
|
|
if (pathname.startsWith('/admin') && user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') {
|
|
return NextResponse.redirect(new URL('/app', req.url))
|
|
}
|
|
}
|
|
|
|
// Auth routes: redirect to /app if already logged in
|
|
if (AUTH_ROUTES.some(r => pathname.startsWith(r))) {
|
|
if (user) {
|
|
return NextResponse.redirect(new URL('/app', req.url))
|
|
}
|
|
}
|
|
|
|
// API routes: check auth for non-public endpoints
|
|
if (pathname.startsWith('/api/') && !PUBLIC_API_PREFIXES.some(p => pathname.startsWith(p))) {
|
|
if (!user) {
|
|
// Allow /api/auth/me to return null (used for auth check)
|
|
if (pathname === '/api/auth/me') {
|
|
return NextResponse.next()
|
|
}
|
|
// Allow /api/icons GET (public for symbol loading)
|
|
if (pathname === '/api/icons' && req.method === 'GET') {
|
|
return NextResponse.next()
|
|
}
|
|
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
|
}
|
|
}
|
|
|
|
// Security: block common attack paths
|
|
if (
|
|
pathname.includes('..') ||
|
|
pathname.includes('.env') ||
|
|
pathname.includes('wp-admin') ||
|
|
pathname.includes('wp-login') ||
|
|
pathname.includes('.php') ||
|
|
pathname.includes('xmlrpc') ||
|
|
pathname.match(/\.(sql|bak|config|log|ini)$/i)
|
|
) {
|
|
return new NextResponse(null, { status: 404 })
|
|
}
|
|
|
|
const res = NextResponse.next()
|
|
|
|
// Private/authentifizierte Bereiche: nicht indexieren und nicht cachen.
|
|
if (PROTECTED_ROUTES.some(r => pathname.startsWith(r))) {
|
|
res.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive')
|
|
res.headers.set('Cache-Control', 'no-store, max-age=0, must-revalidate')
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
/*
|
|
* Match all request paths except:
|
|
* - _next/static (static files)
|
|
* - _next/image (image optimization)
|
|
* - favicon.ico, sitemap.xml, robots.txt
|
|
* - public files (images, sw.js, etc.)
|
|
*/
|
|
'/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|icons/|sw.js|manifest.json|opengraph-image).*)',
|
|
],
|
|
}
|