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