diff --git a/app/prisma/migrations/20250519123456_add_login_attempts/migration.sql b/app/prisma/migrations/20250519123456_add_login_attempts/migration.sql new file mode 100644 index 0000000..8acb3c1 --- /dev/null +++ b/app/prisma/migrations/20250519123456_add_login_attempts/migration.sql @@ -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"); diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index 475b2aa..575b555 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -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]) +} diff --git a/app/src/app/api/auth/change-password/route.ts b/app/src/app/api/auth/change-password/route.ts index d1cdcdd..961f26f 100644 --- a/app/src/app/api/auth/change-password/route.ts +++ b/app/src/app/api/auth/change-password/route.ts @@ -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 }) } diff --git a/app/src/app/api/auth/login/route.ts b/app/src/app/api/auth/login/route.ts index c7f9fa2..8cd7c7c 100644 --- a/app/src/app/api/auth/login/route.ts +++ b/app/src/app/api/auth/login/route.ts @@ -6,33 +6,16 @@ import { setSessionCookie, } from '@/lib/auth' import { loginSchema } from '@/lib/validate' - -// Simple in-memory rate limiter -const loginAttempts = new Map() - -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, }, }) } diff --git a/app/src/lib/rate-limit.ts b/app/src/lib/rate-limit.ts new file mode 100644 index 0000000..764d732 --- /dev/null +++ b/app/src/lib/rate-limit.ts @@ -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 { + 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 } } }) +}