diff --git a/docs/superpowers/plans/2026-05-22-auth-implementation.md b/docs/superpowers/plans/2026-05-22-auth-implementation.md new file mode 100644 index 0000000..2e9b5e1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-auth-implementation.md @@ -0,0 +1,1191 @@ +# 登录注册系统实现计划 + +> **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? diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite index 0631b06..dee0e76 100644 Binary files a/packages/drizzle-pkg/db.sqlite and b/packages/drizzle-pkg/db.sqlite differ