From 2acf899465afca0c8417dc4cd65048e26512b62e Mon Sep 17 00:00:00 2001 From: Mathieu Date: Sat, 6 Jun 2026 17:24:19 +0200 Subject: [PATCH] feat: rate limiting connexion (5 essais / 15 min) --- src/__tests__/lib/rate-limit.test.ts | 36 ++++++++++++++++++++++++++++ src/lib/rate-limit.ts | 32 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 src/__tests__/lib/rate-limit.test.ts create mode 100644 src/lib/rate-limit.ts diff --git a/src/__tests__/lib/rate-limit.test.ts b/src/__tests__/lib/rate-limit.test.ts new file mode 100644 index 0000000..3dbe1ee --- /dev/null +++ b/src/__tests__/lib/rate-limit.test.ts @@ -0,0 +1,36 @@ +import { + checkRateLimit, + recordFailedAttempt, + clearAttempts, + _resetForTests, +} from '@/lib/rate-limit' + +beforeEach(() => _resetForTests()) + +describe('checkRateLimit', () => { + it('autorise une nouvelle clé', () => { + expect(checkRateLimit('ip:user@test.com')).toBe(true) + }) + + it('autorise après moins de 5 tentatives', () => { + const key = 'ip:user@test.com' + recordFailedAttempt(key) + recordFailedAttempt(key) + recordFailedAttempt(key) + recordFailedAttempt(key) + expect(checkRateLimit(key)).toBe(true) + }) + + it('bloque après 5 tentatives', () => { + const key = 'ip:user@test.com' + for (let i = 0; i < 5; i++) recordFailedAttempt(key) + expect(checkRateLimit(key)).toBe(false) + }) + + it('débloque après clearAttempts', () => { + const key = 'ip:user@test.com' + for (let i = 0; i < 5; i++) recordFailedAttempt(key) + clearAttempts(key) + expect(checkRateLimit(key)).toBe(true) + }) +}) diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..a0f9362 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,32 @@ +const MAX_ATTEMPTS = 5 +const BLOCK_DURATION_MS = 15 * 60 * 1000 // 15 minutes + +type Entry = { count: number; blockedAt: number | null } +let attempts = new Map() + +export function checkRateLimit(key: string): boolean { + const now = Date.now() + const entry = attempts.get(key) + if (!entry) return true + if (entry.blockedAt !== null) { + if (now - entry.blockedAt < BLOCK_DURATION_MS) return false + attempts.delete(key) + } + return true +} + +export function recordFailedAttempt(key: string): void { + const entry = attempts.get(key) ?? { count: 0, blockedAt: null } + entry.count += 1 + if (entry.count >= MAX_ATTEMPTS) entry.blockedAt = Date.now() + attempts.set(key, entry) +} + +export function clearAttempts(key: string): void { + attempts.delete(key) +} + +/** Réinitialisation pour les tests uniquement */ +export function _resetForTests(): void { + attempts = new Map() +}