diff --git a/src/lib/server/rate-limit.ts b/src/lib/server/rate-limit.ts new file mode 100644 index 0000000..534e22d --- /dev/null +++ b/src/lib/server/rate-limit.ts @@ -0,0 +1,74 @@ +import { db } from './db/index.js'; +import { loginAttempts } from './db/schema.js'; +import { and, eq, gte, sql } from 'drizzle-orm'; + +const MAX_ATTEMPTS = 5; +const WINDOW_MS = 60 * 1000; // 1 minute +const LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes + +export function checkRateLimit( + ip: string, + email: string +): { allowed: boolean; retryAfter?: number } { + const windowStart = new Date(Date.now() - LOCKOUT_MS); + + // Count failed attempts from this IP in the lockout window + const result = db + .select({ count: sql`count(*)` }) + .from(loginAttempts) + .where( + and( + eq(loginAttempts.ip, ip), + eq(loginAttempts.success, false), + gte(loginAttempts.timestamp, windowStart) + ) + ) + .get(); + + const failedCount = result?.count ?? 0; + + if (failedCount >= MAX_ATTEMPTS) { + // Find the most recent failed attempt to calculate retry-after + const latest = db + .select({ timestamp: loginAttempts.timestamp }) + .from(loginAttempts) + .where( + and( + eq(loginAttempts.ip, ip), + eq(loginAttempts.success, false), + gte(loginAttempts.timestamp, windowStart) + ) + ) + .orderBy(sql`${loginAttempts.timestamp} DESC`) + .limit(1) + .get(); + + if (latest) { + const retryAfter = Math.ceil( + (latest.timestamp.getTime() + LOCKOUT_MS - Date.now()) / 1000 + ); + return { allowed: false, retryAfter: Math.max(retryAfter, 1) }; + } + return { allowed: false, retryAfter: 900 }; + } + + return { allowed: true }; +} + +export function recordLoginAttempt(ip: string, email: string, success: boolean): void { + db.insert(loginAttempts) + .values({ + ip, + email, + success, + timestamp: new Date() + }) + .run(); +} + +export function clearOldAttempts(): void { + const cutoff = new Date(Date.now() - LOCKOUT_MS * 2); + db.delete(loginAttempts) + .where(sql`${loginAttempts.timestamp} < ${cutoff}`) + .run(); +} diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index 4d973af..98ef2d0 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -1,19 +1,32 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import { verifyPassword, createSession } from '$lib/server/auth.js'; +import { checkRateLimit, recordLoginAttempt } from '$lib/server/rate-limit.js'; -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { + const ip = getClientAddress(); const { email, password } = await request.json(); if (!email || !password) { throw error(400, 'Email and password are required'); } + const rateCheck = checkRateLimit(ip, email); + if (!rateCheck.allowed) { + return json( + { error: 'Too many login attempts. Please try again later.', retryAfter: rateCheck.retryAfter }, + { status: 429, headers: { 'Retry-After': String(rateCheck.retryAfter) } } + ); + } + const user = await verifyPassword(email, password); if (!user) { + recordLoginAttempt(ip, email, false); throw error(401, 'Invalid email or password'); } + recordLoginAttempt(ip, email, true); + const token = await createSession(user.id); cookies.set('session', token, { path: '/',