feat(认证): 实现用户认证API和工具库

- 实现 JWT Token 生成和验证
- 实现登录、注册、登出、重置密码 API
- 实现邮箱验证码发送功能(配置从环境变量读取)
- 实现密码加密和验证工具
- 支持获取当前用户信息
This commit is contained in:
gaoziman 2025-12-19 22:36:08 +08:00
parent 629ff540fc
commit 733c93a91c
9 changed files with 950 additions and 0 deletions

View File

@ -0,0 +1,170 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/drizzle/db';
import { users, verificationCodes } from '@/drizzle/schema';
import { eq, and, gt } from 'drizzle-orm';
import { verifyPassword, validateEmail } from '@/lib/password';
import { generateToken, setAuthCookie } from '@/lib/auth';
// 请求体验证
const loginSchema = z.object({
email: z.string().email('邮箱格式不正确'),
loginType: z.enum(['password', 'code']),
password: z.string().optional(),
code: z.string().optional(),
}).refine((data) => {
if (data.loginType === 'password') {
return !!data.password;
}
if (data.loginType === 'code') {
return !!data.code && data.code.length === 6;
}
return false;
}, {
message: '请提供密码或验证码',
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// 验证请求体
const result = loginSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error.issues[0].message },
{ status: 400 }
);
}
const { email, loginType, password, code } = result.data;
// 验证邮箱格式
if (!validateEmail(email)) {
return NextResponse.json(
{ success: false, error: '邮箱格式不正确' },
{ status: 400 }
);
}
// 查找用户
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user) {
return NextResponse.json(
{ success: false, error: '该邮箱未注册' },
{ status: 400 }
);
}
// 检查用户状态
if (user.status === 'banned') {
return NextResponse.json(
{ success: false, error: '账号已被禁用' },
{ status: 403 }
);
}
if (user.status === 'inactive') {
return NextResponse.json(
{ success: false, error: '账号未激活' },
{ status: 403 }
);
}
// 密码登录
if (loginType === 'password') {
if (!password) {
return NextResponse.json(
{ success: false, error: '请输入密码' },
{ status: 400 }
);
}
const isValidPassword = await verifyPassword(password, user.password);
if (!isValidPassword) {
return NextResponse.json(
{ success: false, error: '密码错误' },
{ status: 400 }
);
}
}
// 验证码登录
if (loginType === 'code') {
if (!code) {
return NextResponse.json(
{ success: false, error: '请输入验证码' },
{ status: 400 }
);
}
const now = new Date();
const validCode = await db
.select()
.from(verificationCodes)
.where(
and(
eq(verificationCodes.email, email),
eq(verificationCodes.code, code),
eq(verificationCodes.type, 'login'),
eq(verificationCodes.used, false),
gt(verificationCodes.expiresAt, now)
)
)
.limit(1);
if (validCode.length === 0) {
return NextResponse.json(
{ success: false, error: '验证码无效或已过期' },
{ status: 400 }
);
}
// 标记验证码为已使用
await db
.update(verificationCodes)
.set({ used: true })
.where(eq(verificationCodes.id, validCode[0].id));
}
// 更新最后登录时间
await db
.update(users)
.set({ lastLoginAt: new Date(), updatedAt: new Date() })
.where(eq(users.userId, user.userId));
// 生成 JWT Token
const token = await generateToken({
userId: user.userId,
email: user.email,
nickname: user.nickname,
plan: user.plan || 'free',
});
// 设置认证 Cookie
await setAuthCookie(token);
return NextResponse.json({
success: true,
user: {
id: user.userId,
email: user.email,
nickname: user.nickname,
plan: user.plan,
avatar: user.avatar,
},
message: '登录成功',
});
} catch (error) {
console.error('登录失败:', error);
return NextResponse.json(
{ success: false, error: '服务器错误' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { removeAuthCookie } from '@/lib/auth';
export async function POST() {
try {
// 删除认证 Cookie
await removeAuthCookie();
return NextResponse.json({
success: true,
message: '登出成功',
});
} catch (error) {
console.error('登出失败:', error);
return NextResponse.json(
{ success: false, error: '服务器错误' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,64 @@
import { NextResponse } from 'next/server';
import { db } from '@/drizzle/db';
import { users } from '@/drizzle/schema';
import { eq } from 'drizzle-orm';
import { getCurrentUser } from '@/lib/auth';
export async function GET() {
try {
// 获取当前用户信息
const jwtPayload = await getCurrentUser();
if (!jwtPayload) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
// 查询用户详细信息
const [user] = await db
.select({
userId: users.userId,
email: users.email,
nickname: users.nickname,
avatar: users.avatar,
plan: users.plan,
status: users.status,
emailVerified: users.emailVerified,
lastLoginAt: users.lastLoginAt,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.userId, jwtPayload.userId))
.limit(1);
if (!user) {
return NextResponse.json(
{ success: false, error: '用户不存在' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
user: {
id: user.userId,
email: user.email,
nickname: user.nickname,
avatar: user.avatar,
plan: user.plan,
status: user.status,
emailVerified: user.emailVerified,
lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt,
},
});
} catch (error) {
console.error('获取用户信息失败:', error);
return NextResponse.json(
{ success: false, error: '服务器错误' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,158 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { nanoid } from 'nanoid';
import { db } from '@/drizzle/db';
import { users, verificationCodes, userSettings } from '@/drizzle/schema';
import { eq, and, gt } from 'drizzle-orm';
import { hashPassword, validatePassword, validateEmail, validateNickname } from '@/lib/password';
import { generateToken, setAuthCookie } from '@/lib/auth';
// 请求体验证
const registerSchema = z.object({
email: z.string().email('邮箱格式不正确'),
password: z.string().min(8, '密码长度不能少于8位'),
confirmPassword: z.string(),
nickname: z.string().min(2, '昵称长度不能少于2位').max(20, '昵称长度不能超过20位'),
code: z.string().length(6, '验证码必须是6位'),
}).refine((data) => data.password === data.confirmPassword, {
message: '两次输入的密码不一致',
path: ['confirmPassword'],
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// 验证请求体
const result = registerSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error.issues[0].message },
{ status: 400 }
);
}
const { email, password, nickname, code } = result.data;
// 验证邮箱格式
if (!validateEmail(email)) {
return NextResponse.json(
{ success: false, error: '邮箱格式不正确' },
{ status: 400 }
);
}
// 验证密码格式
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return NextResponse.json(
{ success: false, error: passwordValidation.errors[0] },
{ status: 400 }
);
}
// 验证昵称格式
const nicknameValidation = validateNickname(nickname);
if (!nicknameValidation.valid) {
return NextResponse.json(
{ success: false, error: nicknameValidation.error },
{ status: 400 }
);
}
// 检查邮箱是否已注册
const existingUser = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (existingUser.length > 0) {
return NextResponse.json(
{ success: false, error: '该邮箱已被注册' },
{ status: 400 }
);
}
// 验证验证码
const now = new Date();
const validCode = await db
.select()
.from(verificationCodes)
.where(
and(
eq(verificationCodes.email, email),
eq(verificationCodes.code, code),
eq(verificationCodes.type, 'register'),
eq(verificationCodes.used, false),
gt(verificationCodes.expiresAt, now)
)
)
.limit(1);
if (validCode.length === 0) {
return NextResponse.json(
{ success: false, error: '验证码无效或已过期' },
{ status: 400 }
);
}
// 标记验证码为已使用
await db
.update(verificationCodes)
.set({ used: true })
.where(eq(verificationCodes.id, validCode[0].id));
// 加密密码
const hashedPassword = await hashPassword(password);
// 生成用户ID
const userId = nanoid();
// 创建用户
const [newUser] = await db
.insert(users)
.values({
userId,
email,
password: hashedPassword,
nickname,
emailVerified: true, // 已通过邮箱验证
lastLoginAt: now,
})
.returning();
// 创建默认用户设置
await db.insert(userSettings).values({
userId,
});
// 生成 JWT Token
const token = await generateToken({
userId: newUser.userId,
email: newUser.email,
nickname: newUser.nickname,
plan: newUser.plan || 'free',
});
// 设置认证 Cookie
await setAuthCookie(token);
return NextResponse.json({
success: true,
user: {
id: newUser.userId,
email: newUser.email,
nickname: newUser.nickname,
plan: newUser.plan,
},
message: '注册成功',
});
} catch (error) {
console.error('注册失败:', error);
return NextResponse.json(
{ success: false, error: '服务器错误' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/drizzle/db';
import { users, verificationCodes } from '@/drizzle/schema';
import { eq, and, gt } from 'drizzle-orm';
import { hashPassword, validatePassword, validateEmail } from '@/lib/password';
// 请求体验证
const resetPasswordSchema = z.object({
email: z.string().email('邮箱格式不正确'),
code: z.string().length(6, '验证码必须是6位'),
newPassword: z.string().min(8, '密码长度不能少于8位'),
confirmPassword: z.string(),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: '两次输入的密码不一致',
path: ['confirmPassword'],
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// 验证请求体
const result = resetPasswordSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error.issues[0].message },
{ status: 400 }
);
}
const { email, code, newPassword } = result.data;
// 验证邮箱格式
if (!validateEmail(email)) {
return NextResponse.json(
{ success: false, error: '邮箱格式不正确' },
{ status: 400 }
);
}
// 验证密码格式
const passwordValidation = validatePassword(newPassword);
if (!passwordValidation.valid) {
return NextResponse.json(
{ success: false, error: passwordValidation.errors[0] },
{ status: 400 }
);
}
// 查找用户
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user) {
return NextResponse.json(
{ success: false, error: '该邮箱未注册' },
{ status: 400 }
);
}
// 验证验证码
const now = new Date();
const validCode = await db
.select()
.from(verificationCodes)
.where(
and(
eq(verificationCodes.email, email),
eq(verificationCodes.code, code),
eq(verificationCodes.type, 'reset'),
eq(verificationCodes.used, false),
gt(verificationCodes.expiresAt, now)
)
)
.limit(1);
if (validCode.length === 0) {
return NextResponse.json(
{ success: false, error: '验证码无效或已过期' },
{ status: 400 }
);
}
// 标记验证码为已使用
await db
.update(verificationCodes)
.set({ used: true })
.where(eq(verificationCodes.id, validCode[0].id));
// 加密新密码
const hashedPassword = await hashPassword(newPassword);
// 更新密码
await db
.update(users)
.set({ password: hashedPassword, updatedAt: now })
.where(eq(users.userId, user.userId));
return NextResponse.json({
success: true,
message: '密码重置成功',
});
} catch (error) {
console.error('重置密码失败:', error);
return NextResponse.json(
{ success: false, error: '服务器错误' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,127 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/drizzle/db';
import { users, verificationCodes } from '@/drizzle/schema';
import { eq, and, gt } from 'drizzle-orm';
import { sendVerificationEmail, generateVerificationCode } from '@/lib/email';
import { validateEmail } from '@/lib/password';
// 请求体验证
const sendCodeSchema = z.object({
email: z.string().email('邮箱格式不正确'),
type: z.enum(['register', 'login', 'reset']),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// 验证请求体
const result = sendCodeSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error.issues[0].message },
{ status: 400 }
);
}
const { email, type } = result.data;
// 验证邮箱格式
if (!validateEmail(email)) {
return NextResponse.json(
{ success: false, error: '邮箱格式不正确' },
{ status: 400 }
);
}
// 检查邮箱是否已注册(仅注册时检查)
if (type === 'register') {
const existingUser = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (existingUser.length > 0) {
return NextResponse.json(
{ success: false, error: '该邮箱已被注册' },
{ status: 400 }
);
}
}
// 检查邮箱是否存在(登录和重置密码时检查)
if (type === 'login' || type === 'reset') {
const existingUser = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (existingUser.length === 0) {
return NextResponse.json(
{ success: false, error: '该邮箱未注册' },
{ status: 400 }
);
}
}
// 检查是否频繁发送1分钟内只能发送1次
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
const recentCode = await db
.select()
.from(verificationCodes)
.where(
and(
eq(verificationCodes.email, email),
eq(verificationCodes.type, type),
gt(verificationCodes.createdAt, oneMinuteAgo)
)
)
.limit(1);
if (recentCode.length > 0) {
return NextResponse.json(
{ success: false, error: '发送太频繁,请稍后再试' },
{ status: 429 }
);
}
// 生成验证码
const code = generateVerificationCode();
// 计算过期时间5分钟后
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
// 保存验证码到数据库
await db.insert(verificationCodes).values({
email,
code,
type,
expiresAt,
});
// 发送邮件
const emailResult = await sendVerificationEmail(email, code, type);
if (!emailResult.success) {
return NextResponse.json(
{ success: false, error: emailResult.error || '发送邮件失败' },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
message: '验证码已发送,请查收邮件',
expiresIn: 300, // 5分钟
});
} catch (error) {
console.error('发送验证码失败:', error);
return NextResponse.json(
{ success: false, error: '服务器错误' },
{ status: 500 }
);
}
}

81
src/lib/auth.ts Normal file
View File

@ -0,0 +1,81 @@
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
// JWT 密钥(生产环境应使用环境变量)
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || 'lioncode-jwt-secret-key-2024'
);
// Token 有效期7 天
const TOKEN_EXPIRY = '7d';
// Cookie 名称
const AUTH_COOKIE_NAME = 'lioncode_auth_token';
// JWT Payload 类型
export interface JWTPayload {
userId: string;
email: string;
nickname: string;
plan: string;
}
// 生成 JWT Token
export async function generateToken(payload: JWTPayload): Promise<string> {
const token = await new SignJWT({ ...payload })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(TOKEN_EXPIRY)
.sign(JWT_SECRET);
return token;
}
// 验证 JWT Token
export async function verifyToken(token: string): Promise<JWTPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as unknown as JWTPayload;
} catch (error) {
console.error('Token 验证失败:', error);
return null;
}
}
// 设置认证 Cookie
export async function setAuthCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(AUTH_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60, // 7 天
path: '/',
});
}
// 获取认证 Cookie
export async function getAuthCookie(): Promise<string | undefined> {
const cookieStore = await cookies();
return cookieStore.get(AUTH_COOKIE_NAME)?.value;
}
// 删除认证 Cookie
export async function removeAuthCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(AUTH_COOKIE_NAME);
}
// 获取当前用户信息(从 Cookie 中)
export async function getCurrentUser(): Promise<JWTPayload | null> {
const token = await getAuthCookie();
if (!token) return null;
return verifyToken(token);
}
// 检查用户是否已登录
export async function isAuthenticated(): Promise<boolean> {
const user = await getCurrentUser();
return user !== null;
}

136
src/lib/email.ts Normal file
View File

@ -0,0 +1,136 @@
import nodemailer from 'nodemailer';
// 邮箱配置(从环境变量读取)
const SMTP_HOST = process.env.SMTP_HOST || 'smtp.qq.com';
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '465');
const SMTP_USER = process.env.SMTP_USER || '';
const SMTP_PASS = process.env.SMTP_PASS || '';
// SMTP 配置
const transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: true,
auth: {
user: SMTP_USER,
pass: SMTP_PASS,
},
});
// 邮件模板类型
type EmailType = 'register' | 'login' | 'reset';
// 获取邮件主题
function getSubject(type: EmailType): string {
const subjects: Record<EmailType, string> = {
register: 'LionCode - 注册验证码',
login: 'LionCode - 登录验证码',
reset: 'LionCode - 重置密码验证码',
};
return subjects[type];
}
// 获取邮件内容
function getEmailContent(type: EmailType, code: string): string {
const typeText: Record<EmailType, string> = {
register: '注册账号',
login: '登录账号',
reset: '重置密码',
};
return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" style="width: 100%; border-collapse: collapse;">
<tr>
<td align="center" style="padding: 40px 0;">
<table role="presentation" style="width: 100%; max-width: 600px; border-collapse: collapse; background-color: #ffffff; border-radius: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);">
<!-- Header -->
<tr>
<td style="padding: 40px 40px 20px; text-align: center;">
<h1 style="margin: 0; font-size: 28px; font-weight: 700; color: #1a1a1a;">LionCode</h1>
<p style="margin: 8px 0 0; font-size: 14px; color: #666;"> AI </p>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 20px 40px;">
<p style="margin: 0 0 20px; font-size: 16px; color: #333; line-height: 1.6;">
</p>
<p style="margin: 0 0 20px; font-size: 16px; color: #333; line-height: 1.6;">
<strong>${typeText[type]}</strong>
</p>
<!-- Code Box -->
<div style="background: linear-gradient(135deg, #E06B3E 0%, #D4643E 100%); border-radius: 8px; padding: 24px; text-align: center; margin: 24px 0;">
<span style="font-size: 36px; font-weight: 700; letter-spacing: 8px; color: #ffffff;">${code}</span>
</div>
<p style="margin: 20px 0 0; font-size: 14px; color: #666; line-height: 1.6;">
<strong>5 </strong>
</p>
<p style="margin: 12px 0 0; font-size: 14px; color: #999; line-height: 1.6;">
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 20px 40px 40px; border-top: 1px solid #eee;">
<p style="margin: 0; font-size: 12px; color: #999; text-align: center;">
</p>
<p style="margin: 8px 0 0; font-size: 12px; color: #999; text-align: center;">
© ${new Date().getFullYear()} LionCode. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`.trim();
}
// 发送验证码邮件
export async function sendVerificationEmail(
to: string,
code: string,
type: EmailType
): Promise<{ success: boolean; error?: string }> {
try {
await transporter.sendMail({
from: {
name: 'LionCode',
address: SMTP_USER,
},
to,
subject: getSubject(type),
html: getEmailContent(type, code),
});
return { success: true };
} catch (error) {
console.error('发送邮件失败:', error);
return {
success: false,
error: error instanceof Error ? error.message : '发送邮件失败',
};
}
}
// 生成6位数字验证码
export function generateVerificationCode(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}

80
src/lib/password.ts Normal file
View File

@ -0,0 +1,80 @@
import bcrypt from 'bcryptjs';
// 加密强度(越高越安全,但速度越慢)
const SALT_ROUNDS = 12;
// 密码规则
export const PASSWORD_RULES = {
minLength: 8,
maxLength: 50,
requireLetter: true,
requireNumber: true,
};
// 加密密码
export async 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 validatePassword(password: string): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (password.length < PASSWORD_RULES.minLength) {
errors.push(`密码长度不能少于 ${PASSWORD_RULES.minLength}`);
}
if (password.length > PASSWORD_RULES.maxLength) {
errors.push(`密码长度不能超过 ${PASSWORD_RULES.maxLength}`);
}
if (PASSWORD_RULES.requireLetter && !/[a-zA-Z]/.test(password)) {
errors.push('密码必须包含字母');
}
if (PASSWORD_RULES.requireNumber && !/\d/.test(password)) {
errors.push('密码必须包含数字');
}
return {
valid: errors.length === 0,
errors,
};
}
// 验证邮箱格式
export function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// 验证昵称格式
export function validateNickname(nickname: string): {
valid: boolean;
error?: string;
} {
if (!nickname || nickname.trim().length === 0) {
return { valid: false, error: '昵称不能为空' };
}
if (nickname.length < 2) {
return { valid: false, error: '昵称长度不能少于 2 位' };
}
if (nickname.length > 20) {
return { valid: false, error: '昵称长度不能超过 20 位' };
}
return { valid: true };
}