You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

37 KiB

登录、注册与用户信息 Implementation Plan

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: 在 Nuxt 4 / Nitro 上实现邮箱密码注册登录、Redis Session(HttpOnly Cookie)、可配置「已验证邮箱」门禁、auth_challenges 驱动的验证与重置密码闭环、NoopMailer、最小页面(/login/register/me),并落地 linked_accounts 空表与 hello 泄露修复。

Architecture: 业务逻辑集中在 server/services/auth.ts(及拆出的 session.tspassword.tschallenge.ts),API handler 薄封装;会话仅存 Redis,Cookie 仅存 opaque id;用 users_table.session_version 在密码重置时 O(1) 吊销所有旧会话(无需 SCAN)。密码哈希选用纯 JS bcryptjs(Bun 友好、无原生编译);挑战 token 明文仅响应给调试日志,库内存 SHA-256 hex

Tech Stack: Nuxt 4.4、Nitro、Bun、Drizzle ORM 0.45、PostgreSQL、pgioredisbcryptjs、项目已有 log4js / logger

规格依据: docs/superpowers/specs/2026-04-12-auth-user-design.md


文件结构总览(创建 / 修改)

路径 职责
package.json 新增依赖 bcryptjsioredis;新增 test 脚本
.env.example 增加 REDIS_URLAUTH_DEBUG_LOG_TOKENSSESSION_TTL_SECONDSCOOKIE_DOMAIN
packages/drizzle-pkg/lib/schema/schema.ts 扩展 users_table;新增 auth_challenge_type 枚举表字段;auth_challengeslinked_accounts
packages/drizzle-pkg/migrations/*.sql bun run db:generate --name auth_user 生成后人工检查
packages/drizzle-pkg/seed.ts 为 seed 用户写入合法 password_hash 或暂时跳过 users 种子
packages/drizzle-pkg/lib/db.ts 如需导出类型可微调(尽量不动)
server/utils/redis.ts getRedis() 单例(ioredis
server/utils/password.ts hashPassword / verifyPassword(bcryptjs)
server/utils/challenge-token.ts randomUrlToken + hashChallengeTokennode:crypto sha256)
server/utils/session-cookie.ts Cookie 名、serializeSessionCookieclearSessionCookie
server/utils/session-ttl.ts sessionTtlSeconds()(无 Redis 依赖,便于单测)
server/utils/session-redis.ts createSessionreadSessiondeleteSession;value 含 userIdsessionVersion
server/utils/errors.ts jsonError(status, code, message) 基于 createError
server/utils/rate-limit.ts Redis INCR + EX 窗口限流
server/utils/verification-policy.ts needsEmailVerified(handlerId: string): boolean
server/utils/mailer.ts Mailer 接口 + NoopMailer
server/services/auth.ts 注册、登录、登出、me、patch、forgot、reset、verify;组合 DB+Redis
server/plugins/04.redis.ts(序号可按现有 plugins 顺延) Nitro 启动时 getRedis();关闭时 quit
server/api/auth/register.post.ts 薄 handler
server/api/me.get.tsserver/api/me.patch.ts 当前用户
server/api/hello.ts 删除文件(或改为 401 无数据;计划采用删除,避免误暴露)
app/pages/login.vueregister.vueme.vue 最小 UI
app/middleware/auth.tsguest.ts 页面守卫
nuxt.config.ts 如需要 runtimeConfig 暴露公共配置(通常不必)
test/unit/session-ttl.test.ts TTL 默认值
test/unit/password.test.ts bcrypt 封装单测
test/unit/challenge-token.test.ts token 长度与哈希确定性
test/unit/verification-policy.test.ts 策略默认值
test/integration/auth.http.test.ts 可选:TEST_INTEGRATION=1 且已 bun run dev 时跑 HTTP 流程

Task 1: 依赖与脚本

Files:

  • Modify: package.json

  • Modify: .env.example

  • Step 1: 安装依赖

Run:

cd /home/dash/projects/person-panel && bun add bcryptjs ioredis && bun add -d @types/bcryptjs

Expected: package.json 出现 bcryptjsioredis@types/bcryptjs

  • Step 2: 增加 test 脚本

在根 package.jsonscripts 中增加:

"test": "bun test test/unit",
"test:integration": "bun test test/integration"

(路径可按最终目录微调,但需与 Step 3 文件一致。)

  • Step 3: 更新 .env.example

DATABASE_URL= 下追加(整文件示例):

DATABASE_URL=postgresql://postgres:123456qaz@localhost:6666/postgres
REDIS_URL=redis://127.0.0.1:6379
SESSION_TTL_SECONDS=604800
AUTH_DEBUG_LOG_TOKENS=false
COOKIE_DOMAIN=
  • Step 4: Commit
git add package.json bun.lock .env.example && git commit -m "chore: add auth-related deps and env example"

Task 2: Drizzle schema(用户、挑战、OAuth 预留)

Files:

  • Modify: packages/drizzle-pkg/lib/schema/schema.ts

  • Step 1: 写失败类型检查(可选) — 跳过纯类型任务;直接进入 Step 2。

  • Step 2: 用以下内容替换 / 合并进 schema.ts(保留导出名称与项目风格一致)

import {
  integer,
  pgTable,
  varchar,
  timestamp,
  text,
  pgEnum,
  uniqueIndex,
  index,
} from "drizzle-orm/pg-core";

export const authChallengeTypeEnum = pgEnum("auth_challenge_type", [
  "email_verify",
  "password_reset",
]);

export const usersTable = pgTable("users_table", {
  id: integer().primaryKey().generatedAlwaysAsIdentity(),
  name: varchar({ length: 255 }).notNull(),
  age: integer().notNull(),
  email: varchar({ length: 320 }).notNull().unique(),
  passwordHash: text("password_hash").notNull(),
  emailVerifiedAt: timestamp("email_verified_at", {
    withTimezone: true,
  }),
  sessionVersion: integer("session_version").notNull().default(0),
  createdAt: timestamp("created_at", { withTimezone: true })
    .notNull()
    .defaultNow(),
  updatedAt: timestamp("updated_at", { withTimezone: true })
    .notNull()
    .defaultNow(),
});

export const authChallengesTable = pgTable(
  "auth_challenges",
  {
    id: integer().primaryKey().generatedAlwaysAsIdentity(),
    userId: integer("user_id")
      .notNull()
      .references(() => usersTable.id, { onDelete: "cascade" }),
    type: authChallengeTypeEnum("type").notNull(),
    tokenHash: varchar("token_hash", { length: 64 }).notNull(),
    expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
    consumedAt: timestamp("consumed_at", { withTimezone: true }),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => ({
    tokenHashIdx: index("auth_challenges_token_hash_idx").on(t.tokenHash),
  }),
);

export const linkedAccountsTable = pgTable(
  "linked_accounts",
  {
    id: integer().primaryKey().generatedAlwaysAsIdentity(),
    userId: integer("user_id")
      .notNull()
      .references(() => usersTable.id, { onDelete: "cascade" }),
    provider: varchar({ length: 64 }).notNull(),
    providerUserId: varchar("provider_user_id", { length: 255 }).notNull(),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => ({
    providerUserUnique: uniqueIndex("linked_accounts_provider_uid").on(
      t.provider,
      t.providerUserId,
    ),
  }),
);

注意:password_hash NOT NULL 后,旧迁移里的行若存在,需在 Task 3 用 SQL 处理或清空表(开发环境)。

  • Step 3: 生成迁移

Run:

cd /home/dash/projects/person-panel && DATABASE_URL="$DATABASE_URL" bun run db:generate --name auth_user

Expected: packages/drizzle-pkg/migrations/ 出现新 SQL;打开文件确认:创建 enum、auth_challengeslinked_accountsALTER users_table 增加列。若 Drizzle 对 NOT NULL password_hash 与已有数据冲突,在 SQL 中改为:先 ADD COLUMN password_hash textUPDATE 占位后再 SET NOT NULL,或 开发环境 TRUNCATE users_table 后加 NOT NULL

  • Step 4: 应用迁移
bun run db:migrate

Expected: 无错误。

  • Step 5: Commit
git add packages/drizzle-pkg/lib/schema/schema.ts packages/drizzle-pkg/migrations packages/drizzle-pkg/migrations/meta && git commit -m "feat(db): auth schema and migrations"

Task 3: 修复 seed.ts 以匹配新 users_table

Files:

  • Modify: packages/drizzle-pkg/seed.ts

  • Step 1: 将 seed 改为显式插入带密码用户(避免 drizzle-seed 无法填 passwordHash)

用下面实现 替换 main 逻辑(保留 import './env' 等顶层):

import './env';
import { dbGlobal } from "./lib/db";
import { usersTable } from "./lib/schema/schema";
import bcrypt from "bcryptjs";

async function main() {
  const passwordHash = bcrypt.hashSync("seed-password-123", 10);
  await dbGlobal.insert(usersTable).values(
    Array.from({ length: 5 }).map((_, i) => ({
      name: `Seed User ${i}`,
      age: 20 + i,
      email: `seed${i}@example.com`,
      passwordHash,
    })),
  );
  console.log('Seed complete!');
  process.exit(0);
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});

删除对 drizzle-seed 的依赖引用;若根 package.json 不再使用 drizzle-seed,可在后续单独 PR 移除(本任务可保留依赖以免范围膨胀)。

  • Step 2: 运行 seed(空库或已 truncate)
bun run db:seed

Expected: Seed complete!

  • Step 3: Commit
git add packages/drizzle-pkg/seed.ts && git commit -m "fix(seed): insert users with password hash for new schema"

Files:

  • Create: server/utils/redis.ts

  • Create: server/utils/session-cookie.ts

  • Create: server/plugins/04.redis.ts(若与现有编号冲突,改为下一个空闲编号)

  • Step 1: server/utils/redis.ts

import Redis from "ioredis";

let client: Redis | null = null;

export function getRedis(): Redis {
  if (client) return client;
  const url = process.env.REDIS_URL;
  if (!url) {
    throw new Error("REDIS_URL is required");
  }
  client = new Redis(url, { maxRetriesPerRequest: 2 });
  return client;
}

export async function closeRedis(): Promise<void> {
  if (!client) return;
  await client.quit();
  client = null;
}
  • Step 2: server/utils/session-cookie.ts
import type { H3Event } from "h3";
import { getCookie, setCookie, deleteCookie } from "h3";

export const SESSION_COOKIE_NAME = "pp_session";

export function readSessionIdFromCookie(event: H3Event): string | undefined {
  return getCookie(event, SESSION_COOKIE_NAME);
}

export function writeSessionCookie(
  event: H3Event,
  sessionId: string,
  maxAgeSeconds: number,
): void {
  const secure = process.env.NODE_ENV === "production";
  const domain = process.env.COOKIE_DOMAIN?.trim() || undefined;
  setCookie(event, SESSION_COOKIE_NAME, sessionId, {
    httpOnly: true,
    sameSite: "lax",
    secure,
    path: "/",
    maxAge: maxAgeSeconds,
    domain,
  });
}

export function clearSessionCookie(event: H3Event): void {
  const secure = process.env.NODE_ENV === "production";
  const domain = process.env.COOKIE_DOMAIN?.trim() || undefined;
  deleteCookie(event, SESSION_COOKIE_NAME, {
    path: "/",
    httpOnly: true,
    sameSite: "lax",
    secure,
    domain,
  });
}
  • Step 3: server/plugins/04.redis.ts
export default defineNitroPlugin(() => {
  import("~/server/utils/redis").then(({ getRedis }) => {
    getRedis();
  });
});

若路径别名 ~ 在 Nitro 插件中不可用,改为相对路径 ../utils/redis

  • Step 4: Commit
git add server/utils/redis.ts server/utils/session-cookie.ts server/plugins/04.redis.ts && git commit -m "feat(server): redis singleton and session cookie helpers"

Task 5: 会话 Redis 与版本吊销

Files:

  • Create: server/utils/session-ttl.ts

  • Create: server/utils/session-redis.ts

  • Create: test/unit/session-ttl.test.ts

  • Step 1: server/utils/session-ttl.ts

export function sessionTtlSeconds(): number {
  const raw = process.env.SESSION_TTL_SECONDS;
  const n = raw ? Number(raw) : NaN;
  return Number.isFinite(n) && n > 0 ? n : 604800;
}
  • Step 2: server/utils/session-redis.ts
import { getRedis } from "./redis";
import { sessionTtlSeconds } from "./session-ttl";

export type SessionPayload = {
  userId: number;
  sessionVersion: number;
  createdAt: string;
};

const sessionKey = (id: string) => `sess:${id}`;

export async function createSession(
  sessionId: string,
  payload: SessionPayload,
): Promise<void> {
  const redis = getRedis();
  const ttl = sessionTtlSeconds();
  await redis.set(sessionKey(sessionId), JSON.stringify(payload), "EX", ttl);
}

export async function readSession(
  sessionId: string,
): Promise<SessionPayload | null> {
  const redis = getRedis();
  const raw = await redis.get(sessionKey(sessionId));
  if (!raw) return null;
  try {
    return JSON.parse(raw) as SessionPayload;
  } catch {
    return null;
  }
}

export async function deleteSession(sessionId: string): Promise<void> {
  const redis = getRedis();
  await redis.del(sessionKey(sessionId));
}

登录时从 DB 读取 usersTable.sessionVersion 写入 payload;requireUser 在读到 Redis payload 后 再查 DB 比对 sessionVersion,不一致则删 Redis 会话并 401。

  • Step 3: test/unit/session-ttl.test.ts
import { describe, expect, it, beforeEach } from "bun:test";
import { sessionTtlSeconds } from "../../server/utils/session-ttl";

describe("sessionTtlSeconds", () => {
  beforeEach(() => {
    delete process.env.SESSION_TTL_SECONDS;
  });

  it("defaults to 7d", () => {
    expect(sessionTtlSeconds()).toBe(604800);
  });

  it("respects env", () => {
    process.env.SESSION_TTL_SECONDS = "120";
    expect(sessionTtlSeconds()).toBe(120);
  });
});
  • Step 4: Run
bun test test/unit/session-ttl.test.ts

Expected: PASS

  • Step 5: Commit
git add server/utils/session-ttl.ts server/utils/session-redis.ts test/unit/session-ttl.test.ts && git commit -m "feat(server): redis session storage and ttl helper"

Task 6: 密码与挑战 token 工具

Files:

  • Create: server/utils/password.ts

  • Create: server/utils/challenge-token.ts

  • Create: test/unit/password.test.ts

  • Create: test/unit/challenge-token.test.ts

  • Step 1: server/utils/password.ts

import bcrypt from "bcryptjs";

const ROUNDS = 10;

export async function hashPassword(plain: string): Promise<string> {
  const salt = await bcrypt.genSalt(ROUNDS);
  return bcrypt.hash(plain, salt);
}

export async function verifyPassword(
  plain: string,
  hash: string,
): Promise<boolean> {
  return bcrypt.compare(plain, hash);
}
  • Step 2: server/utils/challenge-token.ts
import { createHash, randomBytes } from "node:crypto";

export function randomUrlToken(): string {
  return randomBytes(32).toString("base64url");
}

export function hashChallengeToken(token: string): string {
  return createHash("sha256").update(token, "utf8").digest("hex");
}
  • Step 3: 测试 test/unit/password.test.ts
import { describe, expect, it } from "bun:test";
import { hashPassword, verifyPassword } from "../../server/utils/password";

describe("password", () => {
  it("hashes and verifies", async () => {
    const h = await hashPassword("hunter2");
    expect(await verifyPassword("hunter2", h)).toBe(true);
    expect(await verifyPassword("wrong", h)).toBe(false);
  });
});
  • Step 4: 测试 test/unit/challenge-token.test.ts
import { describe, expect, it } from "bun:test";
import {
  hashChallengeToken,
  randomUrlToken,
} from "../../server/utils/challenge-token";

describe("challenge-token", () => {
  it("hash is stable", () => {
    expect(hashChallengeToken("abc")).toBe(hashChallengeToken("abc"));
  });

  it("random has reasonable length", () => {
    expect(randomUrlToken().length).toBeGreaterThan(20);
  });
});
  • Step 5: Run
bun test test/unit/password.test.ts test/unit/challenge-token.test.ts

Expected: PASS

  • Step 6: Commit
git add server/utils/password.ts server/utils/challenge-token.ts test/unit/password.test.ts test/unit/challenge-token.test.ts && git commit -m "feat(server): password and challenge token utilities"

Task 7: 错误 JSON、限流、验证策略

Files:

  • Create: server/utils/errors.ts

  • Create: server/utils/rate-limit.ts

  • Create: server/utils/verification-policy.ts

  • Create: server/utils/mailer.ts

  • Create: test/unit/verification-policy.test.ts

  • Step 1: server/utils/errors.ts

import { createError } from "h3";

/** 与规格一致的错误体;依赖 Nitro 将 `createError` 的 `data` 序列化进 JSON。 */
export function jsonError(status: number, code: string, message: string): never {
  throw createError({
    statusCode: status,
    data: { error: { code, message } },
  });
}
  • Step 2: server/utils/rate-limit.ts
import { createError } from "h3";
import { getRedis } from "./redis";

export async function rateLimitOrThrow(
  key: string,
  limit: number,
  windowSeconds: number,
): Promise<void> {
  const redis = getRedis();
  const n = await redis.incr(key);
  if (n === 1) {
    await redis.expire(key, windowSeconds);
  }
  if (n > limit) {
    throw createError({
      statusCode: 429,
      data: {
        error: { code: "RATE_LIMITED", message: "请求过于频繁,请稍后再试" },
      },
    });
  }
}
  • Step 3: server/utils/verification-policy.ts
const NEEDS_VERIFIED = new Set<string>(["patch-me"]);

export function needsEmailVerified(handlerId: string): boolean {
  return NEEDS_VERIFIED.has(handlerId);
}
  • Step 4: 测试 test/unit/verification-policy.test.ts
import { describe, expect, it } from "bun:test";
import { needsEmailVerified } from "../../server/utils/verification-policy";

describe("verification-policy", () => {
  it("patch-me requires verified", () => {
    expect(needsEmailVerified("patch-me")).toBe(true);
  });
  it("unknown defaults false", () => {
    expect(needsEmailVerified("other")).toBe(false);
  });
});
  • Step 5: server/utils/mailer.ts
export type Mailer = {
  sendVerificationEmail(input: { to: string; token: string }): Promise<void>;
  sendPasswordResetEmail(input: { to: string; token: string }): Promise<void>;
};

export const noopMailer: Mailer = {
  async sendVerificationEmail() {},
  async sendPasswordResetEmail() {},
};
  • Step 6: Run单测
bun test test/unit/verification-policy.test.ts
  • Step 7: Commit
git add server/utils/errors.ts server/utils/rate-limit.ts server/utils/verification-policy.ts server/utils/mailer.ts test/unit/verification-policy.test.ts && git commit -m "feat(server): errors, rate limit stub, verification policy, noop mailer"

Task 8: server/services/auth.ts(核心逻辑)

Files:

  • Create: server/services/auth.ts

附录 A 全文写入 server/services/auth.ts,再按本仓库路径别名做最小调整(确保 drizzle-pkglogger../utils/* 解析正确)。行为摘要:

  • registerWithSession:限流 → 校验 → 插入用户 + email_verify challenge → noopMailer → 调试日志 → 轮换旧会话 → 新 Redis session + Cookie;NODE_ENV=test 时响应含 _testTokens.verify

  • loginWithSession:限流 → 校验密码 → 轮换旧会话 → 新 session;错误统一 401(不区分用户不存在与密码错误,可选;附录为防枚举使用统一文案)。

  • logoutSession:删 Redis + clearSessionCookie

  • getCurrentUser / patchMerequireSessionUser 比对 sessionVersionpatch-meemailVerifiedAt

  • forgotPassword:限流 → 始终 { ok: true };存在用户则写 password_reset;test 环境附加 _testTokens.reset

  • resetPasswordsession_version 用 Drizzle sql 表达式 +1 吊销旧会话;消费 challenge。

  • verifyEmail:写 emailVerifiedAt + 消费 challenge。

  • Step 1: 复制附录 A 到 server/services/auth.ts 并修正 import

  • Step 2: Commit

git add server/services/auth.ts && git commit -m "feat(server): auth service (register login session profile challenges)"

Task 9: Nitro API handlers

Files:

  • Create: server/api/auth/register.post.ts
  • Create: server/api/auth/login.post.ts
  • Create: server/api/auth/logout.post.ts
  • Create: server/api/auth/forgot-password.post.ts
  • Create: server/api/auth/reset-password.post.ts
  • Create: server/api/auth/verify-email.post.ts
  • Create: server/api/me.get.ts
  • Create: server/api/me.patch.ts
  • Modify: server/plugins/03.error-logger.ts(若存在)确保 createErrordata 原样输出 JSON

示例 server/api/auth/login.post.ts

import { readBody } from "h3";
import { loginWithSession } from "../../services/auth";

export default defineEventHandler(async (event) => {
  const body = await readBody<{ email?: string; password?: string }>(event);
  return loginWithSession(event, {
    email: body.email ?? "",
    password: body.password ?? "",
  });
});

其余路由同理调用 service。PATCH /api/me 的 handlerId 内部传 patch-me

  • Step 1: 创建全部 handler 文件
  • Step 2: 删除 server/api/hello.ts
git rm server/api/hello.ts
  • Step 3: 手动 smoke
bun run dev

另开终端:

curl -i -X POST http://localhost:3000/api/auth/register \
  -H 'content-type: application/json' \
  -d '{"email":"a@b.com","password":"password123","name":"A","age":20}'

Expected: Set-Cookie: pp_session=...

  • Step 4: Commit
git add server/api && git commit -m "feat(api): auth and profile endpoints, remove public hello"

Task 10: 集成测试 test/integration/auth.http.test.ts

前提: 终端 A 已 NODE_ENV=test REDIS_URL=... DATABASE_URL=... bun run dev 监听 http://127.0.0.1:3000;终端 B 跑测试。NODE_ENV=test 时注册/忘记密码响应含 _testTokens(见附录 A),仅用于自动化。

  • Step 1: 创建 test/integration/auth.http.test.ts
import { describe, it, expect } from "bun:test";

const BASE = process.env.TEST_BASE_URL ?? "http://127.0.0.1:3000";

function parseCookie(res: Response): string {
  const raw = res.headers.get("set-cookie");
  if (!raw) return "";
  return raw.split(";")[0] ?? "";
}

(process.env.TEST_INTEGRATION ? describe : describe.skip)("auth over HTTP", () => {
  it("register → me → verify → patch", async () => {
    const email = `u${Date.now()}@example.com`;
    const reg = await fetch(`${BASE}/api/auth/register`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        email,
        password: "password123",
        name: "T",
        age: 30,
      }),
    });
    expect(reg.status).toBe(200);
    const regJson = (await reg.json()) as {
      user: { emailVerified: boolean };
      _testTokens?: { verify?: string };
    };
    expect(regJson.user.emailVerified).toBe(false);
    const cookie = parseCookie(reg);
    expect(cookie.length).toBeGreaterThan(5);

    const me1 = await fetch(`${BASE}/api/me`, { headers: { cookie } });
    expect(me1.status).toBe(200);

    const patch1 = await fetch(`${BASE}/api/me`, {
      method: "PATCH",
      headers: { cookie, "content-type": "application/json" },
      body: JSON.stringify({ name: "T2" }),
    });
    expect(patch1.status).toBe(403);

    const verify = await fetch(`${BASE}/api/auth/verify-email`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ token: regJson._testTokens?.verify }),
    });
    expect(verify.status).toBe(200);

    const patch2 = await fetch(`${BASE}/api/me`, {
      method: "PATCH",
      headers: { cookie, "content-type": "application/json" },
      body: JSON.stringify({ name: "T2" }),
    });
    expect(patch2.status).toBe(200);
  });
});
  • Step 2: Run(双终端)

终端 A:

export NODE_ENV=test REDIS_URL=redis://127.0.0.1:6379 DATABASE_URL=postgresql://...
bun run dev

终端 B:

export TEST_INTEGRATION=1 NODE_ENV=test
bun test test/integration/auth.http.test.ts

Expected: pass 1 test。

  • Step 3: Commit
git add test/integration/auth.http.test.ts && git commit -m "test: optional HTTP integration for auth flow"

Task 11: 最小前端页面

Files:

  • Create: app/pages/login.vue

  • Create: app/pages/register.vue

  • Create: app/pages/me.vue

  • Create: app/middleware/auth.ts

  • Create: app/middleware/guest.ts

  • Step 1: app/middleware/auth.ts(HttpOnly Cookie 不可用 useCookie 读取,必须用 $fetch 探测会话)

export default defineNuxtRouteMiddleware(async (to) => {
  try {
    await $fetch("/api/me", { credentials: "include" });
  } catch {
    return navigateTo({ path: "/login", query: { redirect: to.fullPath } });
  }
});
  • Step 2: app/middleware/guest.ts
export default defineNuxtRouteMiddleware(async () => {
  try {
    await $fetch("/api/me", { credentials: "include" });
    return navigateTo("/me");
  } catch {
    return;
  }
});

app/pages/login.vueregister.vue<script setup> 里设置 definePageMeta({ middleware: "guest" });在 me.vue 设置 definePageMeta({ middleware: "auth" })

  • Step 3: app/pages/login.vue 骨架(邮箱、密码、提交 $fetch('/api/auth/login', { method:'POST', body:{...}, credentials:'include' }),成功后 navigateTo('/me')

  • Step 4: register.vue / me.vue — 字段与附录 A 校验一致;/mePATCH 时用 try/catchonResponseError403 显示为「需验证邮箱」提示条。

  • Step 5: nuxt dev 手测 注册 → 自动进 /me → PATCH 403 → 在页面上用 _testTokens.verify 仅开发联调(生产不展示;测试环境可由 E2E 注入)。

  • Step 6: Commit

git add app/pages app/middleware && git commit -m "feat(app): minimal login register profile pages"

Task 12: 文档与收尾

  • Step 1: 在 README.md 增加「认证相关环境变量与迁移」小节(3~8 行,链到 spec/plan)。

  • Step 2: Final commit

git add README.md && git commit -m "docs: document auth env and migrations"

附录 A:server/services/auth.ts(完整草稿)

将下列内容保存为 server/services/auth.ts(若路径别名解析失败,仅改 import 路径,不改业务分支)。

import { randomBytes } from "node:crypto";
import type { H3Event } from "h3";
import { readBody, getRequestIP } from "h3";
import { and, eq, gt, isNull, sql } from "drizzle-orm";
import { dbGlobal } from "drizzle-pkg/lib/db";
import {
  authChallengesTable,
  usersTable,
} from "drizzle-pkg/lib/schema/schema";
import log4js from "logger";
import { hashChallengeToken, randomUrlToken } from "../utils/challenge-token";
import { jsonError } from "../utils/errors";
import { noopMailer } from "../utils/mailer";
import { hashPassword, verifyPassword } from "../utils/password";
import { rateLimitOrThrow } from "../utils/rate-limit";
import {
  clearSessionCookie,
  readSessionIdFromCookie,
  writeSessionCookie,
} from "../utils/session-cookie";
import { createSession, deleteSession, readSession } from "../utils/session-redis";
import { sessionTtlSeconds } from "../utils/session-ttl";
import { needsEmailVerified } from "../utils/verification-policy";

const logger = log4js.getLogger("AUTH");

function clientKey(event: H3Event): string {
  return (
    getRequestIP(event) || (event.node.socket?.remoteAddress ?? "") || "unknown"
  );
}

function publicUser(row: typeof usersTable.$inferSelect) {
  return {
    id: row.id,
    name: row.name,
    age: row.age,
    email: row.email,
    emailVerified: Boolean(row.emailVerifiedAt),
  };
}

async function requireSessionUser(event: H3Event) {
  const sid = readSessionIdFromCookie(event);
  if (!sid) jsonError(401, "UNAUTHORIZED", "请先登录");
  const sess = await readSession(sid);
  if (!sess) jsonError(401, "UNAUTHORIZED", "会话已失效");
  const rows = await dbGlobal
    .select()
    .from(usersTable)
    .where(eq(usersTable.id, sess.userId))
    .limit(1);
  const user = rows[0];
  if (!user) {
    await deleteSession(sid);
    jsonError(401, "UNAUTHORIZED", "用户不存在");
  }
  if (user.sessionVersion !== sess.sessionVersion) {
    await deleteSession(sid);
    jsonError(401, "UNAUTHORIZED", "会话已失效");
  }
  return { user, sessionId: sid };
}

export async function registerWithSession(event: H3Event) {
  await rateLimitOrThrow(`rl:register:${clientKey(event)}`, 30, 900);
  const body = await readBody<{
    email?: string;
    password?: string;
    name?: string;
    age?: number;
  }>(event);
  const email = (body.email || "").trim().toLowerCase();
  const password = body.password || "";
  const name = (body.name || "").trim();
  const age = body.age;
  if (!email.includes("@")) jsonError(400, "INVALID_EMAIL", "邮箱格式无效");
  if (password.length < 8) jsonError(400, "WEAK_PASSWORD", "密码至少 8 位");
  if (!name) jsonError(400, "INVALID_NAME", "姓名必填");
  if (typeof age !== "number" || age < 1 || age > 150) {
    jsonError(400, "INVALID_AGE", "年龄无效");
  }

  const dup = await dbGlobal
    .select({ id: usersTable.id })
    .from(usersTable)
    .where(eq(usersTable.email, email))
    .limit(1);
  if (dup.length) jsonError(409, "EMAIL_IN_USE", "该邮箱已注册");

  const passwordHash = await hashPassword(password);
  const [user] = await dbGlobal
    .insert(usersTable)
    .values({ email, passwordHash, name, age })
    .returning();

  const verifyToken = randomUrlToken();
  await dbGlobal.insert(authChallengesTable).values({
    userId: user.id,
    type: "email_verify",
    tokenHash: hashChallengeToken(verifyToken),
    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
  });

  await noopMailer.sendVerificationEmail({ to: email, token: verifyToken });
  if (process.env.AUTH_DEBUG_LOG_TOKENS === "true") {
    logger.info(`verify email token (debug): ${verifyToken}`);
  }

  const oldSid = readSessionIdFromCookie(event);
  if (oldSid) await deleteSession(oldSid);

  const sessionId = randomBytes(32).toString("hex");
  await createSession(sessionId, {
    userId: user.id,
    sessionVersion: user.sessionVersion,
    createdAt: new Date().toISOString(),
  });
  writeSessionCookie(event, sessionId, sessionTtlSeconds());

  const res: Record<string, unknown> = { user: publicUser(user) };
  if (process.env.NODE_ENV === "test") {
    res._testTokens = { verify: verifyToken };
  }
  return res;
}

export async function loginWithSession(
  event: H3Event,
  input: { email: string; password: string },
) {
  await rateLimitOrThrow(`rl:login:${clientKey(event)}`, 40, 900);
  const email = input.email.trim().toLowerCase();
  const password = input.password;
  if (!email || !password) jsonError(400, "INVALID_INPUT", "邮箱与密码必填");

  const rows = await dbGlobal
    .select()
    .from(usersTable)
    .where(eq(usersTable.email, email))
    .limit(1);
  const user = rows[0];
  if (!user || !(await verifyPassword(password, user.passwordHash))) {
    jsonError(401, "INVALID_CREDENTIALS", "邮箱或密码错误");
  }

  const oldSid = readSessionIdFromCookie(event);
  if (oldSid) await deleteSession(oldSid);

  const sessionId = randomBytes(32).toString("hex");
  await createSession(sessionId, {
    userId: user.id,
    sessionVersion: user.sessionVersion,
    createdAt: new Date().toISOString(),
  });
  writeSessionCookie(event, sessionId, sessionTtlSeconds());

  return { user: publicUser(user) };
}

export async function logoutSession(event: H3Event) {
  const sid = readSessionIdFromCookie(event);
  if (sid) await deleteSession(sid);
  clearSessionCookie(event);
  return { ok: true as const };
}

export async function getCurrentUser(event: H3Event) {
  const { user } = await requireSessionUser(event);
  return { user: publicUser(user) };
}

export async function patchMe(
  event: H3Event,
  body: { name?: string; age?: number },
) {
  const { user } = await requireSessionUser(event);
  if (needsEmailVerified("patch-me") && !user.emailVerifiedAt) {
    jsonError(403, "EMAIL_NOT_VERIFIED", "请先完成邮箱验证");
  }
  const name = body.name !== undefined ? String(body.name).trim() : user.name;
  const age = body.age !== undefined ? body.age : user.age;
  if (!name) jsonError(400, "INVALID_NAME", "姓名必填");
  if (typeof age !== "number" || age < 1 || age > 150) {
    jsonError(400, "INVALID_AGE", "年龄无效");
  }

  const [updated] = await dbGlobal
    .update(usersTable)
    .set({ name, age, updatedAt: new Date() })
    .where(eq(usersTable.id, user.id))
    .returning();

  return { user: publicUser(updated) };
}

export async function forgotPassword(event: H3Event, emailRaw: string) {
  await rateLimitOrThrow(`rl:forgot:${clientKey(event)}`, 20, 3600);
  const email = emailRaw.trim().toLowerCase();
  let resetToken: string | undefined;

  const rows = await dbGlobal
    .select()
    .from(usersTable)
    .where(eq(usersTable.email, email))
    .limit(1);
  const user = rows[0];
  if (user) {
    resetToken = randomUrlToken();
    await dbGlobal.insert(authChallengesTable).values({
      userId: user.id,
      type: "password_reset",
      tokenHash: hashChallengeToken(resetToken),
      expiresAt: new Date(Date.now() + 60 * 60 * 1000),
    });
    await noopMailer.sendPasswordResetEmail({ to: email, token: resetToken });
    if (process.env.AUTH_DEBUG_LOG_TOKENS === "true") {
      logger.info(`password reset token (debug): ${resetToken}`);
    }
  }

  const base = { ok: true as const };
  if (process.env.NODE_ENV === "test" && resetToken) {
    return { ...base, _testTokens: { reset: resetToken } };
  }
  return base;
}

export async function resetPassword(
  event: H3Event,
  input: { token: string; new_password: string },
) {
  const token = input.token;
  const new_password = input.new_password;
  if (!token || new_password.length < 8) {
    jsonError(400, "INVALID_INPUT", "token 或新密码无效");
  }
  const h = hashChallengeToken(token);
  const rows = await dbGlobal
    .select()
    .from(authChallengesTable)
    .where(
      and(
        eq(authChallengesTable.tokenHash, h),
        eq(authChallengesTable.type, "password_reset"),
        isNull(authChallengesTable.consumedAt),
        gt(authChallengesTable.expiresAt, new Date()),
      ),
    )
    .limit(1);
  const ch = rows[0];
  if (!ch) jsonError(400, "INVALID_TOKEN", "链接无效或已过期");

  const passwordHash = await hashPassword(new_password);
  await dbGlobal
    .update(usersTable)
    .set({
      passwordHash,
      sessionVersion: sql`${usersTable.sessionVersion} + 1`,
      updatedAt: new Date(),
    })
    .where(eq(usersTable.id, ch.userId));

  await dbGlobal
    .update(authChallengesTable)
    .set({ consumedAt: new Date() })
    .where(eq(authChallengesTable.id, ch.id));

  return { ok: true as const };
}

export async function verifyEmail(event: H3Event, tokenRaw: string) {
  const token = tokenRaw.trim();
  if (!token) jsonError(400, "INVALID_INPUT", "token 必填");
  const h = hashChallengeToken(token);
  const rows = await dbGlobal
    .select()
    .from(authChallengesTable)
    .where(
      and(
        eq(authChallengesTable.tokenHash, h),
        eq(authChallengesTable.type, "email_verify"),
        isNull(authChallengesTable.consumedAt),
        gt(authChallengesTable.expiresAt, new Date()),
      ),
    )
    .limit(1);
  const ch = rows[0];
  if (!ch) jsonError(400, "INVALID_TOKEN", "链接无效或已过期");

  await dbGlobal
    .update(usersTable)
    .set({ emailVerifiedAt: new Date(), updatedAt: new Date() })
    .where(eq(usersTable.id, ch.userId));

  await dbGlobal
    .update(authChallengesTable)
    .set({ consumedAt: new Date() })
    .where(eq(authChallengesTable.id, ch.id));

  return { ok: true as const };
}

计划自检(对照规格)

规格章节 覆盖任务
用户列扩展 + challenges + linked_accounts Task 2
Redis Session + Cookie Task 4–5、Task 9
密码哈希、token 哈希 Task 6
API 列表 Task 9
未验证门禁 Task 7、Task 8–9
forgot 统一 200、reset 吊销会话 Task 8(session_version++
Mailer noop / debug token Task 7、Task 8
最小页面 / middleware Task 11
删除 hello 泄露 Task 9
测试 Task 5–6、10(可选 HTTP 集成)
OAuth 表 Task 2

已知简化: 全量 createError + 统一 onError 格式化需在 Task 9 保证前端收到 { error: { code, message } };若 Nitro 默认不序列化 data,需自定义 handler 包装或 h3 错误钩子——实现时以浏览器 curl 断言为准。


执行交接

计划已保存到 docs/superpowers/plans/2026-04-12-auth-user.md

两种执行方式:

  1. Subagent-Driven(推荐) — 每个 Task 派生子代理执行,任务间人工复核,迭代快。
  2. Inline Execution — 本会话用 executing-plans 按检查点批量实现。

你更倾向哪一种?