diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..a448f5b --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..78b4665 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..b996a97 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..208effe --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..497dc6a --- /dev/null +++ b/src/app/api/auth/reset-password/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/auth/send-code/route.ts b/src/app/api/auth/send-code/route.ts new file mode 100644 index 0000000..93dfe84 --- /dev/null +++ b/src/app/api/auth/send-code/route.ts @@ -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 } + ); + } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..d45a39f --- /dev/null +++ b/src/lib/auth.ts @@ -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 { + 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 { + 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 { + 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 { + const cookieStore = await cookies(); + return cookieStore.get(AUTH_COOKIE_NAME)?.value; +} + +// 删除认证 Cookie +export async function removeAuthCookie(): Promise { + const cookieStore = await cookies(); + cookieStore.delete(AUTH_COOKIE_NAME); +} + +// 获取当前用户信息(从 Cookie 中) +export async function getCurrentUser(): Promise { + const token = await getAuthCookie(); + if (!token) return null; + + return verifyToken(token); +} + +// 检查用户是否已登录 +export async function isAuthenticated(): Promise { + const user = await getCurrentUser(); + return user !== null; +} diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..ff3d705 --- /dev/null +++ b/src/lib/email.ts @@ -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 = { + register: 'LionCode - 注册验证码', + login: 'LionCode - 登录验证码', + reset: 'LionCode - 重置密码验证码', + }; + return subjects[type]; +} + +// 获取邮件内容 +function getEmailContent(type: EmailType, code: string): string { + const typeText: Record = { + register: '注册账号', + login: '登录账号', + reset: '重置密码', + }; + + return ` + + + + + + 验证码 + + + + + + +
+ + + + + + + + + + + + + + + +
+

LionCode

+

智能 AI 对话助手

+
+

+ 您好! +

+

+ 您正在进行${typeText[type]}操作,验证码如下: +

+ + +
+ ${code} +
+ +

+ 验证码有效期为 5 分钟,请尽快完成验证。 +

+

+ 如果这不是您本人的操作,请忽略此邮件。 +

+
+

+ 此邮件由系统自动发送,请勿回复。 +

+

+ © ${new Date().getFullYear()} LionCode. All rights reserved. +

+
+
+ + + `.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(); +} diff --git a/src/lib/password.ts b/src/lib/password.ts new file mode 100644 index 0000000..824272f --- /dev/null +++ b/src/lib/password.ts @@ -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 { + return bcrypt.hash(password, SALT_ROUNDS); +} + +// 验证密码 +export async function verifyPassword( + password: string, + hashedPassword: string +): Promise { + 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 }; +}