feat(webui): require math captcha after 3 failed logins per IP

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-08-08 09:32:52 +08:00
parent f91b26670b
commit 9a1775c17d
2 changed files with 126 additions and 1 deletions
+100
View File
@@ -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();
});
});
+26 -1
View File
@@ -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);