183 lines
6.5 KiB
TypeScript
183 lines
6.5 KiB
TypeScript
import { type NextRequest, NextResponse } from "next/server";
|
||
import { getToken } from "next-auth/jwt";
|
||
import { isBlockedIP, getClientIP } from "@/lib/security/ip-blocklist";
|
||
import { checkRateLimit, rateLimiters } from "@/lib/security/rate-limiter";
|
||
import { isSuspiciousRequest, logSuspiciousRequest } from "@/lib/security/request-monitor";
|
||
|
||
export async function proxy(request: NextRequest) {
|
||
const { pathname } = request.nextUrl;
|
||
|
||
// 🛡️ 安全检查1:阻止黑名单IP
|
||
const clientIP = getClientIP(request);
|
||
if (isBlockedIP(clientIP)) {
|
||
console.warn(`[Security] Blocked request from blacklisted IP: ${clientIP}`, {
|
||
pathname,
|
||
method: request.method,
|
||
userAgent: request.headers.get("user-agent"),
|
||
});
|
||
return new NextResponse("Forbidden", { status: 403 });
|
||
}
|
||
|
||
// 🛡️ 安全检查2:检测可疑请求模式
|
||
const suspiciousCheck = isSuspiciousRequest(request);
|
||
if (suspiciousCheck.suspicious) {
|
||
logSuspiciousRequest(request, suspiciousCheck.reason || "Unknown suspicious pattern");
|
||
// 对于可疑请求,记录但不立即阻止(避免误报)
|
||
// 可以在这里添加更严格的检查
|
||
}
|
||
|
||
// 🛡️ 安全检查3:速率限制
|
||
// 对API路径应用速率限制
|
||
if (pathname === "/api/chat") {
|
||
const chatRateLimit = checkRateLimit(request, rateLimiters.chat);
|
||
if (!chatRateLimit.allowed) {
|
||
console.warn(`[Security] Chat rate limit exceeded for IP: ${clientIP}`, {
|
||
pathname,
|
||
resetTime: new Date(chatRateLimit.resetTime).toISOString(),
|
||
});
|
||
return NextResponse.json(
|
||
{ error: "Too many chat requests", retryAfter: Math.ceil((chatRateLimit.resetTime - Date.now()) / 1000) },
|
||
{ status: 429 }
|
||
);
|
||
}
|
||
} else if (pathname.startsWith("/api/canvas/") && request.method === "PUT") {
|
||
const canvasRateLimit = checkRateLimit(request, rateLimiters.canvas);
|
||
if (!canvasRateLimit.allowed) {
|
||
console.warn(`[Security] Canvas rate limit exceeded for IP: ${clientIP}`, {
|
||
pathname,
|
||
resetTime: new Date(canvasRateLimit.resetTime).toISOString(),
|
||
});
|
||
return NextResponse.json(
|
||
{ error: "Too many canvas requests", retryAfter: Math.ceil((canvasRateLimit.resetTime - Date.now()) / 1000) },
|
||
{ status: 429 }
|
||
);
|
||
}
|
||
} else if (
|
||
pathname.startsWith("/api/realtime/") ||
|
||
pathname.startsWith("/api/fal/proxy")
|
||
) {
|
||
const realtimeRateLimit = checkRateLimit(request, rateLimiters.realtime);
|
||
if (!realtimeRateLimit.allowed) {
|
||
console.warn(`[Security] Realtime rate limit exceeded for IP: ${clientIP}`, {
|
||
pathname,
|
||
resetTime: new Date(realtimeRateLimit.resetTime).toISOString(),
|
||
});
|
||
return NextResponse.json(
|
||
{ error: "Too many realtime requests", retryAfter: Math.ceil((realtimeRateLimit.resetTime - Date.now()) / 1000) },
|
||
{ status: 429 }
|
||
);
|
||
}
|
||
} else if (pathname.startsWith("/api/")) {
|
||
const rateLimit = checkRateLimit(request, rateLimiters.api);
|
||
if (!rateLimit.allowed) {
|
||
console.warn(`[Security] Rate limit exceeded for IP: ${clientIP}`, {
|
||
pathname,
|
||
resetTime: new Date(rateLimit.resetTime).toISOString(),
|
||
});
|
||
return NextResponse.json(
|
||
{ error: "Too many requests", retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) },
|
||
{ status: 429 }
|
||
);
|
||
}
|
||
}
|
||
|
||
// 🛡️ 安全检查4:认证相关API的严格速率限制
|
||
if (pathname.startsWith("/api/auth/")) {
|
||
const rateLimit = checkRateLimit(request, rateLimiters.auth);
|
||
if (!rateLimit.allowed) {
|
||
console.warn(`[Security] Auth rate limit exceeded for IP: ${clientIP}`);
|
||
return NextResponse.json(
|
||
{ error: "Too many authentication attempts", retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) },
|
||
{ status: 429 }
|
||
);
|
||
}
|
||
}
|
||
|
||
// 允许访问 ping 端点
|
||
if (pathname.startsWith("/ping")) {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 允许访问认证相关 API
|
||
if (pathname.startsWith("/api/auth")) {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 允许访问公开的网站设置 API(登录页面需要)
|
||
if (pathname === "/api/site-settings") {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 允许公开访问动态 favicon(浏览器标签图标)
|
||
if (pathname === "/api/favicon") {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 允许访问公开的认证配置(登录弹窗需要)
|
||
if (pathname === "/api/auth-settings") {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 允许匿名访问新版首页灵感数据
|
||
if (pathname === "/api/featured" || pathname.startsWith("/api/featured/")) {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 允许访问钉钉回调页面(public 文件夹中的文件)
|
||
if (pathname === "/dingtalk-callback.html") {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 检测是否为 HTTPS(通过 AUTH_URL 或请求协议判断)
|
||
const isSecure = process.env.AUTH_URL?.startsWith("https://") ||
|
||
request.nextUrl.protocol === "https:";
|
||
|
||
const token = await getToken({
|
||
req: request,
|
||
secret: process.env.AUTH_SECRET,
|
||
secureCookie: isSecure,
|
||
});
|
||
|
||
const externalBase =
|
||
process.env.AUTH_URL ??
|
||
`${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`;
|
||
|
||
// 未登录用户允许访问 /(新版首页)、/login、/register,其余需要登录
|
||
if (!token && !["/", "/login", "/register"].includes(pathname)) {
|
||
// 对于 API 路由的非 GET 请求,直接返回 401 JSON 响应(不重定向)
|
||
if (pathname.startsWith("/api/") && request.method !== "GET") {
|
||
return NextResponse.json(
|
||
{ error: "未授权,请先登录" },
|
||
{ status: 401 }
|
||
);
|
||
}
|
||
|
||
// 对于页面路由(GET 请求),重定向到登录页面
|
||
const loginUrl = new URL("/login", externalBase);
|
||
// 保存当前路径作为回调 URL
|
||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||
return NextResponse.redirect(loginUrl);
|
||
}
|
||
|
||
// 已登录用户访问 /login 或 /register 时重定向到首页
|
||
if (token && ["/login", "/register"].includes(pathname)) {
|
||
return NextResponse.redirect(new URL("/", externalBase));
|
||
}
|
||
|
||
return NextResponse.next();
|
||
}
|
||
|
||
export const config = {
|
||
matcher: [
|
||
/*
|
||
* 匹配所有请求路径,除了:
|
||
* - api/auth (NextAuth.js)
|
||
* - _next/static (静态文件)
|
||
* - _next/image (图片优化文件)
|
||
* - favicon.ico (favicon文件)
|
||
* - public 文件夹中的文件
|
||
*/
|
||
"/((?!api/auth|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||
],
|
||
};
|