Files
2026-08-08 07:48:41 +08:00

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 中:

  1. 修改 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 };
}
  1. 修改 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 }),
  };
}
  1. 修复调用点 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:

  1. 第 2-5 行 import 改为:
import {
  isAuthenticationEnabled,
  isSafeReturnPath,
  rememberTtlSeconds,
  sessionTtlSeconds,
} from "@/lib/auth";
  1. 请求体类型(第 33-37 行)增加 rememberMe:
  const body = (await request.json().catch(() => null)) as {
    username?: unknown;
    password?: unknown;
    next?: unknown;
    rememberMe?: unknown;
  } | null;
  1. 签发会话处(第 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" —— 这是用户明确选择的简化。