diff --git a/docs/superpowers/specs/2026-08-07-remember-me-design.md b/docs/superpowers/specs/2026-08-07-remember-me-design.md new file mode 100644 index 0000000..ff63555 --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-remember-me-design.md @@ -0,0 +1,81 @@ +# 记住我(30 天会话)设计 + +日期:2026-08-07 +状态:已批准 + +## 背景 + +WebUI 登录当前使用无状态 HMAC 签名会话 Cookie(`evoscientist_webui_session`),有效期由 `WEBUI_AUTH_SESSION_TTL_HOURS` 控制(默认 12 小时)。用户希望登录页提供"记住我"选项,勾选后登录状态保持 30 天。 + +## 需求 + +- 登录页新增"Remember me for 30 days"复选框,默认不勾选。 +- 勾选:会话有效 30 天,关闭浏览器再打开无需重新登录。 +- 未勾选:会话级 Cookie(浏览器关闭即失效),服务端 `expiresAt` 保留 12 小时兜底。 +- 认证未启用(`WEBUI_AUTH_ENABLED != "true"`)时行为完全不变。 +- 旧会话 Cookie 无需迁移,自然过期。 + +## 方案 + +扩展现有签名会话 Cookie,按勾选状态使用不同 TTL。不引入新存储、不新增 Cookie。 + +### 1. 配置层 — `src/lib/auth.ts` + +新增: + +```ts +const DEFAULT_REMEMBER_TTL_DAYS = 30; + +export function rememberTtlSeconds(): number { + const days = Number(process.env.WEBUI_AUTH_REMEMBER_TTL_DAYS); + if (!Number.isFinite(days) || days <= 0 || days > 90) { + return DEFAULT_REMEMBER_TTL_DAYS * 24 * 60 * 60; + } + return Math.floor(days * 24 * 60 * 60); +} +``` + +与现有 `sessionTtlSeconds()` 模式一致:环境变量 `WEBUI_AUTH_REMEMBER_TTL_DAYS`,默认 30,上限 90,非法值回退默认。 + +### 2. 会话签发 — `src/lib/server/auth.ts` + +- `createSession(username, role, ttlSeconds: number)`:增加 TTL 参数,用于计算 payload 的 `expiresAt`(由调用方传入 `rememberTtlSeconds()` 或 `sessionTtlSeconds()`)。 +- `authCookieOptions(ttlSeconds: number | null)`:参数为 `null` 时不设置 `maxAge`(会话级 Cookie);否则 `maxAge` 取该值。其余选项(httpOnly、sameSite=lax、secure、path)不变。 +- `SessionPayload` 结构不变;`decodeSessionPayload` 不变。 +- proxy.ts 中间件校验逻辑不变(已基于 payload 的 `expiresAt` 判断,与 Cookie `maxAge` 独立)。 + +### 3. 登录路由 — `src/app/api/auth/login/route.ts` + +- 请求体新增可选字段 `rememberMe`,仅严格 `true` 视为勾选,其余(缺失、false、非布尔)视为未勾选。 +- 勾选:`createSession(user.username, user.role, rememberTtlSeconds())` + `authCookieOptions(rememberTtlSeconds())`。 +- 未勾选:`createSession(user.username, user.role, sessionTtlSeconds())` + `authCookieOptions(null)`(会话级 Cookie,`expiresAt` 为 12 小时兜底)。 +- 其余校验(origin、用户名/密码长度、错误响应)不变。 + +### 4. 登录页 — `src/app/login/page.tsx` + +- 密码框下方新增复选框,文案固定为 "Remember me for 30 days"(写死,不从配置读取)。 +- 状态 `rememberMe`,默认 `false`,提交时加入请求体。 +- 复选框在 `submitting` 时禁用。 +- 样式沿用现有 Tailwind 类,与表单其余部分一致。 + +### 5. 错误处理与边界 + +- 认证未启用:登录路由已返回 404,不涉及本功能。 +- 勾选但环境变量非法:回退 30 天,不报错。 +- 登出逻辑不变(清除 Cookie)。 +- 修改密码轮换密钥后所有会话(含 30 天会话)失效,行为与现状一致。 + +## 测试 + +1. `src/lib/auth` 单测(新增或扩展):`rememberTtlSeconds()` 的默认值、合法值、非法值(非数字、负数)、超上限(>90 天)回退。 +2. 登录路由测试: + - `rememberMe: true` → Set-Cookie 含 `Max-Age` ≈ 30 天,payload `expiresAt` ≈ 30 天后。 + - `rememberMe: false` 或缺失 → Set-Cookie 不含 `Max-Age`,payload `expiresAt` ≈ 12 小时后。 + - `rememberMe` 为非布尔值 → 按未勾选处理。 +3. 手动验证:勾选登录后重启浏览器仍保持登录;未勾选关闭浏览器后需重新登录。 + +## 非目标(YAGNI) + +- 不做 remember token 轮换 / 服务端可撤销的 refresh token 存储。 +- 不做设备管理、踢出会话界面。 +- 不做"30 天内活跃自动续期"。