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 的
eventHandlerwrapper 加权限标记来实现 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"
实现顺序汇总
- Task 1: Schema 迁移(users 字段扩展 + user_sessions 表)
- Task 2: 基础工具库(password.ts / jwt.ts / rate-limit.ts)
- Task 3: Strategy 模式实现(PasswordStrategy + OAuthStrategy 抽象类)
- Task 4: AuthService 主体
- Task 5: API 路由(register / login / logout / refresh / me)
- Task 6: 中间件(请求认证上下文注入)
- 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?