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:
@@ -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");
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 } } })
|
||||
}
|
||||
Reference in New Issue
Block a user