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.ts、password.ts、challenge.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、pg、ioredis、bcryptjs、项目已有 log4js / logger。
规格依据: docs/superpowers/specs/2026-04-12-auth-user-design.md
文件结构总览(创建 / 修改)
| 路径 | 职责 |
|---|---|
package.json |
新增依赖 bcryptjs、ioredis;新增 test 脚本 |
.env.example |
增加 REDIS_URL、AUTH_DEBUG_LOG_TOKENS、SESSION_TTL_SECONDS、COOKIE_DOMAIN |
packages/drizzle-pkg/lib/schema/schema.ts |
扩展 users_table;新增 auth_challenge_type 枚举表字段;auth_challenges、linked_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 + hashChallengeToken(node:crypto sha256) |
server/utils/session-cookie.ts |
Cookie 名、serializeSessionCookie、clearSessionCookie |
server/utils/session-ttl.ts |
sessionTtlSeconds()(无 Redis 依赖,便于单测) |
server/utils/session-redis.ts |
createSession、readSession、deleteSession;value 含 userId、sessionVersion |
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.ts、server/api/me.patch.ts |
当前用户 |
server/api/hello.ts |
删除文件(或改为 401 无数据;计划采用删除,避免误暴露) |
app/pages/login.vue、register.vue、me.vue |
最小 UI |
app/middleware/auth.ts、guest.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 出现 bcryptjs、ioredis 与 @types/bcryptjs。
- Step 2: 增加
test脚本
在根 package.json 的 scripts 中增加:
"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_challenges、linked_accounts;ALTER users_table 增加列。若 Drizzle 对 NOT NULL password_hash 与已有数据冲突,在 SQL 中改为:先 ADD COLUMN password_hash text,UPDATE 占位后再 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"
Task 4: Redis 单例与 Cookie 常量
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-pkg、logger 与 ../utils/* 解析正确)。行为摘要:
-
registerWithSession:限流 → 校验 → 插入用户 +email_verifychallenge →noopMailer→ 调试日志 → 轮换旧会话 → 新 Redis session + Cookie;NODE_ENV=test时响应含_testTokens.verify。 -
loginWithSession:限流 → 校验密码 → 轮换旧会话 → 新 session;错误统一 401(不区分用户不存在与密码错误,可选;附录为防枚举使用统一文案)。 -
logoutSession:删 Redis +clearSessionCookie。 -
getCurrentUser/patchMe:requireSessionUser比对sessionVersion;patch-me需emailVerifiedAt。 -
forgotPassword:限流 → 始终{ ok: true };存在用户则写password_reset;test 环境附加_testTokens.reset。 -
resetPassword:session_version用 Drizzlesql表达式 +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(若存在)确保createError的data原样输出 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.vue、register.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 校验一致;/me上PATCH时用try/catch或onResponseError将 403 显示为「需验证邮箱」提示条。 -
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。
两种执行方式:
- Subagent-Driven(推荐) — 每个 Task 派生子代理执行,任务间人工复核,迭代快。
- Inline Execution — 本会话用 executing-plans 按检查点批量实现。
你更倾向哪一种?