docs: design spec for login captcha and session-expiry redirect

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-08-08 08:50:44 +08:00
parent b53d58fa35
commit 6e7a1e4898
@@ -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<string, { count: number, resetAt: number }>`,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 页的判定。