mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
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:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
b90ab18ea7
commit
e3d3d5c499
@@ -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();
|
||||
}
|
||||
@@ -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: '/',
|
||||
|
||||
Reference in New Issue
Block a user