# 登录注册系统实现计划 > **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` ```sql -- 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`); ``` ```typescript // 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** ```bash 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`** ```typescript // 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 { return bcrypt.hash(password, SALT_ROUNDS); } export async function verifyPassword( password: string, hashedPassword: string ): Promise { return bcrypt.compare(password, hashedPassword); } export function isPasswordInHistory( password: string, history: string[] ): Promise { 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: ```bash grep -r "jose\|jsonwebtoken" /home/dash/coding/nuxt-app/package.json ``` 假设用 `jose`(比 jsonwebtoken 更现代,tree-shakeable): ```typescript // 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 { return new SignJWT(payload as Record) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime(ACCESS_TOKEN_EXPIRY) .sign(JWT_SECRET); } export async function verifyAccessToken( token: string ): Promise { 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`** ```typescript // 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** ```bash 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`** ```typescript // 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; generateTokens(user: AuthUser, sessionId: string): Promise; revokeSession(sessionId: string): Promise; } ``` - [ ] **Step 2: `server/service/auth/strategies/oauth.strategy.ts`** ```typescript // 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; async generateTokens( user: AuthUser, sessionId: string ): Promise { //delegated to AuthService — strategy itself only knows the interface throw new Error("Use AuthService.generateTokens()"); } async revokeSession(sessionId: string): Promise { throw new Error("Use AuthService.revokeSession()"); } } ``` - [ ] **Step 3: `server/service/auth/strategies/password.strategy.ts`** ```typescript // 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 { 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 { const accessToken = await signAccessToken({ userId: user.id, sessionId, role: user.role, }); // refresh token 由 AuthService 生成,这里只返回 accessToken 占位 return { accessToken, refreshToken: "" }; } async revokeSession(sessionId: string): Promise { // delegated to AuthService } } ``` - [ ] **Step 4: `server/service/auth/strategies/index.ts`** ```typescript // 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 = { 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** ```bash 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` ```typescript // 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 { 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 { 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 { await dbGlobal .update(userSessions) .set({ revokedAt: new Date() }) .where(eq(userSessions.id, sessionId)); } private async createSession( userId: number, ip?: string, userAgent?: string ): Promise { 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 { 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** ```bash 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` ```typescript // 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 ?? "注册失败" } }; } }); ``` ```typescript // 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 = { ACCOUNT_LOCKED: 423, INVALID_CREDENTIALS: 401, }; setResponseStatus(event, statusMap[e.code ?? "UNKNOWN"] ?? 400); return { error: { code: e.code ?? "UNKNOWN", message: e.message ?? "登录失败" } }; } }); ``` ```typescript // 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 }; }); ``` ```typescript // 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 无效" } }; } }); ``` ```typescript // 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** ```bash 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` ```typescript // 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** ```bash 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` ```typescript // 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); }); }); ``` ```typescript // 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(); }); }); ``` ```typescript // 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** ```bash 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: 需要添加 `jose` 和 `uuid` 依赖(未在现有 package.json 中) --- **Plan complete.** 需要在 Task 2 之前先添加 `jose` 和 `uuid` 依赖。 **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?