1 changed files with 609 additions and 0 deletions
@ -0,0 +1,609 @@ |
|||||
|
# Auth Captcha 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:** 为 `POST /api/auth/login` 与 `POST /api/auth/register` 增加自建 SVG 图形验证码与基于内存的 IP 限流;新增 `GET /api/auth/captcha`;登录/注册页展示验证码与换一张。 |
||||
|
|
||||
|
**Architecture:** 使用 `svg-captcha` 生成 SVG 与答案,答案经规范化后存入进程内 `Map`(TTL 默认 3 分钟,校验成功或失败均删除 challenge)。限流复用现有 `assertUnderRateLimit`(`server/utils/simple-rate-limit.ts`)。客户端 IP 统一使用 `getRequestIP(event, { xForwardedFor: true }) ?? "unknown"`,与评论、管理接口一致。 |
||||
|
|
||||
|
**Tech Stack:** Nuxt 4 / Nitro、h3、`svg-captcha`、Bun `bun:test`、现有 `R.success` / `defineWrappedResponseHandler`。 |
||||
|
|
||||
|
**规格依据:** [2026-04-19-auth-captcha-design.md](../specs/2026-04-19-auth-captcha-design.md) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## File Structure Map |
||||
|
|
||||
|
| 路径 | 动作 | 职责 | |
||||
|
|------|------|------| |
||||
|
| `package.json` | Modify | 增加依赖 `svg-captcha` | |
||||
|
| `server/service/captcha/store.ts` | Create | 内存 challenge 存储、`captchaCreate`、`captchaConsume` | |
||||
|
| `server/service/captcha/store.test.ts` | Create | store 单元测试 | |
||||
|
| `server/service/captcha/challenge.ts` | Create | 封装 `svg-captcha` + 调用 `captchaCreate`,导出 `createCaptchaChallenge()` | |
||||
|
| `server/service/captcha/challenge.test.ts` | Create | 可选:对 `createCaptchaChallenge` 做轻量断言(含 `captchaId` 与非空 `imageSvg`) | |
||||
|
| `server/service/captcha/validate-body.ts` | Create | `assertLoginRegisterCaptchaFieldsPresent`(缺少字段时 400) | |
||||
|
| `server/api/auth/captcha.get.ts` | Create | 签发验证码 JSON + `Cache-Control: no-store` + 限流 | |
||||
|
| `server/api/auth/login.post.ts` | Modify | 限流 → 读 body → 校验字段 → `captchaConsume` → `loginUser` | |
||||
|
| `server/api/auth/register.post.ts` | Modify | 限流 → 读 body → 校验字段 → `captchaConsume` → `allowRegister` → `registerUser` | |
||||
|
| `server/utils/auth-api-routes.ts` | Modify | allowlist 增加 `GET /api/auth/captcha` | |
||||
|
| `app/pages/login/index.vue` | Modify | 拉取/刷新验证码、表单项、`img` data URL | |
||||
|
| `app/pages/register/index.vue` | Modify | 同上 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 1: 添加依赖 `svg-captcha` |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Modify: `package.json`(`dependencies`) |
||||
|
|
||||
|
- [ ] **Step 1: 安装依赖** |
||||
|
|
||||
|
在项目根目录执行: |
||||
|
|
||||
|
```bash |
||||
|
bun add svg-captcha |
||||
|
``` |
||||
|
|
||||
|
预期:`package.json` 出现 `"svg-captcha"` 条目且无安装错误。 |
||||
|
|
||||
|
- [ ] **Step 2: 确认类型** |
||||
|
|
||||
|
运行: |
||||
|
|
||||
|
```bash |
||||
|
bunx tsc --noEmit -p server 2>/dev/null || npx nuxt prepare |
||||
|
``` |
||||
|
|
||||
|
若 Nuxt 工程无独立 `server` tsconfig,可仅运行 `bun run postinstall`(即 `nuxt prepare`)。预期:无与 `svg-captcha` 导入相关的报错(该包自带类型声明)。 |
||||
|
|
||||
|
- [ ] **Step 3: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add package.json bun.lock |
||||
|
git commit -m "chore: add svg-captcha for auth captcha" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 2: 实现验证码内存存储与单元测试 |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Create: `server/service/captcha/store.ts` |
||||
|
- Create: `server/service/captcha/store.test.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 编写会失败的测试(行为约定)** |
||||
|
|
||||
|
创建 `server/service/captcha/store.test.ts`: |
||||
|
|
||||
|
```typescript |
||||
|
import { describe, expect, test } from "bun:test"; |
||||
|
import { captchaConsume, captchaCreate } from "./store"; |
||||
|
|
||||
|
describe("captcha store", () => { |
||||
|
test("consume succeeds once then fails", () => { |
||||
|
const { captchaId } = captchaCreate("ab12"); |
||||
|
expect(captchaConsume(captchaId, "ab12")).toBe(true); |
||||
|
expect(captchaConsume(captchaId, "ab12")).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
test("wrong answer invalidates challenge", () => { |
||||
|
const { captchaId } = captchaCreate("ab12"); |
||||
|
expect(captchaConsume(captchaId, "xxxx")).toBe(false); |
||||
|
expect(captchaConsume(captchaId, "ab12")).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
test("trim + lowercase on user input", () => { |
||||
|
const { captchaId } = captchaCreate("ab12"); |
||||
|
expect(captchaConsume(captchaId, " AB12 ")).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
test("length mismatch fails without throwing", () => { |
||||
|
const { captchaId } = captchaCreate("ab12"); |
||||
|
expect(captchaConsume(captchaId, "x")).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
test("expired challenge fails", () => { |
||||
|
const { captchaId } = captchaCreate("ab12", { ttlMs: -1000 }); |
||||
|
expect(captchaConsume(captchaId, "ab12")).toBe(false); |
||||
|
}); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 2: 运行测试确认失败** |
||||
|
|
||||
|
```bash |
||||
|
bun test server/service/captcha/store.test.ts |
||||
|
``` |
||||
|
|
||||
|
预期:导入/函数未定义类失败。 |
||||
|
|
||||
|
- [ ] **Step 3: 实现 `store.ts`** |
||||
|
|
||||
|
创建 `server/service/captcha/store.ts`: |
||||
|
|
||||
|
```typescript |
||||
|
import { randomUUID } from "node:crypto"; |
||||
|
import { timingSafeEqual } from "node:crypto"; |
||||
|
|
||||
|
const DEFAULT_TTL_MS = 180_000; |
||||
|
|
||||
|
type Entry = { answerBuf: Buffer; expiresAt: number }; |
||||
|
|
||||
|
const store = new Map<string, Entry>(); |
||||
|
|
||||
|
function sweepExpired(now: number) { |
||||
|
for (const [id, e] of store) { |
||||
|
if (now > e.expiresAt) { |
||||
|
store.delete(id); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** `answerNormalized` 必须已是规范形式(小写、无首尾空格),与 svg-captcha 生成结果一致 */ |
||||
|
export function captchaCreate( |
||||
|
answerNormalized: string, |
||||
|
options?: { ttlMs?: number }, |
||||
|
): { captchaId: string } { |
||||
|
const now = Date.now(); |
||||
|
sweepExpired(now); |
||||
|
const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS; |
||||
|
const captchaId = randomUUID(); |
||||
|
store.set(captchaId, { |
||||
|
answerBuf: Buffer.from(answerNormalized, "utf8"), |
||||
|
expiresAt: now + ttlMs, |
||||
|
}); |
||||
|
return { captchaId }; |
||||
|
} |
||||
|
|
||||
|
/** 成功或失败均删除 challenge(含答案错误、过期),与规格一致 */ |
||||
|
export function captchaConsume(captchaId: unknown, rawAnswer: unknown): boolean { |
||||
|
if (typeof captchaId !== "string" || typeof rawAnswer !== "string") { |
||||
|
return false; |
||||
|
} |
||||
|
const now = Date.now(); |
||||
|
sweepExpired(now); |
||||
|
const entry = store.get(captchaId); |
||||
|
if (!entry || now > entry.expiresAt) { |
||||
|
if (entry) { |
||||
|
store.delete(captchaId); |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
const guessBuf = Buffer.from(rawAnswer.trim().toLowerCase(), "utf8"); |
||||
|
if (guessBuf.length !== entry.answerBuf.length) { |
||||
|
store.delete(captchaId); |
||||
|
return false; |
||||
|
} |
||||
|
const ok = timingSafeEqual(guessBuf, entry.answerBuf); |
||||
|
store.delete(captchaId); |
||||
|
return ok; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 4: 运行测试确认通过** |
||||
|
|
||||
|
```bash |
||||
|
bun test server/service/captcha/store.test.ts |
||||
|
``` |
||||
|
|
||||
|
预期:全部 PASS。 |
||||
|
|
||||
|
- [ ] **Step 5: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add server/service/captcha/store.ts server/service/captcha/store.test.ts |
||||
|
git commit -m "feat(auth): in-memory captcha store with tests" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 3: 封装 `createCaptchaChallenge` |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Create: `server/service/captcha/challenge.ts` |
||||
|
- Create: `server/service/captcha/challenge.test.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 编写测试** |
||||
|
|
||||
|
创建 `server/service/captcha/challenge.test.ts`(不解析 SVG 文本,避免随 `svg-captcha` 版本脆断;答案消费路径由 `store.test.ts` 覆盖): |
||||
|
|
||||
|
```typescript |
||||
|
import { describe, expect, test } from "bun:test"; |
||||
|
import { createCaptchaChallenge } from "./challenge"; |
||||
|
|
||||
|
describe("createCaptchaChallenge", () => { |
||||
|
test("returns captchaId and non-empty svg", () => { |
||||
|
const { captchaId, imageSvg } = createCaptchaChallenge(); |
||||
|
expect(captchaId).toMatch( |
||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, |
||||
|
); |
||||
|
expect(imageSvg.length).toBeGreaterThan(50); |
||||
|
expect(imageSvg.includes("svg")).toBe(true); |
||||
|
}); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 2: 运行测试确认失败** |
||||
|
|
||||
|
```bash |
||||
|
bun test server/service/captcha/challenge.test.ts |
||||
|
``` |
||||
|
|
||||
|
预期:导入失败。 |
||||
|
|
||||
|
- [ ] **Step 3: 实现 `challenge.ts`** |
||||
|
|
||||
|
创建 `server/service/captcha/challenge.ts`: |
||||
|
|
||||
|
```typescript |
||||
|
import svgCaptcha from "svg-captcha"; |
||||
|
import { captchaCreate } from "./store"; |
||||
|
|
||||
|
/** 与规格一致:易混淆字符已剔除,长度 5 */ |
||||
|
const CHAR_PRESET = "abcdefghjkmnpqrstuvwxyz23456789"; |
||||
|
|
||||
|
export function createCaptchaChallenge(): { captchaId: string; imageSvg: string } { |
||||
|
const { data: imageSvg, text } = svgCaptcha.create({ |
||||
|
size: 5, |
||||
|
noise: 2, |
||||
|
color: true, |
||||
|
charPreset: CHAR_PRESET, |
||||
|
background: "#f4f4f5", |
||||
|
}); |
||||
|
const answerNormalized = text.toLowerCase(); |
||||
|
const { captchaId } = captchaCreate(answerNormalized); |
||||
|
return { captchaId, imageSvg }; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 4: 运行测试** |
||||
|
|
||||
|
```bash |
||||
|
bun test server/service/captcha/challenge.test.ts |
||||
|
``` |
||||
|
|
||||
|
预期:PASS。 |
||||
|
|
||||
|
- [ ] **Step 5: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add server/service/captcha/challenge.ts server/service/captcha/challenge.test.ts |
||||
|
git commit -m "feat(auth): svg captcha challenge factory" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 4: 请求体验证辅助函数 |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Create: `server/service/captcha/validate-body.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 实现** |
||||
|
|
||||
|
创建 `server/service/captcha/validate-body.ts`: |
||||
|
|
||||
|
```typescript |
||||
|
export type AuthCredentialsAndCaptcha = { |
||||
|
username: string; |
||||
|
password: string; |
||||
|
captchaId: string; |
||||
|
captchaAnswer: string; |
||||
|
}; |
||||
|
|
||||
|
export function assertLoginRegisterCaptchaFieldsPresent( |
||||
|
body: unknown, |
||||
|
): asserts body is AuthCredentialsAndCaptcha { |
||||
|
if (typeof body !== "object" || body === null) { |
||||
|
throw createError({ statusCode: 400, statusMessage: "无效请求" }); |
||||
|
} |
||||
|
const b = body as Record<string, unknown>; |
||||
|
if (typeof b.username !== "string" || typeof b.password !== "string") { |
||||
|
throw createError({ statusCode: 400, statusMessage: "无效请求" }); |
||||
|
} |
||||
|
if (typeof b.captchaId !== "string" || b.captchaId.trim() === "") { |
||||
|
throw createError({ statusCode: 400, statusMessage: "请完成验证码" }); |
||||
|
} |
||||
|
if (typeof b.captchaAnswer !== "string" || b.captchaAnswer.trim() === "") { |
||||
|
throw createError({ statusCode: 400, statusMessage: "请完成验证码" }); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 2: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add server/service/captcha/validate-body.ts |
||||
|
git commit -m "feat(auth): validate captcha fields on login/register body" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 5: `GET /api/auth/captcha` |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Create: `server/api/auth/captcha.get.ts` |
||||
|
- Modify: `server/utils/auth-api-routes.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 实现 handler** |
||||
|
|
||||
|
创建 `server/api/auth/captcha.get.ts`: |
||||
|
|
||||
|
```typescript |
||||
|
import { getRequestIP } from "h3"; |
||||
|
import { createCaptchaChallenge } from "#server/service/captcha/challenge"; |
||||
|
import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; |
||||
|
|
||||
|
export default defineWrappedResponseHandler(async (event) => { |
||||
|
setResponseHeader(event, "cache-control", "no-store"); |
||||
|
const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown"; |
||||
|
assertUnderRateLimit(`auth-captcha:${ip}`, 60, 60_000); |
||||
|
|
||||
|
const { captchaId, imageSvg } = createCaptchaChallenge(); |
||||
|
return R.success({ captchaId, imageSvg }); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 2: 更新 allowlist** |
||||
|
|
||||
|
在 `server/utils/auth-api-routes.ts` 的 `API_ALLOWLIST` 中增加一行(保持字母顺序可选): |
||||
|
|
||||
|
```typescript |
||||
|
{ path: "/api/auth/captcha", methods: ["GET"] }, |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 3: 手工验证(开发服务器)** |
||||
|
|
||||
|
运行 `bun run dev`,请求: |
||||
|
|
||||
|
```bash |
||||
|
curl -sI "http://localhost:3000/api/auth/captcha" | tr -d '\r' | grep -i cache-control |
||||
|
``` |
||||
|
|
||||
|
预期:响应头含 `cache-control: no-store`(大小写不敏感)。再: |
||||
|
|
||||
|
```bash |
||||
|
curl -s "http://localhost:3000/api/auth/captcha" |
||||
|
``` |
||||
|
|
||||
|
预期:JSON 含 `captchaId` 与 `imageSvg`,且最外层为项目统一的 `R.success` 包装结构(与现有 API 一致)。 |
||||
|
|
||||
|
- [ ] **Step 4: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add server/api/auth/captcha.get.ts server/utils/auth-api-routes.ts |
||||
|
git commit -m "feat(auth): GET /api/auth/captcha endpoint" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 6: 登录与注册 API 接入验证码 |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Modify: `server/api/auth/login.post.ts` |
||||
|
- Modify: `server/api/auth/register.post.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 修改 `login.post.ts` 完整替换为** |
||||
|
|
||||
|
```typescript |
||||
|
import { getRequestIP } from "h3"; |
||||
|
import { loginUser } from "#server/service/auth"; |
||||
|
import { toPublicAuthError } from "#server/service/auth/errors"; |
||||
|
import { setSessionCookie } from "#server/service/auth/cookie"; |
||||
|
import { captchaConsume } from "#server/service/captcha/store"; |
||||
|
import { assertLoginRegisterCaptchaFieldsPresent } from "#server/service/captcha/validate-body"; |
||||
|
import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; |
||||
|
|
||||
|
export default defineWrappedResponseHandler(async (event) => { |
||||
|
const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown"; |
||||
|
assertUnderRateLimit(`auth-login:${ip}`, 30, 60_000); |
||||
|
|
||||
|
const body = await readBody(event); |
||||
|
assertLoginRegisterCaptchaFieldsPresent(body); |
||||
|
if (!captchaConsume(body.captchaId, body.captchaAnswer)) { |
||||
|
throw createError({ |
||||
|
statusCode: 400, |
||||
|
statusMessage: "验证码错误或已过期,请重试", |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const result = await loginUser({ |
||||
|
username: body.username, |
||||
|
password: body.password, |
||||
|
}); |
||||
|
setSessionCookie(event, result.sessionId); |
||||
|
return R.success({ |
||||
|
user: result.user, |
||||
|
}); |
||||
|
} catch (err) { |
||||
|
throw toPublicAuthError(err); |
||||
|
} |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 2: 修改 `register.post.ts` 完整替换为** |
||||
|
|
||||
|
```typescript |
||||
|
import { getRequestIP } from "h3"; |
||||
|
import { registerUser } from "#server/service/auth"; |
||||
|
import { toPublicAuthError } from "#server/service/auth/errors"; |
||||
|
import { captchaConsume } from "#server/service/captcha/store"; |
||||
|
import { assertLoginRegisterCaptchaFieldsPresent } from "#server/service/captcha/validate-body"; |
||||
|
import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; |
||||
|
|
||||
|
export default defineWrappedResponseHandler(async (event) => { |
||||
|
const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown"; |
||||
|
assertUnderRateLimit(`auth-register:${ip}`, 20, 60_000); |
||||
|
|
||||
|
const body = await readBody(event); |
||||
|
assertLoginRegisterCaptchaFieldsPresent(body); |
||||
|
if (!captchaConsume(body.captchaId, body.captchaAnswer)) { |
||||
|
throw createError({ |
||||
|
statusCode: 400, |
||||
|
statusMessage: "验证码错误或已过期,请重试", |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const allowRegister = await event.context.config.getGlobal("allowRegister"); |
||||
|
if (!allowRegister) { |
||||
|
throw createError({ |
||||
|
statusCode: 403, |
||||
|
statusMessage: "当前已关闭注册", |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const user = await registerUser({ |
||||
|
username: body.username, |
||||
|
password: body.password, |
||||
|
}); |
||||
|
return R.success({ |
||||
|
user, |
||||
|
}); |
||||
|
} catch (err) { |
||||
|
throw toPublicAuthError(err); |
||||
|
} |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 3: 运行单元测试回归** |
||||
|
|
||||
|
```bash |
||||
|
bun test server/service/auth/index.test.ts server/service/captcha/ |
||||
|
``` |
||||
|
|
||||
|
预期:全部 PASS(auth 测试不经过 HTTP,行为不变)。 |
||||
|
|
||||
|
- [ ] **Step 4: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add server/api/auth/login.post.ts server/api/auth/register.post.ts |
||||
|
git commit -m "feat(auth): require captcha on login and register" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 7: 前端登录与注册页 |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Modify: `app/pages/login/index.vue` |
||||
|
- Modify: `app/pages/register/index.vue` |
||||
|
|
||||
|
- [ ] **Step 1: 登录页 — script 增量** |
||||
|
|
||||
|
在 `LoginFormState` 中增加 `captchaAnswer: string`;增加 `captchaIdRef`、`captchaImageSrc` 两个 `ref('')`;增加方法: |
||||
|
|
||||
|
```typescript |
||||
|
const captchaIdRef = ref('') |
||||
|
const captchaImageSrc = ref('') |
||||
|
|
||||
|
async function refreshCaptcha() { |
||||
|
state.captchaAnswer = '' |
||||
|
captchaImageSrc.value = '' |
||||
|
const res = await fetchData<{ captchaId: string; imageSvg: string }>('/api/auth/captcha', { |
||||
|
method: 'GET', |
||||
|
notify: false, |
||||
|
}) |
||||
|
captchaIdRef.value = res.captchaId |
||||
|
captchaImageSrc.value = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(res.imageSvg)}` |
||||
|
} |
||||
|
|
||||
|
onMounted(() => { |
||||
|
void refreshCaptcha() |
||||
|
}) |
||||
|
``` |
||||
|
|
||||
|
说明:`app/composables/useClientApi.ts` 中 `fetchData` 已对 `R.success` 做 `unwrapApiBody`,故 `res` 即为 `{ captchaId, imageSvg }`(与现有 `/api/auth/login` 用法一致)。 |
||||
|
|
||||
|
在 `onSubmit` 的 `body` 中增加: |
||||
|
|
||||
|
```typescript |
||||
|
captchaId: captchaIdRef.value, |
||||
|
captchaAnswer: state.captchaAnswer, |
||||
|
``` |
||||
|
|
||||
|
在 `catch` 与成功跳转前的失败路径:调用 `await refreshCaptcha()`(成功登录无需刷新)。建议在 `catch` 内与 `finally` 中根据是否报错刷新:至少在 `catch` 末尾 `void refreshCaptcha()`。 |
||||
|
|
||||
|
在 `validate` 中为 `captchaAnswer` 增加非空校验。 |
||||
|
|
||||
|
- [ ] **Step 2: 登录页 — template** |
||||
|
|
||||
|
在密码字段与提交按钮之间增加: |
||||
|
|
||||
|
```vue |
||||
|
<UFormField label="验证码" name="captchaAnswer" required> |
||||
|
<div class="flex gap-2 items-center"> |
||||
|
<img |
||||
|
v-if="captchaImageSrc" |
||||
|
:src="captchaImageSrc" |
||||
|
alt="验证码" |
||||
|
class="h-10 rounded border border-default bg-elevated" |
||||
|
/> |
||||
|
<UInput |
||||
|
v-model="state.captchaAnswer" |
||||
|
placeholder="请输入图中字符" |
||||
|
class="flex-1 min-w-0" |
||||
|
autocomplete="off" |
||||
|
/> |
||||
|
<UButton type="button" color="neutral" variant="outline" @click="refreshCaptcha"> |
||||
|
换一张 |
||||
|
</UButton> |
||||
|
</div> |
||||
|
</UFormField> |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 3: 注册页** |
||||
|
|
||||
|
对 `register/index.vue` 做与登录页对称的修改(同一 `refreshCaptcha` 逻辑、`captchaIdRef`、`onMounted`、`onSubmit` body、`catch` 刷新、`validate`)。 |
||||
|
|
||||
|
- [ ] **Step 4: 手工验收** |
||||
|
|
||||
|
启动 `bun run dev`:打开 `/login`、`/register`,确认验证码显示、换一张、错误验证码提示、正确验证码后登录/注册仍可用。 |
||||
|
|
||||
|
- [ ] **Step 5: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add app/pages/login/index.vue app/pages/register/index.vue |
||||
|
git commit -m "feat(auth): captcha UI on login and register pages" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Plan Self-Review |
||||
|
|
||||
|
**1. Spec coverage** |
||||
|
|
||||
|
| 规格章节 | 对应 Task | |
||||
|
|----------|-----------| |
||||
|
| 内存 challenge、TTL、一次性消费 | Task 2 | |
||||
|
| SVG 图形、`imageSvg` + data URL | Task 3、7 | |
||||
|
| `GET /api/auth/captcha`、`no-store`、限流 | Task 5 | |
||||
|
| 登录/注册 body 字段与顺序(先限流再验证码再业务) | Task 6 | |
||||
|
| 注册关闭时先验证码再 403 | Task 6 `register.post.ts` | |
||||
|
| allowlist | Task 5 | |
||||
|
| 错误码 400/429/403 文案 | Task 4–6 | |
||||
|
| IP 策略 | 全文使用 `getRequestIP` 与现有限流工具 | |
||||
|
|
||||
|
**2. Placeholder scan** |
||||
|
|
||||
|
无 TBD/TODO;限流数字为明确常量,可在实现时抽到单文件但非必须。 |
||||
|
|
||||
|
**3. Type consistency** |
||||
|
|
||||
|
`AuthCredentialsAndCaptcha`、`readBody` 与 `loginUser`/`registerUser` 入参字段一致;验证码字段名全程 `captchaId` / `captchaAnswer`。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
**Plan complete and saved to `docs/superpowers/plans/2026-04-19-auth-captcha.md`. Two execution options:** |
||||
|
|
||||
|
1. **Subagent-Driven(推荐)** — 每个 Task 派生子代理并在 Task 之间做简短审查,迭代快。 |
||||
|
2. **Inline Execution** — 在本会话中按 Task 顺序实现,在关键 Task(如 Task 6–7)后人工检查点。 |
||||
|
|
||||
|
你更倾向哪一种? |
||||
Loading…
Reference in new issue