Browse Source

docs: add auth captcha implementation plan

Made-with: Cursor
main
npmrun 11 hours ago
parent
commit
9ae8e46777
  1. 609
      docs/superpowers/plans/2026-04-19-auth-captcha.md

609
docs/superpowers/plans/2026-04-19-auth-captcha.md

@ -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…
Cancel
Save