From 6e7a1e4898464c79ce3c442124cca0ee0c41c5c6 Mon Sep 17 00:00:00 2001 From: m4 Date: Sat, 8 Aug 2026 08:50:44 +0800 Subject: [PATCH] docs: design spec for login captcha and session-expiry redirect Co-Authored-By: Claude Opus 4.7 --- ...8-login-captcha-session-redirect-design.md | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-08-login-captcha-session-redirect-design.md diff --git a/docs/superpowers/specs/2026-08-08-login-captcha-session-redirect-design.md b/docs/superpowers/specs/2026-08-08-login-captcha-session-redirect-design.md new file mode 100644 index 0000000..9a1aabc --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-login-captcha-session-redirect-design.md @@ -0,0 +1,81 @@ +# 登录验证码与会话过期自动跳登录页 — 设计 + +日期:2026-08-08 + +## 背景 + +- 会话过期后,前端只在 health check 上显示 "Offline",用户不知道需要重新登录。 +- 登录接口无防爆破机制,需要数字验证码。 + +## 需求 + +1. 任何 API 返回 401 时,浏览器自动跳转 `/login?next=<当前路径>`,而不是显示离线。 +2. 同一 IP 登录失败 3 次后,登录表单要求输入数学算式验证码(如 "3 + 7 = ?");登录成功后清零。 + +## 设计 + +### 1. 401 全局拦截跳转 + +新增 `src/lib/authRedirect.ts`: + +- 导出 `installAuthRedirect()`,在应用根组件(`src/app/layout.tsx` 下的客户端组件)挂载时调用一次,幂等。 +- 包装 `window.fetch`:仅拦截同源且路径以 `/api/` 开头的请求。 +- 响应状态为 401 时执行 `window.location.replace("/login?next=" + encodeURIComponent(location.pathname + location.search))`。 +- 排除情况: + - 请求路径为 `/api/auth/login`(登录失败本身返回 401,不应跳转)。 + - 当前已在 `/login` 页面。 +- 拦截器只包装 fetch;服务端 middleware(`src/proxy.ts`)已有的页面级重定向保持不变。 + +跳转逻辑抽成纯函数 `shouldRedirectFor(url: string, status: number, pathname: string): boolean`,便于单元测试。 + +### 2. 数学算式验证码 + +新增 `src/lib/server/captcha.ts`: + +- `createCaptcha()`:生成 `a`、`b`(各 1–9 的随机整数),返回 `{ id, question }`,其中 `question = "${a} + ${b} = ?"`。 +- `id` 为无状态签名令牌:`base64url(JSON {a, b, exp}) + "." + HMAC-SHA256(payload, authSecret)`,`exp` 为签发后 5 分钟。 +- `verifyCaptcha(id, answer)`:校验签名、过期时间、`answer === a + b`。篡改或过期均失败。 +- 无服务端存储,水平扩展安全。 + +新增 `GET /api/auth/captcha`: + +- 返回 `{ id, question }`,`Cache-Control: no-store`。 +- 加入 `src/proxy.ts` 的 `isPublicPath`(登录前必须可访问)。 + +### 3. 按 IP 的失败计数 + +新增 `src/lib/server/loginFailures.ts`: + +- 内存 `Map`,key 为客户端 IP(取 `request.headers.get("x-forwarded-for")` 首段,回退 `"unknown"`)。 +- `recordFailure(ip)`:计数 +1;`shouldRequireCaptcha(ip)`:`count >= 3`;`clearFailures(ip)`:登录成功时清零。 +- 条目 15 分钟无更新后过期;读取时惰性清理。 +- 进程重启后计数清零(已接受的取舍)。 + +### 4. 登录路由改动(`src/app/api/auth/login/route.ts`) + +- 请求体增加可选 `captchaId`、`captchaAnswer`。 +- 流程: + 1. `shouldRequireCaptcha(ip)` 为真时,先校验验证码;缺失或错误返回 400 `{ error, requiresCaptcha: true }`,并 `recordFailure(ip)`。 + 2. 校验用户名密码;失败返回 401 `{ error: "Invalid username or password.", requiresCaptcha: shouldRequireCaptcha(ip) }`(在 `recordFailure` 之后重新评估)。 + 3. 成功时 `clearFailures(ip)`,其余逻辑(session、rememberMe、cookie)不变。 + +### 5. 登录页改动(`src/app/login/page.tsx`) + +- 新增 state:`captchaRequired`、`captchaId`、`captchaQuestion`、`captchaAnswer`。 +- 提交后若响应含 `requiresCaptcha: true`:拉取 `GET /api/auth/captcha`,显示验证码区块(算式文本 + 数字输入框 + "换一题"按钮),清空输入并 focus。 +- "换一题"重新请求 captcha 接口。 +- 提交时若 `captchaRequired` 为真,请求体带上 `captchaId` 和 `captchaAnswer`。 +- 验证码区块 UI 与现有表单风格一致(`Input` 组件、`text-sm` 标签)。 + +## 错误处理 + +- 验证码过期/篡改:返回与"验证码错误"相同的 400 响应,前端自动拉取新题。 +- 并发失败计数竞态:内存 Map 单进程内操作,可接受极端情况多计一次。 +- `x-forwarded-for` 缺失时所有请求共享 `"unknown"` 桶——单机部署下可接受,文档注明反向代理需传递该头。 + +## 测试 + +- `src/lib/server/captcha.test.ts`:生成/校验往返、答案错误、签名篡改、过期。 +- `src/lib/server/loginFailures.test.ts`:计数阈值、清零、过期清理。 +- `src/app/api/auth/login/route.test.ts`(扩展现有):失败 3 次后 401 响应带 `requiresCaptcha`;缺验证码返回 400;验证码错误返回 400;验证码正确+密码正确登录成功并清零计数。 +- `src/lib/authRedirect.test.ts`:`shouldRedirectFor` 对 401/非 401、login 路径、当前在 /login 页的判定。