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:
@@ -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 页的判定。
|
||||
Reference in New Issue
Block a user