feat(webui): per-IP login failure counter

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-08-08 09:24:48 +08:00
parent f5776cdf49
commit 99ddaa6df4
2 changed files with 107 additions and 0 deletions
+57
View File
@@ -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");
});
});
+50
View File
@@ -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();
}