fix(security): replace in-memory rate limiting with persistent DB-backed limiter

- Add LoginAttempt model to Prisma schema with migration
- Create rate-limit.ts utility (10 attempts / 15 min window, DB-backed)
- Apply rate limiting to login endpoint (replaces in-memory Map)
- Apply rate limiting to change-password endpoint (previously unprotected)
- Rate limit state survives server restarts and works across multiple instances
This commit is contained in:
2026-05-19 10:10:50 +02:00
parent 45a50dc906
commit f4eedaffe2
5 changed files with 64 additions and 25 deletions
@@ -0,0 +1,7 @@
CREATE TABLE "LoginAttempt" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoginAttempt_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "LoginAttempt_key_createdAt_idx" ON "LoginAttempt"("key", "createdAt");
+8
View File
@@ -255,3 +255,11 @@ model AuditLog {
metadata Json?
createdAt DateTime @default(now())
}
model LoginAttempt {
id String @id @default(cuid())
key String // IP address or identifier
createdAt DateTime @default(now())
@@index([key, createdAt])
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser, verifyPassword, hashPassword } from '@/lib/auth'
import { changePasswordSchema } from '@/lib/validate'
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
export async function POST(request: NextRequest) {
const user = await getCurrentUser()
@@ -9,6 +10,19 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const ip =
request.headers.get('x-forwarded-for') ??
request.headers.get('x-real-ip') ??
'unknown'
const { limited } = await checkRateLimit(ip)
if (limited) {
return NextResponse.json(
{ error: 'Too many attempts. Please try again later.' },
{ status: 429 }
)
}
let body: unknown
try {
body = await request.json()
@@ -28,6 +42,7 @@ export async function POST(request: NextRequest) {
const valid = await verifyPassword(currentPassword, user.passwordHash)
if (!valid) {
await recordAttempt(ip)
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 })
}
+9 -25
View File
@@ -6,33 +6,16 @@ import {
setSessionCookie,
} from '@/lib/auth'
import { loginSchema } from '@/lib/validate'
// Simple in-memory rate limiter
const loginAttempts = new Map<string, { count: number; resetAt: number }>()
function checkRateLimit(ip: string): boolean {
const now = Date.now()
const windowMs = 15 * 60 * 1000 // 15 minutes
const maxAttempts = 10
const record = loginAttempts.get(ip)
if (!record || record.resetAt < now) {
loginAttempts.set(ip, { count: 1, resetAt: now + windowMs })
return true
}
if (record.count >= maxAttempts) {
return false
}
record.count++
return true
}
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
export async function POST(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for') || 'unknown'
const ip =
request.headers.get('x-forwarded-for') ??
request.headers.get('x-real-ip') ??
'unknown'
if (!checkRateLimit(ip)) {
const { limited } = await checkRateLimit(ip)
if (limited) {
return NextResponse.json(
{ error: 'Too many login attempts. Please try again later.' },
{ status: 429 }
@@ -58,11 +41,13 @@ export async function POST(request: NextRequest) {
const user = await prisma.user.findUnique({ where: { email } })
if (!user) {
await recordAttempt(ip)
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
}
const valid = await verifyPassword(password, user.passwordHash)
if (!valid) {
await recordAttempt(ip)
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
}
@@ -75,7 +60,6 @@ export async function POST(request: NextRequest) {
email: user.email,
name: user.name,
role: user.role,
mustChangePassword: user.mustChangePassword,
},
})
}
+25
View File
@@ -0,0 +1,25 @@
import { prisma } from '@/lib/prisma'
const MAX_ATTEMPTS = 10
const WINDOW_MS = 15 * 60 * 1000 // 15 minutes
export async function checkRateLimit(key: string): Promise<{ limited: boolean; remaining: number }> {
const windowStart = new Date(Date.now() - WINDOW_MS)
const count = await prisma.loginAttempt.count({
where: { key, createdAt: { gte: windowStart } },
})
if (count >= MAX_ATTEMPTS) {
return { limited: true, remaining: 0 }
}
return { limited: false, remaining: MAX_ATTEMPTS - count }
}
export async function recordAttempt(key: string): Promise<void> {
await prisma.loginAttempt.create({ data: { key } })
// Clean up old records (keep last 24h only)
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000)
await prisma.loginAttempt.deleteMany({ where: { createdAt: { lt: cutoff } } })
}