docs: implementation plan for login captcha and 401 redirect
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user