Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
16 KiB
记住我(30 天会话)实现计划
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: 登录页新增"Remember me for 30 days"复选框,勾选后会话保持 30 天,未勾选则为浏览器会话级 Cookie(服务端 12 小时兜底)。
Architecture: 扩展现有无状态 HMAC 签名会话 Cookie:登录请求体新增 rememberMe 布尔字段,按勾选状态选择会话 TTL 与 Cookie maxAge。不引入新存储、不新增 Cookie。设计文档:docs/superpowers/specs/2026-08-07-remember-me-design.md。
Tech Stack: Next.js (App Router)、TypeScript、Vitest。
Global Constraints
- 复选框文案固定为
Remember me for 30 days(写死,不读配置)。 - 复选框默认不勾选。
rememberMe仅严格true视为勾选,其余一律按未勾选处理。- remember TTL 环境变量
WEBUI_AUTH_REMEMBER_TTL_DAYS:默认 30 天,上限 90 天,非法值回退默认。 - 认证未启用(
WEBUI_AUTH_ENABLED != "true")时行为完全不变。 - 测试用 Vitest,运行命令
npm test;测试文件与被测文件同目录,命名*.test.ts。
Task 1: rememberTtlSeconds() 配置函数
Files:
- Modify:
src/lib/auth.ts - Test:
src/lib/auth.test.ts(新建)
Interfaces:
-
Produces:
rememberTtlSeconds(): number— 返回 remember 会话的秒数;后续 Task 3 的登录路由调用它。 -
Step 1: 写失败测试
创建 src/lib/auth.test.ts:
import { afterEach, describe, expect, it } from "vitest";
import { rememberTtlSeconds } from "./auth";
const ORIGINAL = process.env.WEBUI_AUTH_REMEMBER_TTL_DAYS;
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.WEBUI_AUTH_REMEMBER_TTL_DAYS;
else process.env.WEBUI_AUTH_REMEMBER_TTL_DAYS = ORIGINAL;
});
describe("rememberTtlSeconds", () => {
it("defaults to 30 days when the env var is unset", () => {
delete process.env.WEBUI_AUTH_REMEMBER_TTL_DAYS;
expect(rememberTtlSeconds()).toBe(30 * 24 * 60 * 60);
});
it("uses the configured number of days", () => {
process.env.WEBUI_AUTH_REMEMBER_TTL_DAYS = "7";
expect(rememberTtlSeconds()).toBe(7 * 24 * 60 * 60);
});
it.each(["abc", "-3", "0"])(
"falls back to 30 days for invalid value %s",
(value) => {
process.env.WEBUI_AUTH_REMEMBER_TTL_DAYS = value;
expect(rememberTtlSeconds()).toBe(30 * 24 * 60 * 60);
}
);
it("falls back to 30 days when above the 90-day cap", () => {
process.env.WEBUI_AUTH_REMEMBER_TTL_DAYS = "365";
expect(rememberTtlSeconds()).toBe(30 * 24 * 60 * 60);
});
});
- Step 2: 运行测试确认失败
Run: npx vitest run src/lib/auth.test.ts
Expected: FAIL,rememberTtlSeconds is not a function(或导出不存在)
- Step 3: 实现
在 src/lib/auth.ts 末尾(isSafeReturnPath 之后)追加:
const DEFAULT_REMEMBER_TTL_DAYS = 30;
const MAX_REMEMBER_TTL_DAYS = 90;
export function rememberTtlSeconds(): number {
const days = Number(process.env.WEBUI_AUTH_REMEMBER_TTL_DAYS);
if (!Number.isFinite(days) || days <= 0 || days > MAX_REMEMBER_TTL_DAYS) {
return DEFAULT_REMEMBER_TTL_DAYS * 24 * 60 * 60;
}
return Math.floor(days * 24 * 60 * 60);
}
- Step 4: 运行测试确认通过
Run: npx vitest run src/lib/auth.test.ts
Expected: PASS(5 个测试)
- Step 5: 提交
git add src/lib/auth.ts src/lib/auth.test.ts
git commit -m "feat(webui): rememberTtlSeconds config for remember-me sessions"
Task 2: createSession / authCookieOptions 支持可变 TTL
Files:
- Modify:
src/lib/server/auth.ts:118-135(createSession)、src/lib/server/auth.ts:173-182(authCookieOptions) - Modify:
src/app/api/auth/password/route.ts:99,102(调用点适配) - Test:
src/lib/server/auth.test.ts(新建)
Interfaces:
-
Consumes: 无(本任务只改签名,rememberTtlSeconds 在 Task 3 才接入)
-
Produces:
createSession(username: string, role: UserRole, ttlSeconds: number): { token: string; payload: SessionPayload }authCookieOptions(ttlSeconds: number | null): { name: string; httpOnly: true; sameSite: "lax"; secure: boolean; path: "/"; maxAge?: number }—null时不含maxAge(会话级 Cookie)
-
Step 1: 写失败测试
创建 src/lib/server/auth.test.ts:
import { afterAll, describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
const ORIGINAL_ENABLED = process.env.WEBUI_AUTH_ENABLED;
const ORIGINAL_SECRET = process.env.WEBUI_AUTH_SECRET;
process.env.WEBUI_AUTH_ENABLED = "true";
process.env.WEBUI_AUTH_SECRET = "test-secret-with-at-least-32-characters";
const { authCookieOptions, createSession, decodeSessionPayload } =
await import("./auth");
afterAll(() => {
if (ORIGINAL_ENABLED === undefined) delete process.env.WEBUI_AUTH_ENABLED;
else process.env.WEBUI_AUTH_ENABLED = ORIGINAL_ENABLED;
if (ORIGINAL_SECRET === undefined) delete process.env.WEBUI_AUTH_SECRET;
else process.env.WEBUI_AUTH_SECRET = ORIGINAL_SECRET;
});
describe("createSession", () => {
it("sets expiresAt from the given TTL and round-trips", () => {
const before = Math.floor(Date.now() / 1000);
const { token, payload } = createSession("alice", "admin", 60);
expect(payload.expiresAt).toBeGreaterThanOrEqual(before + 60);
expect(payload.expiresAt).toBeLessThanOrEqual(before + 65);
expect(decodeSessionPayload(token)).toEqual(payload);
});
});
describe("authCookieOptions", () => {
it("omits maxAge for a session-scoped cookie", () => {
const options = authCookieOptions(null);
expect(options).not.toHaveProperty("maxAge");
expect(options.name).toBe("evoscientist_webui_session");
expect(options.httpOnly).toBe(true);
expect(options.path).toBe("/");
});
it("sets maxAge when a TTL is given", () => {
expect(authCookieOptions(3600).maxAge).toBe(3600);
});
});
- Step 2: 运行测试确认失败
Run: npx vitest run src/lib/server/auth.test.ts
Expected: FAIL,createSession 需要 3 个参数 / authCookieOptions(null) 类型错误
- Step 3: 实现
src/lib/server/auth.ts 中:
- 修改
createSession(第 118-135 行),增加ttlSeconds参数:
export function createSession(
username: string,
role: UserRole,
ttlSeconds: number
): {
token: string;
payload: SessionPayload;
} {
const secret = requireSecret();
const now = new Date();
const payload: SessionPayload = {
username,
role,
loggedInAt: now.toISOString(),
expiresAt: Math.floor(now.getTime() / 1000) + ttlSeconds,
};
const encoded = encode(JSON.stringify(payload));
return { token: `${encoded}.${sign(encoded, secret)}`, payload };
}
- 修改
authCookieOptions(第 173-182 行):
export function authCookieOptions(ttlSeconds: number | null) {
return {
name: AUTH_COOKIE_NAME,
httpOnly: true,
sameSite: "lax" as const,
secure: process.env.NODE_ENV === "production",
path: "/",
...(ttlSeconds === null ? {} : { maxAge: ttlSeconds }),
};
}
- 修复调用点
src/app/api/auth/password/route.ts(保持现有 12 小时持久 Cookie 行为不变):
第 2 行 import 改为:
import {
AUTH_COOKIE_NAME,
isAuthenticationEnabled,
sessionTtlSeconds,
} from "@/lib/auth";
第 99 行:
const { token, payload } = createSession(
session.username,
role,
sessionTtlSeconds()
);
第 102 行:
response.cookies.set({
...authCookieOptions(sessionTtlSeconds()),
value: token,
});
注意:登录路由 src/app/api/auth/login/route.ts 此时会编译报错,属于预期 —— Task 3 修复。
- Step 4: 运行测试确认通过
Run: npx vitest run src/lib/server/auth.test.ts
Expected: PASS
- Step 5: 提交
git add src/lib/server/auth.ts src/lib/server/auth.test.ts src/app/api/auth/password/route.ts
git commit -m "feat(webui): parameterize session TTL and cookie maxAge"
Task 3: 登录路由支持 rememberMe
Files:
- Modify:
src/app/api/auth/login/route.ts - Test:
src/app/api/auth/login/route.test.ts(新建)
Interfaces:
-
Consumes:
rememberTtlSeconds()(Task 1)、createSession(username, role, ttlSeconds)、authCookieOptions(ttlSeconds | null)(Task 2) -
Produces:
POST /api/auth/login接受可选rememberMe字段;Task 4 的前端会发送它。 -
Step 1: 写失败测试
创建 src/app/api/auth/login/route.test.ts:
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("server-only", () => ({}));
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-login-"));
const ORIGINAL_ENV = {
EVOSCIENTIST_DATA_DIR: process.env.EVOSCIENTIST_DATA_DIR,
WEBUI_AUTH_ENABLED: process.env.WEBUI_AUTH_ENABLED,
WEBUI_AUTH_SECRET: process.env.WEBUI_AUTH_SECRET,
WEBUI_AUTH_USERNAME: process.env.WEBUI_AUTH_USERNAME,
WEBUI_AUTH_PASSWORD: process.env.WEBUI_AUTH_PASSWORD,
WEBUI_AUTH_REMEMBER_TTL_DAYS: process.env.WEBUI_AUTH_REMEMBER_TTL_DAYS,
WEBUI_AUTH_SESSION_TTL_HOURS: process.env.WEBUI_AUTH_SESSION_TTL_HOURS,
};
process.env.EVOSCIENTIST_DATA_DIR = dataDir;
process.env.WEBUI_AUTH_ENABLED = "true";
process.env.WEBUI_AUTH_SECRET = "test-secret-with-at-least-32-characters";
process.env.WEBUI_AUTH_USERNAME = "admin";
process.env.WEBUI_AUTH_PASSWORD = "bootstrap-password";
delete process.env.WEBUI_AUTH_REMEMBER_TTL_DAYS;
delete process.env.WEBUI_AUTH_SESSION_TTL_HOURS;
const { POST } = await import("./route");
const { decodeSessionPayload } = await import("@/lib/server/auth");
const { closeUserStoreForTests } = await import("@/lib/server/userStore");
const REMEMBER_TTL = 30 * 24 * 60 * 60;
const SESSION_TTL = 12 * 60 * 60;
afterAll(() => {
closeUserStoreForTests();
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
fs.rmSync(dataDir, { recursive: true, force: true });
});
function loginRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
async function loginWith(body: unknown) {
const res = await POST(
loginRequest({ username: "admin", password: "bootstrap-password", ...(body as object) })
);
expect(res.status).toBe(200);
const setCookie = res.headers.get("set-cookie") ?? "";
const token = setCookie.split(";")[0].split("=").slice(1).join("=");
const payload = decodeSessionPayload(token);
expect(payload).not.toBeNull();
return { setCookie, expiresIn: payload!.expiresAt - Math.floor(Date.now() / 1000) };
}
describe("POST /api/auth/login rememberMe", () => {
it("issues a 30-day persistent cookie when rememberMe is true", async () => {
const { setCookie, expiresIn } = await loginWith({ rememberMe: true });
expect(setCookie).toContain(`Max-Age=${REMEMBER_TTL}`);
expect(expiresIn).toBeGreaterThan(REMEMBER_TTL - 10);
expect(expiresIn).toBeLessThanOrEqual(REMEMBER_TTL);
});
it("issues a session cookie with a 12h server-side expiry when rememberMe is omitted", async () => {
const { setCookie, expiresIn } = await loginWith({});
expect(setCookie).not.toContain("Max-Age");
expect(expiresIn).toBeGreaterThan(SESSION_TTL - 10);
expect(expiresIn).toBeLessThanOrEqual(SESSION_TTL);
});
it("treats non-boolean rememberMe as not remembered", async () => {
const { setCookie } = await loginWith({ rememberMe: "yes" });
expect(setCookie).not.toContain("Max-Age");
});
});
- Step 2: 运行测试确认失败
Run: npx vitest run src/app/api/auth/login/route.test.ts
Expected: FAIL(TypeScript 编译错误:createSession 参数不足 / authCookieOptions 需要参数)
- Step 3: 实现
修改 src/app/api/auth/login/route.ts:
- 第 2-5 行 import 改为:
import {
isAuthenticationEnabled,
isSafeReturnPath,
rememberTtlSeconds,
sessionTtlSeconds,
} from "@/lib/auth";
- 请求体类型(第 33-37 行)增加
rememberMe:
const body = (await request.json().catch(() => null)) as {
username?: unknown;
password?: unknown;
next?: unknown;
rememberMe?: unknown;
} | null;
- 签发会话处(第 61-69 行)改为:
const rememberMe = body?.rememberMe === true;
const ttlSeconds = rememberMe ? rememberTtlSeconds() : sessionTtlSeconds();
const { token, payload } = createSession(user.username, user.role, ttlSeconds);
await recordLogin(payload);
const response = NextResponse.json(
{
redirectTo: isSafeReturnPath(body?.next) ? body.next : "/",
},
{ headers: NO_STORE }
);
response.cookies.set({
...authCookieOptions(rememberMe ? ttlSeconds : null),
value: token,
});
return response;
- Step 4: 运行测试确认通过
Run: npx vitest run src/app/api/auth/login/route.test.ts src/lib/server/auth.test.ts src/lib/auth.test.ts
Expected: PASS
- Step 5: 提交
git add src/app/api/auth/login/route.ts src/app/api/auth/login/route.test.ts
git commit -m "feat(webui): rememberMe login flag with 30-day persistent cookie"
Task 4: 登录页"Remember me"复选框
Files:
- Modify:
src/app/login/page.tsx
Interfaces:
-
Consumes:
POST /api/auth/login的rememberMe字段(Task 3) -
Produces: 无(纯 UI)
-
Step 1: 添加状态与请求字段
src/app/login/page.tsx:
第 19 行后(const [submitting, ...] 之前)加:
const [rememberMe, setRememberMe] = useState(false);
第 31 行请求体改为:
body: JSON.stringify({ username, password, next: returnPath(), rememberMe }),
- Step 2: 添加复选框 UI
在密码 </label> 之后(第 89 行后)、</div> 之前插入:
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
name="rememberMe"
checked={rememberMe}
onChange={(event) => setRememberMe(event.target.checked)}
disabled={submitting}
className="h-4 w-4"
/>
<span>Remember me for 30 days</span>
</label>
- Step 3: 验证类型与 lint
Run: npm run lint && npx tsc --noEmit
Expected: 无错误
- Step 4: 浏览器手动验证
WEBUI_AUTH_ENABLED=true \
WEBUI_AUTH_SECRET="$(openssl rand -hex 32)" \
WEBUI_AUTH_USERNAME=admin \
WEBUI_AUTH_PASSWORD=test-password-123 \
npm run dev
-
打开 http://localhost:4716 → 重定向到 /login
-
不勾选登录:DevTools → Application → Cookies,
evoscientist_webui_session的 Expires 显示为 "Session" -
登出后勾选 "Remember me for 30 days" 登录:Cookie 的 Expires 为 30 天后
-
确认未勾选时重启浏览器需重新登录
-
Step 5: 提交
git add src/app/login/page.tsx
git commit -m "feat(webui): remember-me checkbox on the login page"
Task 5: 全量回归
- Step 1: 跑全部测试与 lint
Run: npm test && npm run lint
Expected: 全部通过
- Step 2: 提交遗留改动(若有)
git status --short
预期无未提交的相关改动。
Self-Review 记录
- Spec 覆盖:配置函数(Task 1)、会话签发(Task 2)、登录路由(Task 3)、登录页(Task 4)、错误处理与边界(Global Constraints + Task 3 测试)、测试要求(各任务测试 + Task 5 回归)。密码路由调用点适配已包含在 Task 2 Step 3。
- 已知取舍:若部署者将
WEBUI_AUTH_REMEMBER_TTL_DAYS改为非 30 的值,UI 文案仍写死 "30 days" —— 这是用户明确选择的简化。