diff --git a/src/lib/server/loginFailures.test.ts b/src/lib/server/loginFailures.test.ts new file mode 100644 index 0000000..2f39982 --- /dev/null +++ b/src/lib/server/loginFailures.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { + clearAllFailuresForTests, + clearFailures, + clientIp, + FAILURE_THRESHOLD, + recordFailure, + shouldRequireCaptcha, +} from "./loginFailures"; + +afterEach(() => { + vi.useRealTimers(); + clearAllFailuresForTests(); +}); + +describe("loginFailures", () => { + it("requires captcha only after FAILURE_THRESHOLD failures", () => { + const ip = "1.2.3.4"; + for (let i = 0; i < FAILURE_THRESHOLD - 1; i++) recordFailure(ip); + expect(shouldRequireCaptcha(ip)).toBe(false); + recordFailure(ip); + expect(shouldRequireCaptcha(ip)).toBe(true); + }); + + it("tracks IPs independently", () => { + for (let i = 0; i < FAILURE_THRESHOLD; i++) recordFailure("1.1.1.1"); + expect(shouldRequireCaptcha("1.1.1.1")).toBe(true); + expect(shouldRequireCaptcha("2.2.2.2")).toBe(false); + }); + + it("clearFailures resets the counter", () => { + const ip = "3.3.3.3"; + for (let i = 0; i < FAILURE_THRESHOLD; i++) recordFailure(ip); + clearFailures(ip); + expect(shouldRequireCaptcha(ip)).toBe(false); + }); + + it("expires entries after 15 minutes idle", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-08T00:00:00Z")); + const ip = "4.4.4.4"; + for (let i = 0; i < FAILURE_THRESHOLD; i++) recordFailure(ip); + expect(shouldRequireCaptcha(ip)).toBe(true); + vi.setSystemTime(new Date("2026-08-08T00:16:00Z")); + expect(shouldRequireCaptcha(ip)).toBe(false); + }); + + it("clientIp reads the first x-forwarded-for hop and falls back to unknown", () => { + const req = new NextRequest("http://localhost/api/auth/login", { + headers: { "x-forwarded-for": "9.9.9.9, 10.0.0.1" }, + }); + expect(clientIp(req)).toBe("9.9.9.9"); + const bare = new NextRequest("http://localhost/api/auth/login"); + expect(clientIp(bare)).toBe("unknown"); + }); +}); diff --git a/src/lib/server/loginFailures.ts b/src/lib/server/loginFailures.ts new file mode 100644 index 0000000..b36263c --- /dev/null +++ b/src/lib/server/loginFailures.ts @@ -0,0 +1,50 @@ +import type { NextRequest } from "next/server"; + +export const FAILURE_THRESHOLD = 3; +const ENTRY_TTL_MS = 15 * 60 * 1000; + +interface Entry { + count: number; + touchedAt: number; +} + +// In-memory, per-process: counts reset on restart, which is an accepted +// trade-off (single-instance deployment). +const entries = new Map(); + +function prune(): void { + const now = Date.now(); + for (const [ip, entry] of entries) { + if (now - entry.touchedAt > ENTRY_TTL_MS) entries.delete(ip); + } +} + +export function recordFailure(ip: string): void { + prune(); + const existing = entries.get(ip); + entries.set(ip, { count: (existing?.count ?? 0) + 1, touchedAt: Date.now() }); +} + +export function shouldRequireCaptcha(ip: string): boolean { + const entry = entries.get(ip); + if (!entry) return false; + if (Date.now() - entry.touchedAt > ENTRY_TTL_MS) { + entries.delete(ip); + return false; + } + return entry.count >= FAILURE_THRESHOLD; +} + +export function clearFailures(ip: string): void { + entries.delete(ip); +} + +export function clientIp(request: NextRequest): string { + const forwarded = request.headers.get("x-forwarded-for"); + const first = forwarded?.split(",")[0]?.trim(); + return first || "unknown"; +} + +export function clearAllFailuresForTests(): void { + entries.clear(); +}