docs: implementation plan for login captcha and 401 redirect

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-08-08 08:58:01 +08:00
parent 6e7a1e4898
commit ce01a6022b
@@ -0,0 +1,873 @@
# Login Captcha + Session-Expiry Redirect Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 会话过期(API 401)时自动跳转登录页;同一 IP 登录失败 3 次后要求数学算式验证码。
**Architecture:** 前端通过全局 fetch 拦截器捕获 401 并跳转 `/login?next=...`;验证码为无状态 HMAC 签名令牌(无需服务端存储);失败计数为按 IP 的内存 Map。
**Tech Stack:** Next.js App Router, React, Vitest, node:crypto (HMAC-SHA256)。
## Global Constraints
- 测试命令:`npx vitest run <file>`(package.json `"test": "vitest run"`)。
- 服务端测试文件需 `vi.mock("server-only", () => ({}));`(参照 `src/app/api/auth/login/route.test.ts`)。
- 认证环境变量参照现有测试:`WEBUI_AUTH_ENABLED=true`、`WEBUI_AUTH_SECRET`(≥32 字符)、`WEBUI_AUTH_USERNAME`、`WEBUI_AUTH_PASSWORD`、`EVOSCIENTIST_DATA_DIR`(临时目录)。
- 登录失败阈值:3 次;验证码有效期:5 分钟;失败计数条目 TTL:15 分钟。
- 设计文档:`docs/superpowers/specs/2026-08-08-login-captcha-session-redirect-design.md`。
---
### Task 1: 401 全局 fetch 拦截跳转
**Files:**
- Create: `src/lib/authRedirect.ts`
- Test: `src/lib/authRedirect.test.ts`
- Create: `src/app/components/AuthRedirectInstaller.tsx`
- Modify: `src/app/layout.tsx`(在 ThemeProvider 内挂载 installer)
**Interfaces:**
- Produces:
- `shouldRedirectFor(url: string, status: number, pathname: string): boolean`
- `installAuthRedirect(): () => void`(返回卸载函数)
- `<AuthRedirectInstaller />` 客户端组件(无 props,渲染 null)
- [ ] **Step 1: Write the failing test**
`src/lib/authRedirect.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { shouldRedirectFor } from "./authRedirect";
describe("shouldRedirectFor", () => {
it("redirects on 401 for same-origin API paths", () => {
expect(shouldRedirectFor("/api/conversations", 401, "/")).toBe(true);
expect(
shouldRedirectFor("http://localhost/api/usage/summary", 401, "/chat")
).toBe(true);
});
it("ignores non-401 statuses", () => {
expect(shouldRedirectFor("/api/conversations", 200, "/")).toBe(false);
expect(shouldRedirectFor("/api/conversations", 500, "/")).toBe(false);
});
it("ignores non-API paths", () => {
expect(shouldRedirectFor("/login", 401, "/")).toBe(false);
expect(shouldRedirectFor("/favicon.ico", 401, "/")).toBe(false);
});
it("ignores the login endpoint itself", () => {
expect(shouldRedirectFor("/api/auth/login", 401, "/login")).toBe(false);
expect(shouldRedirectFor("/api/auth/login", 401, "/")).toBe(false);
});
it("ignores 401 while already on the login page", () => {
expect(shouldRedirectFor("/api/auth/me", 401, "/login")).toBe(false);
});
it("ignores unparseable urls", () => {
expect(shouldRedirectFor("http://[", 401, "/")).toBe(false);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/lib/authRedirect.test.ts`
Expected: FAIL — `Cannot find module './authRedirect'`
- [ ] **Step 3: Write minimal implementation**
`src/lib/authRedirect.ts`:
```ts
export function shouldRedirectFor(
url: string,
status: number,
pathname: string
): boolean {
if (status !== 401) return false;
if (pathname.startsWith("/login")) return false;
let path: string;
try {
path = new URL(url, "http://localhost").pathname;
} catch {
return false;
}
if (!path.startsWith("/api/")) return false;
if (path === "/api/auth/login") return false;
return true;
}
// Wraps window.fetch so any same-origin API 401 sends the browser to the
// login page, preserving the current location as ?next. Returns a restore
// function. Middleware only covers document navigations; fetch calls
// (health checks, chat, polling) would otherwise fail silently as "offline".
export function installAuthRedirect(): () => void {
const original = window.fetch;
const wrapped: typeof window.fetch = async (input, init) => {
const response = await original(input, init);
try {
const url =
typeof input === "string"
? input
: input instanceof Request
? input.url
: input.toString();
const resolved = new URL(url, window.location.origin);
if (
resolved.origin === window.location.origin &&
shouldRedirectFor(url, response.status, window.location.pathname)
) {
const next = encodeURIComponent(
window.location.pathname + window.location.search
);
window.location.replace(`/login?next=${next}`);
}
} catch {
// Redirect failures must not break the caller's response handling.
}
return response;
};
window.fetch = wrapped;
return () => {
window.fetch = original;
};
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/lib/authRedirect.test.ts`
Expected: PASS (6 tests)
- [ ] **Step 5: Mount the installer in the root layout**
Create `src/app/components/AuthRedirectInstaller.tsx`:
```tsx
"use client";
import { useEffect } from "react";
import { installAuthRedirect } from "@/lib/authRedirect";
export function AuthRedirectInstaller() {
useEffect(() => installAuthRedirect(), []);
return null;
}
```
In `src/app/layout.tsx`, add the import and render inside `ThemeProvider`:
```tsx
import { AuthRedirectInstaller } from "./components/AuthRedirectInstaller";
// ...
<ThemeProvider>
<AuthRedirectInstaller />
{children}
<ThemedToaster />
</ThemeProvider>
```
- [ ] **Step 6: Typecheck**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 7: Commit**
```bash
git add src/lib/authRedirect.ts src/lib/authRedirect.test.ts src/app/components/AuthRedirectInstaller.tsx src/app/layout.tsx
git commit -m "feat(webui): redirect to /login on any API 401 response"
```
---
### Task 2: 数学算式验证码库
**Files:**
- Create: `src/lib/server/captcha.ts`
- Test: `src/lib/server/captcha.test.ts`
**Interfaces:**
- Consumes: `authSecret()` from `@/lib/auth`(返回 `string | null`)
- Produces:
- `createCaptcha(): { id: string; question: string }` — `question` 形如 `"3 + 7 = ?"`
- `verifyCaptcha(id: unknown, answer: unknown): boolean`
- [ ] **Step 1: Write the failing test**
`src/lib/server/captcha.test.ts`:
```ts
import { afterEach, describe, expect, it, vi } from "vitest";
process.env.WEBUI_AUTH_SECRET = "test-secret-with-at-least-32-characters";
const { createCaptcha, verifyCaptcha } = await import("./captcha");
afterEach(() => {
vi.useRealTimers();
});
describe("captcha", () => {
it("round-trips: correct answer verifies", () => {
const { id, question } = createCaptcha();
const match = question.match(/^(\d+) \+ (\d+) = \?$/);
expect(match).not.toBeNull();
const answer = String(Number(match![1]) + Number(match![2]));
expect(verifyCaptcha(id, answer)).toBe(true);
});
it("rejects a wrong answer", () => {
const { id, question } = createCaptcha();
const match = question.match(/^(\d+) \+ (\d+) = \?/)!;
const wrong = String(Number(match[1]) + Number(match[2]) + 1);
expect(verifyCaptcha(id, wrong)).toBe(false);
});
it("rejects a tampered payload", () => {
const { id } = createCaptcha();
const [payload] = id.split(".");
const forged = `${payload}.${"a".repeat(43)}`;
expect(verifyCaptcha(forged, "2")).toBe(false);
});
it("rejects an expired captcha", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-08T00:00:00Z"));
const { id, question } = createCaptcha();
const match = question.match(/^(\d+) \+ (\d+) = \?/)!;
const answer = String(Number(match[1]) + Number(match[2]));
vi.setSystemTime(new Date("2026-08-08T00:06:00Z"));
expect(verifyCaptcha(id, answer)).toBe(false);
});
it("rejects malformed inputs", () => {
expect(verifyCaptcha(undefined, "2")).toBe(false);
expect(verifyCaptcha("not-a-token", "2")).toBe(false);
expect(verifyCaptcha(123, "2")).toBe(false);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/lib/server/captcha.test.ts`
Expected: FAIL — `Cannot find module './captcha'`
- [ ] **Step 3: Write minimal implementation**
`src/lib/server/captcha.ts`:
```ts
import { createHmac, randomInt, timingSafeEqual } from "node:crypto";
import { authSecret } from "@/lib/auth";
const CAPTCHA_TTL_MS = 5 * 60 * 1000;
interface CaptchaPayload {
a: number;
b: number;
exp: number;
}
function sign(encoded: string, secret: string): string {
return createHmac("sha256", secret).update(encoded).digest("base64url");
}
export function createCaptcha(): { id: string; question: string } {
const secret = authSecret();
if (!secret) throw new Error("WEBUI_AUTH_SECRET is not configured.");
const a = randomInt(1, 10);
const b = randomInt(1, 10);
const payload: CaptchaPayload = { a, b, exp: Date.now() + CAPTCHA_TTL_MS };
const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url");
return {
id: `${encoded}.${sign(encoded, secret)}`,
question: `${a} + ${b} = ?`,
};
}
export function verifyCaptcha(id: unknown, answer: unknown): boolean {
const secret = authSecret();
if (!secret || typeof id !== "string" || typeof answer !== "string") {
return false;
}
const [encoded, signature] = id.split(".");
if (!encoded || !signature) return false;
const expected = Buffer.from(sign(encoded, secret));
const provided = Buffer.from(signature);
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
return false;
}
let payload: Partial<CaptchaPayload>;
try {
payload = JSON.parse(
Buffer.from(encoded, "base64url").toString("utf8")
) as Partial<CaptchaPayload>;
} catch {
return false;
}
if (
typeof payload.a !== "number" ||
typeof payload.b !== "number" ||
typeof payload.exp !== "number" ||
payload.exp < Date.now()
) {
return false;
}
return answer.trim() === String(payload.a + payload.b);
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/lib/server/captcha.test.ts`
Expected: PASS (5 tests)
- [ ] **Step 5: Commit**
```bash
git add src/lib/server/captcha.ts src/lib/server/captcha.test.ts
git commit -m "feat(webui): stateless signed math-captcha tokens"
```
---
### Task 3: 按 IP 的登录失败计数
**Files:**
- Create: `src/lib/server/loginFailures.ts`
- Test: `src/lib/server/loginFailures.test.ts`
**Interfaces:**
- Produces:
- `FAILURE_THRESHOLD = 3`(导出常量)
- `recordFailure(ip: string): void`
- `shouldRequireCaptcha(ip: string): boolean`
- `clearFailures(ip: string): void`
- `clientIp(request: NextRequest): string`
- `clearAllFailuresForTests(): void`
- [ ] **Step 1: Write the failing test**
`src/lib/server/loginFailures.test.ts`:
```ts
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");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/lib/server/loginFailures.test.ts`
Expected: FAIL — `Cannot find module './loginFailures'`
- [ ] **Step 3: Write minimal implementation**
`src/lib/server/loginFailures.ts`:
```ts
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();
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/lib/server/loginFailures.test.ts`
Expected: PASS (5 tests)
- [ ] **Step 5: Commit**
```bash
git add src/lib/server/loginFailures.ts src/lib/server/loginFailures.test.ts
git commit -m "feat(webui): per-IP login failure counter"
```
---
### Task 4: 验证码下发接口
**Files:**
- Create: `src/app/api/auth/captcha/route.ts`
- Modify: `src/proxy.ts:75-87`(`isPublicPath` 增加 `/api/auth/captcha`)
**Interfaces:**
- Consumes: `createCaptcha()` from `@/lib/server/captcha`(Task 2);`isAuthenticationEnabled()` from `@/lib/auth`
- Produces: `GET /api/auth/captcha` → `200 { id: string; question: string }`,`Cache-Control: no-store`;未启用认证时 404
- [ ] **Step 1: Create the route**
`src/app/api/auth/captcha/route.ts`:
```ts
import { NextResponse } from "next/server";
import { isAuthenticationEnabled } from "@/lib/auth";
import { createCaptcha } from "@/lib/server/captcha";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
if (!isAuthenticationEnabled()) {
return NextResponse.json(
{ error: "WebUI authentication is disabled." },
{ status: 404, headers: { "Cache-Control": "no-store" } }
);
}
const { id, question } = createCaptcha();
return NextResponse.json(
{ id, question },
{ headers: { "Cache-Control": "no-store" } }
);
}
```
- [ ] **Step 2: Allow unauthenticated access in the middleware**
In `src/proxy.ts`, add to `isPublicPath`:
```ts
pathname === "/api/auth/captcha" ||
```
放在 `pathname === "/api/auth/login" ||` 之后。
- [ ] **Step 3: Typecheck**
Run: `npx tsc --noEmit`
Expected: no errors
- [ ] **Step 4: Commit**
```bash
git add src/app/api/auth/captcha/route.ts src/proxy.ts
git commit -m "feat(webui): GET /api/auth/captcha issues math captchas"
```
---
### Task 5: 登录路由集成验证码与失败计数
**Files:**
- Modify: `src/app/api/auth/login/route.ts`
- Test: `src/app/api/auth/login/route.test.ts`(追加 describe 块)
**Interfaces:**
- Consumes: Task 2 `verifyCaptcha`;Task 3 `clientIp`、`recordFailure`、`shouldRequireCaptcha`、`clearFailures`、`clearAllFailuresForTests`
- Produces: 登录响应 JSON 增加可选字段 `requiresCaptcha: boolean`(401 与验证码相关 400 时携带)
- [ ] **Step 1: Write the failing test**
在 `src/app/api/auth/login/route.test.ts` 顶部 import 区追加:
```ts
const { clearAllFailuresForTests } = await import("@/lib/server/loginFailures");
const { createCaptcha } = await import("@/lib/server/captcha");
```
文件末尾追加:
```ts
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();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/app/api/auth/login/route.test.ts`
Expected: FAIL — 新用例中 `requiresCaptcha` 断言失败(响应无此字段)
- [ ] **Step 3: Implement the login route changes**
在 `src/app/api/auth/login/route.ts` 顶部 import 追加:
```ts
import { verifyCaptcha } from "@/lib/server/captcha";
import {
clearFailures,
clientIp,
recordFailure,
shouldRequireCaptcha,
} from "@/lib/server/loginFailures";
```
请求体类型与解析追加(在 `rememberMe?: unknown;` 之后):
```ts
captchaId?: unknown;
captchaAnswer?: unknown;
```
在 `try {` 之前插入验证码门槛逻辑:
```ts
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 }
);
}
}
```
修改凭据失败分支(`if (!user)`):
```ts
const user = verifyCredentials(username, password);
if (!user) {
recordFailure(ip);
return NextResponse.json(
{
error: "Invalid username or password.",
requiresCaptcha: shouldRequireCaptcha(ip),
},
{ status: 401, headers: NO_STORE }
);
}
clearFailures(ip);
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run src/app/api/auth/login/route.test.ts`
Expected: PASS(原有 3 个 + 新增 5 个)
- [ ] **Step 5: Commit**
```bash
git add src/app/api/auth/login/route.ts src/app/api/auth/login/route.test.ts
git commit -m "feat(webui): require math captcha after 3 failed logins per IP"
```
---
### Task 6: 登录页验证码 UI
**Files:**
- Modify: `src/app/login/page.tsx`
**Interfaces:**
- Consumes: `GET /api/auth/captcha`(Task 4);登录响应 `requiresCaptcha`(Task 5)
- Produces: 无导出(页面组件)
- [ ] **Step 1: Add captcha state and fetch helper**
在组件内既有 state 之后追加:
```tsx
const [captchaRequired, setCaptchaRequired] = useState(false);
const [captchaId, setCaptchaId] = useState("");
const [captchaQuestion, setCaptchaQuestion] = useState("");
const [captchaAnswer, setCaptchaAnswer] = useState("");
```
组件内追加刷新函数:
```tsx
const refreshCaptcha = async () => {
setCaptchaAnswer("");
const response = await fetch("/api/auth/captcha", { cache: "no-store" });
if (!response.ok) return;
const body = (await response.json()) as { id?: string; question?: string };
if (body.id && body.question) {
setCaptchaId(body.id);
setCaptchaQuestion(body.question);
}
};
```
- [ ] **Step 2: Wire into the submit flow**
响应类型追加字段:
```ts
const body = (await response.json().catch(() => ({}))) as {
error?: string;
redirectTo?: string;
requiresCaptcha?: boolean;
};
```
在错误处理前追加验证码门槛处理(`if (!response.ok) throw ...` 替换为):
```ts
if (!response.ok) {
if (body.requiresCaptcha) {
setCaptchaRequired(true);
await refreshCaptcha();
}
throw new Error(body.error || "Unable to sign in.");
}
```
请求体追加(`body: JSON.stringify({...})`):
```ts
body: JSON.stringify({
username,
password,
next: returnPath(),
rememberMe,
...(captchaRequired ? { captchaId, captchaAnswer } : {}),
}),
```
- [ ] **Step 3: Render the captcha block**
在 remember-me `</label>` 之后追加:
```tsx
{captchaRequired && (
<label className="block space-y-1.5 text-sm font-medium">
<span>
Verification: {captchaQuestion}{" "}
<button
type="button"
onClick={() => void refreshCaptcha()}
className="text-muted-foreground underline underline-offset-2"
>
New question
</button>
</span>
<Input
name="captchaAnswer"
inputMode="numeric"
autoComplete="off"
value={captchaAnswer}
onChange={(event) => setCaptchaAnswer(event.target.value)}
disabled={submitting}
required
/>
</label>
)}
```
- [ ] **Step 4: Manual verification**
Run: `npm run dev`,用错误密码连续登录 3 次(同一浏览器),确认第 3 次失败后页面出现算式验证码区块、"New question" 可换题、错误答案报 "Incorrect verification code."、正确答案+正确密码登录成功。
- [ ] **Step 5: Commit**
```bash
git add src/app/login/page.tsx
git commit -m "feat(webui): captcha input on login page after repeated failures"
```
---
### Task 7: 全量回归
- [ ] **Step 1: Run the full test suite**
Run: `npm test`
Expected: 全部通过
- [ ] **Step 2: Typecheck**
Run: `npx tsc --noEmit`
Expected: no errors