feat(认证): 实现用户认证API和工具库
- 实现 JWT Token 生成和验证 - 实现登录、注册、登出、重置密码 API - 实现邮箱验证码发送功能(配置从环境变量读取) - 实现密码加密和验证工具 - 支持获取当前用户信息
This commit is contained in:
parent
629ff540fc
commit
733c93a91c
170
src/app/api/auth/login/route.ts
Normal file
170
src/app/api/auth/login/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/app/api/auth/logout/route.ts
Normal file
20
src/app/api/auth/logout/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/app/api/auth/me/route.ts
Normal file
64
src/app/api/auth/me/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
158
src/app/api/auth/register/route.ts
Normal file
158
src/app/api/auth/register/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/app/api/auth/reset-password/route.ts
Normal file
114
src/app/api/auth/reset-password/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/app/api/auth/send-code/route.ts
Normal file
127
src/app/api/auth/send-code/route.ts
Normal 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
81
src/lib/auth.ts
Normal 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
136
src/lib/email.ts
Normal 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
80
src/lib/password.ts
Normal 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 };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user