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.
 
 
 
 

36 KiB

登录注册系统实现计划

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: 实现基于 Strategy 模式的登录注册系统,支持密码认证、JWT Refresh Token、账户锁定、限流,并预留 OAuth2 扩展接口。

Architecture: AuthService 统一入口持有多个 Strategy(PasswordStrategy 实现现在,OAuthStrategy 预留接口)。Access Token 用无状态 JWT,Refresh Token 用 UUIDv4 存数据库并通过 HTTP Only Cookie 传输。

Tech Stack: bcryptjs / Zod / drizzle-orm / @libsql/client / jose (JWT)


文件结构

packages/drizzle-pkg/lib/schema/
├── auth.ts           ← 修改:users 字段扩展 + user_sessions 表

server/
├── types/
│   └── auth.ts       ← 新建:AuthTypes(TokenPair, Session, Strategy interfaces)
├── service/auth/
│   ├── index.ts      ← 新建:AuthService 主体
│   ├── strategies/
│   │   ├── index.ts   ← 新建:StrategyRegistry
│   │   ├── password.strategy.ts  ← 新建:PasswordStrategy
│   │   └── oauth.strategy.ts      ← 新建:OAuthStrategy 抽象类(预留)
│   └── lib/
│       ├── jwt.ts            ← 新建:sign/verify JWT
│       ├── password.ts       ← 新建:bcrypt 加密 + 校验 + 历史
│       └── rate-limit.ts     ← 新建:限流 + 锁定检查
├── middleware/
│   └── auth.ts       ← 新建:请求认证中间件
└── api/auth/
    ├── register.post.ts    ← 新建
    ├── login.post.ts       ← 新建
    ├── logout.post.ts      ← 新建
    ├── refresh.post.ts     ← 新建
    └── me.get.ts           ← 新建

Task 1: Schema 迁移(users 字段扩展 + user_sessions 表)

Files:

  • Create: packages/drizzle-pkg/migrations/0002_add_auth_tables.sql
  • Modify: packages/drizzle-pkg/lib/schema/auth.ts
-- packages/drizzle-pkg/migrations/0002_add_auth_tables.sql

-- 1. 添加 auth 相关字段到 users 表(email 已存在但非 unique)
CREATE TABLE `users_new` (`id` integer PRIMARY KEY NOT NULL, `username` text NOT NULL, `email` text, `nickname` text, `password` text NOT NULL, `avatar` text, `role` text DEFAULT 'user' NOT NULL, `status` text DEFAULT 'active' NOT NULL, `public_slug` text, `bio_markdown` text, `bio_visibility` text DEFAULT 'private' NOT NULL, `social_links_json` text DEFAULT '[]' NOT NULL, `avatar_visibility` text DEFAULT 'private' NOT NULL, `discover_visible` integer DEFAULT true NOT NULL, `discover_location` text, `discover_show_location` integer DEFAULT false NOT NULL, `created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL, `updated_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL, `email_verified` integer DEFAULT false NOT NULL, `password_history` text DEFAULT '[]' NOT NULL, `failed_login_attempts` integer DEFAULT 0 NOT NULL, `lockout_until` integer, `last_login_at` integer, `last_login_ip` text);
--> statement-breakpoint
INSERT INTO `users_new` (`id`, `username`, `email`, `nickname`, `password`, `avatar`, `role`, `status`, `public_slug`, `bio_markdown`, `bio_visibility`, `social_links_json`, `avatar_visibility`, `discover_visible`, `discover_location`, `discover_show_location`, `created_at`, `updated_at`) SELECT `id`, `username`, `email`, `nickname`, `password`, `avatar`, `role`, `status`, `public_slug`, `bio_markdown`, `bio_visibility`, `social_links_json`, `avatar_visibility`, `discover_visible`, `discover_location`, `discover_show_location`, `created_at`, `updated_at` FROM `users`;
--> statement-breakpoint
DROP TABLE `users`;
--> statement-breakpoint
ALTER TABLE `users_new` RENAME TO `users`;
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
--> statement-breakpoint
CREATE INDEX `users_email_index` ON `users` (`email`);

-- 2. 创建 user_sessions 表
CREATE TABLE `user_sessions` (
    `id` text PRIMARY KEY NOT NULL,
    `user_id` integer NOT NULL,
    `refresh_token_hash` text NOT NULL,
    `user_agent` text,
    `ip` text,
    `created_at` integer NOT NULL,
    `expires_at` integer NOT NULL,
    `revoked_at` integer,
    FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE INDEX `user_sessions_user_id_index` ON `user_sessions` (`user_id`);
// packages/drizzle-pkg/lib/schema/auth.ts
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const users = sqliteTable("users", {
  id: integer().primaryKey(),
  username: text().notNull().unique(),
  email: text(), // unique index added via migration
  nickname: text(),
  password: text().notNull(),
  avatar: text(),
  role: text().notNull().default("user"),
  status: text().notNull().default("active"),
  createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
  updatedAt: integer("updated_at", { mode: "timestamp_ms" })
    .defaultNow()
    .$onUpdate(() => new Date())
    .notNull(),
  // --- auth fields ---
  emailVerified: integer("email_verified", { mode: "boolean" }).default(false).notNull(),
  passwordHistory: text("password_history").default("[]").notNull(), // JSON array of bcrypt hashes
  failedLoginAttempts: integer("failed_login_attempts").default(0).notNull(),
  lockoutUntil: integer("lockout_until", { mode: "timestamp_ms" }),
  lastLoginAt: integer("last_login_at", { mode: "timestamp_ms" }),
  lastLoginIp: text("last_login_ip"),
});

export const userSessions = sqliteTable("user_sessions", {
  id: text().primaryKey(),
  userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
  refreshTokenHash: text("refresh_token_hash").notNull(),
  userAgent: text("user_agent"),
  ip: text(),
  createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
  expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
  revokedAt: integer("revoked_at", { mode: "timestamp_ms" }),
});
  • Step 1: 创建 migration 文件 0002_add_auth_tables.sql

Copy the SQL above into the file.

  • Step 2: 更新 packages/drizzle-pkg/lib/schema/auth.ts

Update the existing schema file to add the new fields and the userSessions table.

  • Step 3: 运行 drizzle generate 生成类型

Run: bun --filter drizzle-pkg generate Expected: 生成新 migration 类型定义

  • Step 4: 运行 drizzle migrate 执行迁移

Run: bun --filter drizzle-pkg migrate Expected: 迁移成功,无错误

  • Step 5: Commit
git add packages/drizzle-pkg/migrations/0002_add_auth_tables.sql packages/drizzle-pkg/lib/schema/auth.ts
git commit -m "feat(auth): add auth schema - users ext + user_sessions table"

Task 2: 基础工具库(password.ts / jwt.ts / rate-limit.ts)

Files:

  • Create: server/service/auth/lib/password.ts

  • Create: server/service/auth/lib/jwt.ts

  • Create: server/service/auth/lib/rate-limit.ts

  • Step 1: server/service/auth/lib/password.ts

// server/service/auth/lib/password.ts
import bcrypt from "bcryptjs";

const SALT_ROUNDS = 12;
const PASSWORD_HISTORY_SIZE = 5;

export function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS);
}

export async function verifyPassword(
  password: string,
  hashedPassword: string
): Promise<boolean> {
  return bcrypt.compare(password, hashedPassword);
}

export function isPasswordInHistory(
  password: string,
  history: string[]
): Promise<boolean> {
  if (history.length === 0) return false;
  for (const h of history) {
    if (await bcrypt.compare(password, h)) return true;
  }
  return false;
}

export function addPasswordToHistory(
  newHash: string,
  history: string[]
): string[] {
  const updated = [newHash, ...history];
  return updated.slice(0, PASSWORD_HISTORY_SIZE);
}

export interface PasswordStrengthResult {
  valid: boolean;
  errors: string[];
}

export function validatePasswordStrength(password: string): PasswordStrengthResult {
  const errors: string[] = [];
  if (password.length < 8) errors.push("至少8个字符");
  if (!/[A-Z]/.test(password)) errors.push("需包含大写字母");
  if (!/[a-z]/.test(password)) errors.push("需包含小写字母");
  if (!/[0-9]/.test(password)) errors.push("需包含数字");
  if (!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password))
    errors.push("需包含特殊字符");
  return { valid: errors.length === 0, errors };
}
  • Step 2: server/service/auth/lib/jwt.ts

需要先确认项目是否有 jose 包,没有则用 jsonwebtoken:

grep -r "jose\|jsonwebtoken" /home/dash/coding/nuxt-app/package.json

假设用 jose(比 jsonwebtoken 更现代,tree-shakeable):

// server/service/auth/lib/jwt.ts
import { SignJWT, jwtVerify, decodeJwt } from "jose";
import type { JWTPayload } from "jose";

const JWT_SECRET = new TextEncoder().encode(
  process.env.JWT_SECRET || "dev-secret-change-in-production"
);
const ACCESS_TOKEN_EXPIRY = "15m";

export interface AccessTokenPayload extends JWTPayload {
  userId: number;
  sessionId: string;
  role: string;
}

export async function signAccessToken(payload: {
  userId: number;
  sessionId: string;
  role: string;
}): Promise<string> {
  return new SignJWT(payload as Record<string, unknown>)
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime(ACCESS_TOKEN_EXPIRY)
    .sign(JWT_SECRET);
}

export async function verifyAccessToken(
  token: string
): Promise<AccessTokenPayload | null> {
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET);
    return payload as AccessTokenPayload;
  } catch {
    return null;
  }
}

export function decodeAccessTokenNoVerify(token: string): AccessTokenPayload | null {
  try {
    return decodeJwt(token) as AccessTokenPayload;
  } catch {
    return null;
  }
}
  • Step 3: server/service/auth/lib/rate-limit.ts
// server/service/auth/lib/rate-limit.ts

// 内存限流 Map:key = IP,value = { count, resetAt }
const loginAttempts = new Map<
  string,
  { count: number; resetAt: number }
>();

const WINDOW_MS = 60_000;       // 1 分钟窗口
const MAX_ATTEMPTS = 5;         // 最多 5 次
const LOCKOUT_MS = 15 * 60_000; // 锁定 15 分钟

export function checkRateLimit(ip: string): {
  allowed: boolean;
  retryAfterMs: number;
} {
  const now = Date.now();
  const entry = loginAttempts.get(ip);

  if (!entry || now > entry.resetAt) {
    loginAttempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
    return { allowed: true, retryAfterMs: 0 };
  }

  if (entry.count >= MAX_ATTEMPTS) {
    return {
      allowed: false,
      retryAfterMs: entry.resetAt - now,
    };
  }

  entry.count++;
  return { allowed: true, retryAfterMs: 0 };
}

export function clearRateLimit(ip: string): void {
  loginAttempts.delete(ip);
}

// 账户锁定检查(基于 users.lockout_until 字段,在 DB 层做,这里只提供辅助函数)
export function isLocked(lockoutUntil: Date | null): boolean {
  if (!lockoutUntil) return false;
  return Date.now() < lockoutUntil.getTime();
}

export function getLockoutRemainingMs(lockoutUntil: Date): number {
  return Math.max(0, lockoutUntil.getTime() - Date.now());
}
  • Step 4: Commit
git add server/service/auth/lib/password.ts server/service/auth/lib/jwt.ts server/service/auth/lib/rate-limit.ts
git commit -m "feat(auth): add password/jwt/rate-limit utility libs"

Task 3: Strategy 模式实现(PasswordStrategy + OAuthStrategy 抽象类)

Files:

  • Create: server/service/auth/strategies/oauth.strategy.ts

  • Create: server/service/auth/strategies/password.strategy.ts

  • Create: server/service/auth/strategies/index.ts

  • Create: server/types/auth.ts

  • Step 1: server/types/auth.ts

// server/types/auth.ts

export interface TokenPair {
  accessToken: string;
  refreshToken: string;
}

export interface Session {
  id: string;
  userId: number;
  userAgent: string | null;
  ip: string | null;
  createdAt: Date;
  expiresAt: Date;
  revokedAt: Date | null;
}

export interface AuthUser {
  id: number;
  email: string | null;
  username: string;
  role: string;
  status: string;
}

export type AuthErrorCode =
  | "INVALID_CREDENTIALS"
  | "ACCOUNT_LOCKED"
  | "WEAK_PASSWORD"
  | "EMAIL_EXISTS"
  | "TOKEN_EXPIRED"
  | "SESSION_REVOKED"
  | "RATE_LIMITED";

export interface AuthError {
  error: {
    code: AuthErrorCode;
    message: string;
  };
}

export interface IAuthStrategy {
  readonly type: "password" | "oauth";
  authenticate(credentials: unknown): Promise<AuthUser>;
  generateTokens(user: AuthUser, sessionId: string): Promise<TokenPair>;
  revokeSession(sessionId: string): Promise<void>;
}
  • Step 2: server/service/auth/strategies/oauth.strategy.ts
// server/service/auth/strategies/oauth.strategy.ts
import type { IAuthStrategy, AuthUser, TokenPair } from "../../types/auth";

export abstract class OAuthStrategy implements IAuthStrategy {
  abstract readonly provider: string;
  abstract readonly type: "oauth";

  abstract authenticate(oauthToken: string): Promise<AuthUser>;

  async generateTokens(
    user: AuthUser,
    sessionId: string
  ): Promise<TokenPair> {
    //delegated to AuthService — strategy itself only knows the interface
    throw new Error("Use AuthService.generateTokens()");
  }

  async revokeSession(sessionId: string): Promise<void> {
    throw new Error("Use AuthService.revokeSession()");
  }
}
  • Step 3: server/service/auth/strategies/password.strategy.ts
// server/service/auth/strategies/password.strategy.ts
import { eq } from "drizzle-orm";
import { dbGlobal } from "@/drizzle-pkg/lib/db";
import { users } from "@/drizzle-pkg/lib/schema/auth";
import type { IAuthStrategy, AuthUser, TokenPair } from "../../types/auth";
import {
  hashPassword,
  verifyPassword,
  isPasswordInHistory,
  addPasswordToHistory,
  validatePasswordStrength,
  isLocked,
} from "../lib/password";
import { signAccessToken } from "../lib/jwt";

export class PasswordStrategy implements IAuthStrategy {
  readonly type = "password" as const;

  async authenticate(credentials: {
    email: string;
    password: string;
    ip?: string;
    userAgent?: string;
  }): Promise<AuthUser> {
    const { email, password, ip, userAgent } = credentials;

    // 查找用户
    const [user] = await dbGlobal
      .select()
      .from(users)
      .where(eq(users.email, email))
      .limit(1);

    if (!user) {
      throw { code: "INVALID_CREDENTIALS", message: "邮箱或密码错误" };
    }

    // 检查账户锁定
    if (user.lockoutUntil && isLocked(user.lockoutUntil)) {
      throw { code: "ACCOUNT_LOCKED", message: "账户已锁定,请稍后再试" };
    }

    // 校验密码
    const valid = await verifyPassword(password, user.password);
    if (!valid) {
      // 更新失败次数
      const attempts = (user.failedLoginAttempts || 0) + 1;
      const lockoutUntil = attempts >= 5 ? new Date(Date.now() + 15 * 60_000) : null;
      await dbGlobal
        .update(users)
        .set({
          failedLoginAttempts: attempts,
          lockoutUntil: lockoutUntil ?? null,
        })
        .where(eq(users.id, user.id));

      throw { code: "INVALID_CREDENTIALS", message: "邮箱或密码错误" };
    }

    // 登录成功:重置失败计数 + 更新 lastLogin
    await dbGlobal
      .update(users)
      .set({
        failedLoginAttempts: 0,
        lockoutUntil: null,
        lastLoginAt: new Date(),
        lastLoginIp: ip ?? null,
      })
      .where(eq(users.id, user.id));

    return {
      id: user.id,
      email: user.email,
      username: user.username,
      role: user.role,
      status: user.status,
    };
  }

  async generateTokens(
    user: AuthUser,
    sessionId: string
  ): Promise<TokenPair> {
    const accessToken = await signAccessToken({
      userId: user.id,
      sessionId,
      role: user.role,
    });
    // refresh token 由 AuthService 生成,这里只返回 accessToken 占位
    return { accessToken, refreshToken: "" };
  }

  async revokeSession(sessionId: string): Promise<void> {
    // delegated to AuthService
  }
}
  • Step 4: server/service/auth/strategies/index.ts
// server/service/auth/strategies/index.ts
import { PasswordStrategy } from "./password.strategy";
import { OAuthStrategy } from "./oauth.strategy";
import type { IAuthStrategy } from "../../types/auth";

export { PasswordStrategy, OAuthStrategy };

export const strategyRegistry: Record<string, IAuthStrategy> = {
  password: new PasswordStrategy(),
  // oauth strategies will be added here when implemented
};

export function getStrategy(type: string): IAuthStrategy {
  const s = strategyRegistry[type];
  if (!s) throw new Error(`Unknown auth strategy: ${type}`);
  return s;
}
  • Step 5: Commit
git add server/types/auth.ts server/service/auth/strategies/oauth.strategy.ts server/service/auth/strategies/password.strategy.ts server/service/auth/strategies/index.ts
git commit -m "feat(auth): implement Strategy pattern - PasswordStrategy + OAuthStrategy abstract"

Task 4: AuthService 主体

Files:

  • Create: server/service/auth/index.ts
// server/service/auth/index.ts
import { v4 as uuidv4 } from "uuid";
import { eq, and, isNull } from "drizzle-orm";
import { dbGlobal } from "@/drizzle-pkg/lib/db";
import { users, userSessions } from "@/drizzle-pkg/lib/schema/auth";
import { signAccessToken } from "./lib/jwt";
import { hashPassword } from "./lib/password";
import type { AuthUser, TokenPair } from "../types/auth";
import { getStrategy } from "./strategies";

const REFRESH_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days

export class AuthService {
  async register(opts: {
    email: string;
    password: string;
    username: string;
    ip?: string;
    userAgent?: string;
  }): Promise<AuthUser> {
    const { email, password, username, ip, userAgent } = opts;

    // 检查邮箱是否已存在
    const [existing] = await dbGlobal
      .select({ id: users.id })
      .from(users)
      .where(eq(users.email, email))
      .limit(1);

    if (existing) {
      throw { code: "EMAIL_EXISTS", message: "该邮箱已注册" };
    }

    // 密码强度校验
    const { valid, errors } = validatePasswordStrength(password);
    if (!valid) {
      throw { code: "WEAK_PASSWORD", message: errors.join(";") };
    }

    const passwordHash = await hashPassword(password);

    // 插入用户
    const [user] = await dbGlobal
      .insert(users)
      .values({
        email,
        username,
        password: passwordHash,
        role: "user",
        status: "active",
        passwordHistory: JSON.stringify([passwordHash]),
      })
      .returning();

    // 创建 session
    await this.createSession(user.id, ip, userAgent);

    return {
      id: user.id,
      email: user.email!,
      username: user.username,
      role: user.role,
      status: user.status,
    };
  }

  async login(opts: {
    email: string;
    password: string;
    ip?: string;
    userAgent?: string;
  }): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
    const { email, password, ip, userAgent } = opts;

    // 用 PasswordStrategy 认证(包含锁定检查)
    const strategy = getStrategy("password");
    const user = await strategy.authenticate({ email, password, ip, userAgent });

    // 创建 session
    const sessionId = await this.createSession(user.id, ip, userAgent);

    // 生成 tokens
    const tokens = await this.generateTokens(user, sessionId);

    return { user, ...tokens };
  }

  async logout(sessionId: string): Promise<void> {
    await dbGlobal
      .update(userSessions)
      .set({ revokedAt: new Date() })
      .where(eq(userSessions.id, sessionId));
  }

  async refreshToken(
    refreshToken: string
  ): Promise<{ accessToken: string; newRefreshToken: string }> {
    // 在所有 session 中查找匹配的 refreshToken hash
    const sessions = await dbGlobal
      .select()
      .from(userSessions)
      .where(isNull(userSessions.revokedAt));

    let matchedSession: (typeof sessions)[0] | null = null;
    for (const s of sessions) {
      // 用 timing-safe 比较(简单实现:直接比对 UUID,因为 token 本身是 uuid,
      // 不需要 bcrypt 了,直接存 hash 就好,这里用 tokenId 比对)
      // 实际上 refreshToken 本身存的就是 hash,需要验证
      if (s.id === refreshToken && s.expiresAt > new Date()) {
        matchedSession = s;
        break;
      }
    }

    if (!matchedSession) {
      throw { code: "SESSION_REVOKED", message: "Session 已失效" };
    }

    // 获取用户
    const [user] = await dbGlobal
      .select()
      .from(users)
      .where(eq(users.id, matchedSession.userId))
      .limit(1);

    if (!user || user.status !== "active") {
      throw { code: "INVALID_CREDENTIALS", message: "用户状态异常" };
    }

    // 作废旧 session
    await dbGlobal
      .update(userSessions)
      .set({ revokedAt: new Date() })
      .where(eq(userSessions.id, matchedSession.id));

    // 创建新 session(refresh token rotation)
    const newSessionId = await this.createSession(user.id, matchedSession.ip ?? undefined, matchedSession.userAgent ?? undefined);

    const authUser: AuthUser = {
      id: user.id,
      email: user.email ?? null,
      username: user.username,
      role: user.role,
      status: user.status,
    };

    const tokens = await this.generateTokens(authUser, newSessionId);
    return { accessToken: tokens.accessToken, newRefreshToken: tokens.refreshToken };
  }

  async revokeSession(sessionId: string): Promise<void> {
    await dbGlobal
      .update(userSessions)
      .set({ revokedAt: new Date() })
      .where(eq(userSessions.id, sessionId));
  }

  private async createSession(
    userId: number,
    ip?: string,
    userAgent?: string
  ): Promise<string> {
    const id = uuidv4();
    const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRY_MS);

    await dbGlobal.insert(userSessions).values({
      id,
      userId,
      refreshTokenHash: "", // 在生成 token 时填充,这里先空着
      userAgent: userAgent ?? null,
      ip: ip ?? null,
      createdAt: new Date(),
      expiresAt,
      revokedAt: null,
    });

    return id;
  }

  private async generateTokens(
    user: AuthUser,
    sessionId: string
  ): Promise<TokenPair> {
    const accessToken = await signAccessToken({
      userId: user.id,
      sessionId,
      role: user.role,
    });
    // refreshToken = sessionId itself(UUIDv4 足够随机)
    const refreshToken = sessionId;

    // 更新 session 的 refreshTokenHash(用 sessionId 作为 token,存 hash)
    // 简单处理:直接存 token 本身(生产环境应用 bcrypt)
    await dbGlobal
      .update(userSessions)
      .set({ refreshTokenHash: refreshToken })
      .where(eq(userSessions.id, sessionId));

    return { accessToken, refreshToken };
  }
}

export const authService = new AuthService();

Note: uuid 包未在 package.json 中,需要确认是否已安装:grep uuid /home/dash/coding/nuxt-app/package.json。如果没有,Task 4 之前需要先添加 uuid 依赖。

  • Step 1: 确认 uuid 包是否已安装

Run: grep -r '"uuid"' /home/dash/coding/nuxt-app/package.json 如果不存在,记录需要在 Task 4 之前添加 uuid 依赖。

  • Step 2: 创建 server/service/auth/index.ts

Write the AuthService as shown above (without bcrypt for refreshToken hash for simplicity — production would use bcrypt).

  • Step 3: Commit
git add server/service/auth/index.ts
git commit -m "feat(auth): implement AuthService with Strategy integration"

Task 5: API 路由

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/refresh.post.ts
  • Create: server/api/auth/me.get.ts
// server/api/auth/register.post.ts
import { z } from "zod";
import { authService } from "../service/auth";
import { checkRateLimit } from "../service/auth/lib/rate-limit";

const RegisterSchema = z.object({
  email: z.string().email(),
  password: z.string(),
  username: z.string().min(2).max(32),
});

export default defineEventHandler(async (event) => {
  const ip = getHeader(event, "x-forwarded-for") ?? "unknown";
  const userAgent = getHeader(event, "user-agent") ?? undefined;

  const { allowed, retryAfterMs } = checkRateLimit(ip);
  if (!allowed) {
    setResponseStatus(event, 429);
    return { error: { code: "RATE_LIMITED", message: "操作过于频繁,请稍后再试" } };
  }

  const body = await readBody(event);
  const parsed = RegisterSchema.safeParse(body);
  if (!parsed.success) {
    setResponseStatus(event, 400);
    return { error: { code: "BAD_REQUEST", message: "参数错误" } };
  }

  try {
    const user = await authService.register({
      ...parsed.data,
      ip,
      userAgent,
    });
    setResponseStatus(event, 201);
    return { user };
  } catch (err: unknown) {
    const e = err as { code?: string; message?: string };
    setResponseStatus(event, e.code === "EMAIL_EXISTS" ? 409 : 400);
    return { error: { code: e.code ?? "UNKNOWN", message: e.message ?? "注册失败" } };
  }
});
// server/api/auth/login.post.ts
import { z } from "zod";
import { authService } from "../service/auth";
import { checkRateLimit } from "../service/auth/lib/rate-limit";

const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string(),
});

export default defineEventHandler(async (event) => {
  const ip = getHeader(event, "x-forwarded-for") ?? "unknown";
  const userAgent = getHeader(event, "user-agent") ?? undefined;

  const { allowed, retryAfterMs } = checkRateLimit(ip);
  if (!allowed) {
    setResponseStatus(event, 429);
    return { error: { code: "RATE_LIMITED", message: "操作过于频繁,请稍后再试" } };
  }

  const body = await readBody(event);
  const parsed = LoginSchema.safeParse(body);
  if (!parsed.success) {
    setResponseStatus(event, 400);
    return { error: { code: "BAD_REQUEST", message: "参数错误" } };
  }

  try {
    const { user, accessToken, refreshToken } = await authService.login({
      ...parsed.data,
      ip,
      userAgent,
    });

    // 设置 HTTP Only Cookie
    setCookie(event, "refresh_token", refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "strict",
      maxAge: 7 * 24 * 60 * 60, // 7 days in seconds
      path: "/",
    });

    return { user, accessToken };
  } catch (err: unknown) {
    const e = err as { code?: string; message?: string };
    const statusMap: Record<string, number> = {
      ACCOUNT_LOCKED: 423,
      INVALID_CREDENTIALS: 401,
    };
    setResponseStatus(event, statusMap[e.code ?? "UNKNOWN"] ?? 400);
    return { error: { code: e.code ?? "UNKNOWN", message: e.message ?? "登录失败" } };
  }
});
// server/api/auth/logout.post.ts
import { authService } from "../service/auth";

export default defineEventHandler(async (event) => {
  const refreshToken = getCookie(event, "refresh_token");
  if (refreshToken) {
    await authService.logout(refreshToken);
  }

  // 清除 cookie
  deleteCookie(event, "refresh_token", { path: "/" });

  return { success: true };
});
// server/api/auth/refresh.post.ts
import { authService } from "../service/auth";

export default defineEventHandler(async (event) => {
  const refreshToken = getCookie(event, "refresh_token");
  if (!refreshToken) {
    setResponseStatus(event, 401);
    return { error: { code: "TOKEN_EXPIRED", message: "未登录" } };
  }

  try {
    const { accessToken, newRefreshToken } = await authService.refreshToken(refreshToken);

    // 轮转:清除旧 cookie,设置新 cookie
    setCookie(event, "refresh_token", newRefreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "strict",
      maxAge: 7 * 24 * 60 * 60,
      path: "/",
    });

    return { accessToken };
  } catch (err: unknown) {
    const e = err as { code?: string; message?: string };
    setResponseStatus(event, 401);
    return { error: { code: e.code ?? "TOKEN_EXPIRED", message: e.message ?? "Token 无效" } };
  }
});
// server/api/auth/me.get.ts
import { verifyAccessToken } from "../service/auth/lib/jwt";
import { dbGlobal } from "@/drizzle-pkg/lib/db";
import { users } from "@/drizzle-pkg/lib/schema/auth";
import { eq } from "drizzle-orm";

export default defineEventHandler(async (event) => {
  const accessToken = getHeader(event, "authorization")?.replace("Bearer ", "");
  if (!accessToken) {
    setResponseStatus(event, 401);
    return { error: { code: "TOKEN_EXPIRED", message: "未登录" } };
  }

  const payload = await verifyAccessToken(accessToken);
  if (!payload) {
    setResponseStatus(event, 401);
    return { error: { code: "TOKEN_EXPIRED", message: "Token 无效" } };
  }

  const [user] = await dbGlobal
    .select({
      id: users.id,
      email: users.email,
      username: users.username,
      role: users.role,
      status: users.status,
    })
    .from(users)
    .where(eq(users.id, payload.userId))
    .limit(1);

  if (!user) {
    setResponseStatus(event, 404);
    return { error: { code: "NOT_FOUND", message: "用户不存在" } };
  }

  return { user };
});
  • Step 1: 创建 server/api/auth/register.post.ts

  • Step 2: 创建 server/api/auth/login.post.ts

  • Step 3: 创建 server/api/auth/logout.post.ts

  • Step 4: 创建 server/api/auth/refresh.post.ts

  • Step 5: 创建 server/api/auth/me.get.ts

  • Step 6: Commit

git add server/api/auth/register.post.ts server/api/auth/login.post.ts server/api/auth/logout.post.ts server/api/auth/refresh.post.ts server/api/auth/me.get.ts
git commit -m "feat(auth): add auth API routes - register/login/logout/refresh/me"

Task 6: 中间件(请求认证)

Files:

  • Create: server/middleware/auth.ts
// server/middleware/auth.ts
import { verifyAccessToken } from "../service/auth/lib/jwt";

export default defineEventHandler(async (event) => {
  // 只对需要认证的路由做处理,这里不拦截全部路由
  // 而是让 /api/auth/me 等路由自行验证
  // 这个中间件可以用来统一注入 user 到 context(可选)

  const accessToken = getHeader(event, "authorization")?.replace("Bearer ", "");
  if (!accessToken) return;

  const payload = await verifyAccessToken(accessToken);
  if (payload) {
    event.context.user = {
      userId: payload.userId,
      sessionId: payload.sessionId,
      role: payload.role,
    };
  }
});

Note: Nuxt 的 server middleware 会在所有路由前执行。如果只对特定路由需要认证,可以在路由内部处理(已实现),或者用 Nuxt 的 eventHandler wrapper 加权限标记来实现 route-level middleware。这个中间件作为轻量版本可用可不用。

  • Step 1: 创建 server/middleware/auth.ts

  • Step 2: Commit

git add server/middleware/auth.ts
git commit -m "feat(auth): add auth middleware for request context injection"

Task 7: 测试覆盖

Files:

  • Create: server/service/auth/__tests__/password.test.ts
  • Create: server/service/auth/__tests__/jwt.test.ts
  • Create: server/service/auth/__tests__/rate-limit.test.ts
  • Create: server/service/auth/__tests__/auth-service.test.ts
// server/service/auth/__tests__/password.test.ts
import { describe, it, expect } from "bun:test";
import {
  hashPassword,
  verifyPassword,
  validatePasswordStrength,
  isPasswordInHistory,
  addPasswordToHistory,
} from "../lib/password";

describe("password utils", () => {
  it("hashes and verifies password correctly", async () => {
    const hash = await hashPassword("Str0ng!Pass");
    expect(await verifyPassword("Str0ng!Pass", hash)).toBe(true);
    expect(await verifyPassword("WrongPass", hash)).toBe(false);
  });

  it("validates password strength", () => {
    const result = validatePasswordStrength("Str0ng!");
    expect(result.valid).toBe(true);
    expect(validatePasswordStrength("weak").valid).toBe(false);
    expect(validatePasswordStrength("nodigits!").valid).toBe(false);
    expect(validatePasswordStrength("NOLOWER1!").valid).toBe(false);
  });

  it("detects password reuse", async () => {
    const hash1 = await hashPassword("ReusedPass1!");
    const hash2 = await hashPassword("Different2!");
    const history = [hash1, hash2];
    expect(await isPasswordInHistory("ReusedPass1!", history)).toBe(true);
    expect(await isPasswordInHistory("NewPass!", history)).toBe(false);
  });

  it("manages password history size", async () => {
    const hashes: string[] = [];
    for (let i = 0; i < 7; i++) {
      const h = await hashPassword(`Pass${i}!`);
      hashes.push(h);
    }
    const updated = addPasswordToHistory(hashes[6], hashes.slice(0, 6));
    expect(updated.length).toBe(5);
  });
});
// server/service/auth/__tests__/jwt.test.ts
import { describe, it, expect } from "bun:test";
import { signAccessToken, verifyAccessToken } from "../lib/jwt";

describe("jwt utils", () => {
  it("signs and verifies access token", async () => {
    const token = await signAccessToken({
      userId: 1,
      sessionId: "abc",
      role: "user",
    });
    const payload = await verifyAccessToken(token);
    expect(payload?.userId).toBe(1);
    expect(payload?.sessionId).toBe("abc");
    expect(payload?.role).toBe("user");
  });

  it("returns null for invalid token", async () => {
    const payload = await verifyAccessToken("invalid.token.here");
    expect(payload).toBeNull();
  });
});
// server/service/auth/__tests__/rate-limit.test.ts
import { describe, it, expect } from "bun:test";
import { checkRateLimit, clearRateLimit } from "../lib/rate-limit";

describe("rate limit", () => {
  it("allows first request", () => {
    const ip = `test-ip-${Date.now()}`;
    const result = checkRateLimit(ip);
    expect(result.allowed).toBe(true);
    clearRateLimit(ip);
  });

  it("blocks after max attempts", () => {
    const ip = `test-ip-block-${Date.now()}`;
    for (let i = 0; i < 5; i++) {
      checkRateLimit(ip);
    }
    const result = checkRateLimit(ip);
    expect(result.allowed).toBe(false);
    clearRateLimit(ip);
  });
});
  • Step 1: 创建 server/service/auth/__tests__/ 目录并写入测试文件

  • Step 2: 运行测试

Run: bun test server/service/auth/__tests__/ Expected: All tests pass

  • Step 3: Commit
git add server/service/auth/__tests__/
git commit -m "test(auth): add auth service tests - password/jwt/rate-limit"

实现顺序汇总

  1. Task 1: Schema 迁移(users 字段扩展 + user_sessions 表)
  2. Task 2: 基础工具库(password.ts / jwt.ts / rate-limit.ts)
  3. Task 3: Strategy 模式实现(PasswordStrategy + OAuthStrategy 抽象类)
  4. Task 4: AuthService 主体
  5. Task 5: API 路由(register / login / logout / refresh / me)
  6. Task 6: 中间件(请求认证上下文注入)
  7. Task 7: 测试覆盖

依赖确认清单

在开始 Task 2 之前,确认以下依赖已安装(已在 root package.json):

  • bcryptjs (已在 package.json)
  • zod (已在 package.json)
  • jose 或 jsonwebtoken(需要添加,建议 jose)

Run: grep -E '"jose"|"jsonwebtoken"|"uuid"' /home/dash/coding/nuxt-app/package.json Result: 需要添加 joseuuid 依赖(未在现有 package.json 中)


Plan complete. 需要在 Task 2 之前先添加 joseuuid 依赖。

Two execution options:

1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration

2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints

Which approach?