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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user