From 9a1775c17d34cc0451bff686ab1326745fcd000c Mon Sep 17 00:00:00 2001 From: m4 Date: Sat, 8 Aug 2026 09:32:52 +0800 Subject: [PATCH] feat(webui): require math captcha after 3 failed logins per IP Co-Authored-By: Claude Opus 4.7 --- src/app/api/auth/login/route.test.ts | 100 +++++++++++++++++++++++++++ src/app/api/auth/login/route.ts | 27 +++++++- 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/app/api/auth/login/route.test.ts b/src/app/api/auth/login/route.test.ts index f6bb2d1..6eef2a7 100644 --- a/src/app/api/auth/login/route.test.ts +++ b/src/app/api/auth/login/route.test.ts @@ -28,6 +28,8 @@ delete process.env.WEBUI_AUTH_SESSION_TTL_HOURS; const { POST } = await import("./route"); const { decodeSessionPayload } = await import("@/lib/server/auth"); const { closeUserStoreForTests } = await import("@/lib/server/userStore"); +const { clearAllFailuresForTests } = await import("@/lib/server/loginFailures"); +const { createCaptcha } = await import("@/lib/server/captcha"); const REMEMBER_TTL = 30 * 24 * 60 * 60; const SESSION_TTL = 12 * 60 * 60; @@ -81,3 +83,101 @@ describe("POST /api/auth/login rememberMe", () => { expect(setCookie).not.toContain("Max-Age"); }); }); + +describe("POST /api/auth/login captcha", () => { + const IP = "203.0.113.10"; + + function ipLoginRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-forwarded-for": IP, + }, + body: JSON.stringify(body), + }); + } + + async function failLogins(times: number) { + for (let i = 0; i < times; i++) { + const res = await POST( + ipLoginRequest({ username: "admin", password: "wrong" }) + ); + expect(res.status).toBe(401); + } + } + + it("does not require a captcha before the failure threshold", async () => { + clearAllFailuresForTests(); + const res = await POST( + ipLoginRequest({ username: "admin", password: "wrong" }) + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.requiresCaptcha).toBe(false); + clearAllFailuresForTests(); + }); + + it("flags requiresCaptcha once the threshold is reached", async () => { + clearAllFailuresForTests(); + await failLogins(2); + const res = await POST( + ipLoginRequest({ username: "admin", password: "wrong" }) + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.requiresCaptcha).toBe(true); + clearAllFailuresForTests(); + }); + + it("rejects login without a captcha once required", async () => { + clearAllFailuresForTests(); + await failLogins(3); + const res = await POST( + ipLoginRequest({ username: "admin", password: "bootstrap-password" }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.requiresCaptcha).toBe(true); + clearAllFailuresForTests(); + }); + + it("rejects a wrong captcha answer", async () => { + clearAllFailuresForTests(); + await failLogins(3); + const { id } = createCaptcha(); + const res = await POST( + ipLoginRequest({ + username: "admin", + password: "bootstrap-password", + captchaId: id, + captchaAnswer: "0", + }) + ); + expect(res.status).toBe(400); + clearAllFailuresForTests(); + }); + + it("logs in with a correct captcha and clears the counter", async () => { + clearAllFailuresForTests(); + await failLogins(3); + const { id, question } = createCaptcha(); + const match = question.match(/^(\d+) \+ (\d+) = \?/)!; + const res = await POST( + ipLoginRequest({ + username: "admin", + password: "bootstrap-password", + captchaId: id, + captchaAnswer: String(Number(match[1]) + Number(match[2])), + }) + ); + expect(res.status).toBe(200); + // Counter cleared: a fresh failure starts from zero again. + const again = await POST( + ipLoginRequest({ username: "admin", password: "wrong" }) + ); + const body = await again.json(); + expect(body.requiresCaptcha).toBe(false); + clearAllFailuresForTests(); + }); +}); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 10ca3d0..5966a64 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -12,6 +12,13 @@ import { recordLogin, verifyCredentials, } from "@/lib/server/auth"; +import { verifyCaptcha } from "@/lib/server/captcha"; +import { + clearFailures, + clientIp, + recordFailure, + shouldRequireCaptcha, +} from "@/lib/server/loginFailures"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -37,6 +44,8 @@ export async function POST(request: NextRequest) { password?: unknown; next?: unknown; rememberMe?: unknown; + captchaId?: unknown; + captchaAnswer?: unknown; } | null; const username = typeof body?.username === "string" ? body.username.trim() : ""; @@ -53,14 +62,30 @@ export async function POST(request: NextRequest) { ); } + const ip = clientIp(request); + if (shouldRequireCaptcha(ip)) { + if (!verifyCaptcha(body?.captchaId, body?.captchaAnswer)) { + recordFailure(ip); + return NextResponse.json( + { error: "Incorrect verification code.", requiresCaptcha: true }, + { status: 400, headers: NO_STORE } + ); + } + } + try { const user = verifyCredentials(username, password); if (!user) { + recordFailure(ip); return NextResponse.json( - { error: "Invalid username or password." }, + { + error: "Invalid username or password.", + requiresCaptcha: shouldRequireCaptcha(ip), + }, { status: 401, headers: NO_STORE } ); } + clearFailures(ip); const rememberMe = body?.rememberMe === true; const ttlSeconds = rememberMe ? rememberTtlSeconds() : sessionTtlSeconds(); const { token, payload } = createSession(user.username, user.role, ttlSeconds);