v1.0.4: Security hardening - rate limiting, middleware, HSTS, password strength, anti-enumeration
This commit is contained in:
@@ -31,6 +31,18 @@ const nextConfig = {
|
|||||||
key: 'Cross-Origin-Opener-Policy',
|
key: 'Cross-Origin-Opener-Policy',
|
||||||
value: 'same-origin',
|
value: 'same-origin',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'Strict-Transport-Security',
|
||||||
|
value: 'max-age=63072000; includeSubDomains; preload',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'X-DNS-Prefetch-Control',
|
||||||
|
value: 'on',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'X-XSS-Protection',
|
||||||
|
value: '1; mode=block',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'Content-Security-Policy',
|
key: 'Content-Security-Policy',
|
||||||
value: [
|
value: [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.0.3",
|
"version": "1.0.4",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -2,9 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/db'
|
import { prisma } from '@/lib/db'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { rateLimit, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||||
|
|
||||||
|
const changePwLimiter = rateLimit({ id: 'change-pw', max: 5, windowSeconds: 60 * 15 })
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = getClientIp(req)
|
||||||
|
const rl = changePwLimiter.check(ip)
|
||||||
|
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||||
|
|
||||||
const user = await getSession()
|
const user = await getSession()
|
||||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||||
|
|
||||||
@@ -14,8 +21,8 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Beide Felder sind erforderlich' }, { status: 400 })
|
return NextResponse.json({ error: 'Beide Felder sind erforderlich' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 6) {
|
if (newPassword.length < 8) {
|
||||||
return NextResponse.json({ error: 'Neues Kennwort muss mindestens 6 Zeichen lang sein' }, { status: 400 })
|
return NextResponse.json({ error: 'Neues Kennwort muss mindestens 8 Zeichen lang sein' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbUser = await (prisma as any).user.findUnique({
|
const dbUser = await (prisma as any).user.findUnique({
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ import { prisma } from '@/lib/db'
|
|||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import { cookies } from 'next/headers'
|
import { cookies } from 'next/headers'
|
||||||
|
import { deleteAccountLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||||
|
|
||||||
// POST: User deletes their own account
|
// POST: User deletes their own account
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = getClientIp(req)
|
||||||
|
const rl = deleteAccountLimiter.check(ip)
|
||||||
|
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||||
|
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { prisma } from '@/lib/db'
|
import { prisma } from '@/lib/db'
|
||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
import { sendEmail, getSmtpConfig } from '@/lib/email'
|
import { sendEmail, getSmtpConfig } from '@/lib/email'
|
||||||
|
import { forgotPasswordLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = getClientIp(req)
|
||||||
|
const rl = forgotPasswordLimiter.check(ip)
|
||||||
|
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||||
|
|
||||||
const { email } = await req.json()
|
const { email } = await req.json()
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return NextResponse.json({ error: 'E-Mail erforderlich' }, { status: 400 })
|
return NextResponse.json({ error: 'E-Mail erforderlich' }, { status: 400 })
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ import { cookies } from 'next/headers'
|
|||||||
import { login, createToken } from '@/lib/auth'
|
import { login, createToken } 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'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = getClientIp(request)
|
||||||
|
const rl = loginLimiter.check(ip)
|
||||||
|
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
const validated = loginSchema.safeParse(body)
|
const validated = loginSchema.safeParse(body)
|
||||||
|
|||||||
@@ -4,16 +4,21 @@ import { hashPassword } from '@/lib/auth'
|
|||||||
import { sendEmail } from '@/lib/email'
|
import { sendEmail } from '@/lib/email'
|
||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { registerLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
organizationName: z.string().min(2, 'Organisationsname zu kurz').max(200),
|
organizationName: z.string().min(2, 'Organisationsname zu kurz').max(200),
|
||||||
name: z.string().min(2, 'Name zu kurz').max(200),
|
name: z.string().min(2, 'Name zu kurz').max(200),
|
||||||
email: z.string().email('Ungültige E-Mail-Adresse'),
|
email: z.string().email('Ungültige E-Mail-Adresse'),
|
||||||
password: z.string().min(6, 'Passwort muss mindestens 6 Zeichen haben'),
|
password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen haben'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = getClientIp(req)
|
||||||
|
const rl = registerLimiter.check(ip)
|
||||||
|
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||||
|
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
const data = registerSchema.parse(body)
|
const data = registerSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { prisma } from '@/lib/db'
|
import { prisma } from '@/lib/db'
|
||||||
import { sendEmail } from '@/lib/email'
|
import { sendEmail } from '@/lib/email'
|
||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
|
import { resendVerificationLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = getClientIp(req)
|
||||||
|
const rl = resendVerificationLimiter.check(ip)
|
||||||
|
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||||
|
|
||||||
const { email } = await req.json()
|
const { email } = await req.json()
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/db'
|
import { prisma } from '@/lib/db'
|
||||||
import { hashPassword } from '@/lib/auth'
|
import { hashPassword } from '@/lib/auth'
|
||||||
|
import { resetPasswordLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = getClientIp(req)
|
||||||
|
const rl = resetPasswordLimiter.check(ip)
|
||||||
|
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||||
|
|
||||||
const { token, password } = await req.json()
|
const { token, password } = await req.json()
|
||||||
if (!token || !password) {
|
if (!token || !password) {
|
||||||
return NextResponse.json({ error: 'Token und Passwort erforderlich' }, { status: 400 })
|
return NextResponse.json({ error: 'Token und Passwort erforderlich' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 8) {
|
||||||
return NextResponse.json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' }, { status: 400 })
|
return NextResponse.json({ error: 'Passwort muss mindestens 8 Zeichen lang sein' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await (prisma as any).user.findFirst({
|
const user = await (prisma as any).user.findFirst({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { prisma } from '@/lib/db'
|
import { prisma } from '@/lib/db'
|
||||||
import { sendEmail, getSmtpConfig } from '@/lib/email'
|
import { sendEmail, getSmtpConfig } from '@/lib/email'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { contactLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||||
|
|
||||||
const contactSchema = z.object({
|
const contactSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
@@ -23,6 +24,10 @@ async function getContactEmail(): Promise<string> {
|
|||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = getClientIp(req)
|
||||||
|
const rl = contactLimiter.check(ip)
|
||||||
|
if (!rl.success) return rateLimitResponse(rl.resetAt)
|
||||||
|
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
const data = contactSchema.parse(body)
|
const data = contactSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export default function RegisterPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 8) {
|
||||||
toast({ title: 'Passwort muss mindestens 6 Zeichen haben', variant: 'destructive' })
|
toast({ title: 'Passwort muss mindestens 8 Zeichen haben', variant: 'destructive' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ export default function RegisterPage() {
|
|||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Mindestens 6 Zeichen"
|
placeholder="Mindestens 8 Zeichen"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ function ResetPasswordForm() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 8) {
|
||||||
setError('Passwort muss mindestens 6 Zeichen lang sein.')
|
setError('Passwort muss mindestens 8 Zeichen lang sein.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
@@ -108,7 +108,7 @@ function ResetPasswordForm() {
|
|||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Min. 6 Zeichen"
|
placeholder="Min. 8 Zeichen"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -64,12 +64,12 @@ export async function login(
|
|||||||
}) 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)
|
const isValidPassword = await bcrypt.compare(password, user.password)
|
||||||
if (!isValidPassword) {
|
if (!isValidPassword) {
|
||||||
return { success: false, error: 'Ungültiges Passwort' }
|
return { success: false, error: 'E-Mail oder Passwort falsch' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track email verification status (allow login regardless)
|
// Track email verification status (allow login regardless)
|
||||||
|
|||||||
107
src/lib/rate-limit.ts
Normal file
107
src/lib/rate-limit.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// In-memory rate limiter for API endpoints
|
||||||
|
// Tracks request counts per IP within sliding windows
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number
|
||||||
|
resetAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const stores = new Map<string, Map<string, RateLimitEntry>>()
|
||||||
|
|
||||||
|
interface RateLimitConfig {
|
||||||
|
/** Unique identifier for this limiter (e.g. 'login', 'register') */
|
||||||
|
id: string
|
||||||
|
/** Maximum requests allowed within the window */
|
||||||
|
max: number
|
||||||
|
/** Window duration in seconds */
|
||||||
|
windowSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RateLimitResult {
|
||||||
|
success: boolean
|
||||||
|
remaining: number
|
||||||
|
resetAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStore(id: string): Map<string, RateLimitEntry> {
|
||||||
|
if (!stores.has(id)) {
|
||||||
|
stores.set(id, new Map())
|
||||||
|
}
|
||||||
|
return stores.get(id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic cleanup of expired entries (every 5 minutes)
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [, store] of stores) {
|
||||||
|
for (const [key, entry] of store) {
|
||||||
|
if (now > entry.resetAt) {
|
||||||
|
store.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000)
|
||||||
|
|
||||||
|
export function rateLimit(config: RateLimitConfig) {
|
||||||
|
const store = getStore(config.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
check(ip: string): RateLimitResult {
|
||||||
|
const now = Date.now()
|
||||||
|
const key = ip
|
||||||
|
const entry = store.get(key)
|
||||||
|
|
||||||
|
// No entry or expired → fresh window
|
||||||
|
if (!entry || now > entry.resetAt) {
|
||||||
|
store.set(key, {
|
||||||
|
count: 1,
|
||||||
|
resetAt: now + config.windowSeconds * 1000,
|
||||||
|
})
|
||||||
|
return { success: true, remaining: config.max - 1, resetAt: now + config.windowSeconds * 1000 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within window
|
||||||
|
entry.count++
|
||||||
|
if (entry.count > config.max) {
|
||||||
|
return { success: false, remaining: 0, resetAt: entry.resetAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, remaining: config.max - entry.count, resetAt: entry.resetAt }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-configured limiters for different endpoints
|
||||||
|
export const loginLimiter = rateLimit({ id: 'login', max: 5, windowSeconds: 60 * 15 }) // 5 attempts per 15 min
|
||||||
|
export const registerLimiter = rateLimit({ id: 'register', max: 3, windowSeconds: 60 * 60 }) // 3 per hour
|
||||||
|
export const forgotPasswordLimiter = rateLimit({ id: 'forgot-pw', max: 3, windowSeconds: 60 * 15 }) // 3 per 15 min
|
||||||
|
export const resendVerificationLimiter = rateLimit({ id: 'resend-verify', max: 3, windowSeconds: 60 * 15 })
|
||||||
|
export const contactLimiter = rateLimit({ id: 'contact', max: 5, windowSeconds: 60 * 60 }) // 5 per hour
|
||||||
|
export const deleteAccountLimiter = rateLimit({ id: 'delete-acct', max: 3, windowSeconds: 60 * 15 })
|
||||||
|
export const resetPasswordLimiter = rateLimit({ id: 'reset-pw', max: 5, windowSeconds: 60 * 15 })
|
||||||
|
|
||||||
|
/** Extract client IP from request headers */
|
||||||
|
export function getClientIp(req: Request): string {
|
||||||
|
const forwarded = req.headers.get('x-forwarded-for')
|
||||||
|
if (forwarded) {
|
||||||
|
return forwarded.split(',')[0].trim()
|
||||||
|
}
|
||||||
|
const realIp = req.headers.get('x-real-ip')
|
||||||
|
if (realIp) return realIp
|
||||||
|
return '127.0.0.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper: create a 429 response with retry-after header */
|
||||||
|
export function rateLimitResponse(resetAt: number) {
|
||||||
|
const retryAfter = Math.ceil((resetAt - Date.now()) / 1000)
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.' }),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': String(retryAfter),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
114
src/middleware.ts
Normal file
114
src/middleware.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { jwtVerify } from 'jose'
|
||||||
|
|
||||||
|
const JWT_SECRET = new TextEncoder().encode(
|
||||||
|
process.env.NEXTAUTH_SECRET || 'dev-only-fallback-do-not-use-in-production'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Routes that require authentication
|
||||||
|
const PROTECTED_ROUTES = ['/app', '/settings', '/admin']
|
||||||
|
|
||||||
|
// 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/contact',
|
||||||
|
'/api/demo',
|
||||||
|
'/api/donate',
|
||||||
|
'/api/rapports/',
|
||||||
|
'/api/tenants/by-slug/',
|
||||||
|
]
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
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).*)',
|
||||||
|
],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user