feat(auth): login rate limiting

5 failed attempts per IP triggers 15-minute lockout. Uses loginAttempts
table added in Phase 1. Returns 429 with Retry-After header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-09 01:10:37 +01:00
committed by Fred Cabassut
co-authored by Claude Opus 4.6
parent 5bed796396
commit 5aaebfefe5
2 changed files with 88 additions and 1 deletions
+74
View File
@@ -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<number>`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();
}
+14 -1
View File
@@ -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: '/',