feat(webui): per-IP login failure counter
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, Entry>();
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user