🔴 P0 安全: .env入gitignore, CSP/HSTS, Logo安全(5MB限制+SVG消毒+SHA256), middleware权限收紧(VIP不能访问admin), 发布去重, GitHub认证, 删除危险API, skill-discoverer绕过审核修复, prisma默认连接修复 🔴 P0 可靠性: deepseek/github超时重试, 脚本层DeepSeek保护, 脚本启动验证, OAuth竞态修复 🔴 P0 性能: skill-discoverer N+1优化(↓94%), 后台API分页限制, getUserStats聚合优化 🟡 P1: 分类动态加载, 星级字段统一, fetch-logos并发Bug修复, 批量发布冲突不删除, check-tools重试, 前端空指针修复(13处), logo-fetcher日志, rate-limit清理, 公开API缓存
148 lines
4.2 KiB
TypeScript
148 lines
4.2 KiB
TypeScript
import NextAuth from "next-auth";
|
|
import CredentialsProvider from "next-auth/providers/credentials";
|
|
import GithubProvider from "next-auth/providers/github";
|
|
import { prisma } from "@/lib/prisma";
|
|
import bcrypt from "bcryptjs";
|
|
|
|
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
|
|
const MAX_LOGIN_ATTEMPTS = 7;
|
|
const LOGIN_WINDOW_MS = 60_000;
|
|
|
|
function checkLoginRateLimit(email: string): boolean {
|
|
const now = Date.now();
|
|
const record = loginAttempts.get(email);
|
|
if (!record || now > record.resetAt) {
|
|
loginAttempts.set(email, { count: 1, resetAt: now + LOGIN_WINDOW_MS });
|
|
return true;
|
|
}
|
|
if (record.count >= MAX_LOGIN_ATTEMPTS) return false;
|
|
record.count++;
|
|
return true;
|
|
}
|
|
|
|
export const authOptions = {
|
|
providers: [
|
|
CredentialsProvider({
|
|
name: "credentials",
|
|
credentials: {
|
|
email: { label: "邮箱", type: "email" },
|
|
password: { label: "密码", type: "password" },
|
|
},
|
|
async authorize(credentials) {
|
|
if (!credentials?.email || !credentials?.password) return null;
|
|
|
|
if (!checkLoginRateLimit(credentials.email)) {
|
|
throw new Error("登录尝试过于频繁,请1分钟后再试");
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { email: credentials.email },
|
|
});
|
|
|
|
if (!user || !user.passwordHash) return null;
|
|
|
|
const isValid = await bcrypt.compare(
|
|
credentials.password,
|
|
user.passwordHash
|
|
);
|
|
if (!isValid) return null;
|
|
|
|
loginAttempts.delete(credentials.email);
|
|
|
|
return {
|
|
id: user.id.toString(),
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
level: user.level,
|
|
points: user.points,
|
|
};
|
|
},
|
|
}),
|
|
GithubProvider({
|
|
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
}),
|
|
],
|
|
callbacks: {
|
|
async signIn({ user, account, profile }: any) {
|
|
if (account?.provider === "github" && profile) {
|
|
const oauthId = profile.id?.toString();
|
|
const email = profile.email || user.email;
|
|
|
|
if (!oauthId) return false;
|
|
|
|
const existing = await prisma.user.findFirst({
|
|
where: {
|
|
oauthProvider: "github",
|
|
oauthId,
|
|
},
|
|
});
|
|
|
|
if (!existing) {
|
|
try {
|
|
const newUser = await prisma.user.create({
|
|
data: {
|
|
email: email || `github_${oauthId}@placeholder.com`,
|
|
name: profile.name || user.name || null,
|
|
avatarUrl: profile.avatar_url || user.image || null,
|
|
oauthProvider: "github",
|
|
oauthId,
|
|
role: "user",
|
|
},
|
|
});
|
|
user.id = newUser.id.toString();
|
|
user.role = newUser.role;
|
|
} catch (createErr: unknown) {
|
|
const ce = createErr as { code?: string };
|
|
if (ce.code === "P2002") {
|
|
const retry = await prisma.user.findFirst({
|
|
where: { oauthProvider: "github", oauthId },
|
|
});
|
|
if (retry) {
|
|
user.id = retry.id.toString();
|
|
user.role = retry.role;
|
|
return true;
|
|
}
|
|
}
|
|
throw createErr;
|
|
}
|
|
} else {
|
|
user.id = existing.id.toString();
|
|
user.role = existing.role;
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
async jwt({ token, user }: any) {
|
|
if (user) {
|
|
token.id = user.id;
|
|
token.role = user.role;
|
|
token.level = user.level;
|
|
token.points = user.points;
|
|
}
|
|
return token;
|
|
},
|
|
async session({ session, token }: any) {
|
|
if (session.user) {
|
|
session.user.id = token.id;
|
|
session.user.role = token.role;
|
|
session.user.level = token.level;
|
|
session.user.points = token.points;
|
|
}
|
|
return session;
|
|
},
|
|
},
|
|
pages: {
|
|
signIn: "/admin/login",
|
|
},
|
|
session: {
|
|
strategy: "jwt" as const,
|
|
maxAge: 30 * 24 * 60 * 60,
|
|
},
|
|
secret: process.env.NEXTAUTH_SECRET,
|
|
};
|
|
|
|
const handler = NextAuth(authOptions);
|
|
export { handler as GET, handler as POST };
|