You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
230 lines
6.6 KiB
230 lines
6.6 KiB
import { NextRequest, NextResponse } from "next/server";
|
|
import { ADMIN_ROLE } from "@/lib/constants/auth";
|
|
import {
|
|
hostPublicDefaultCustomerId,
|
|
isHostPublicApiEnabled,
|
|
} from "@/lib/constants/host-public-api";
|
|
import {
|
|
DEFAULT_HOST_CUSTOMER_ID,
|
|
isHostServiceAuthDisabled,
|
|
} from "@/lib/constants/host-service";
|
|
import { verifyToken } from "@/modules/auth/jwt";
|
|
import { AuthError } from "@/modules/auth/errors";
|
|
import {
|
|
parseServiceTokenFromEnv,
|
|
resolveEnvServiceToken,
|
|
} from "@/modules/auth/service-token-env";
|
|
import {
|
|
EMBED_DEMO_COOKIE,
|
|
verifyEmbedDemoToken,
|
|
} from "@/modules/embed-demo/auth";
|
|
|
|
const PUBLIC_API_PATHS = [
|
|
"/api/auth/login",
|
|
"/api/embed-demo/login",
|
|
];
|
|
|
|
function isHostPublicRoute(pathname: string): boolean {
|
|
return pathname.startsWith("/api/host/");
|
|
}
|
|
|
|
function isPublicApi(pathname: string): boolean {
|
|
if (pathname.startsWith("/api/embed-demo/")) {
|
|
return true;
|
|
}
|
|
return PUBLIC_API_PATHS.some(
|
|
(path) => pathname === path || pathname.startsWith(`${path}/`),
|
|
);
|
|
}
|
|
|
|
function isAdminRoute(pathname: string): boolean {
|
|
return (
|
|
pathname.startsWith("/api/alerts") ||
|
|
pathname.startsWith("/api/metrics") ||
|
|
pathname.startsWith("/api/rpa") ||
|
|
pathname.startsWith("/api/admin/queues") ||
|
|
pathname.startsWith("/api/admin/markup-configs") ||
|
|
pathname.startsWith("/api/admin/customers") ||
|
|
pathname === "/api/quotes/manual-fallback"
|
|
);
|
|
}
|
|
|
|
function isHostRoute(pathname: string): boolean {
|
|
if (pathname === "/api/quotes/manual-fallback") {
|
|
return false;
|
|
}
|
|
return (
|
|
pathname.startsWith("/api/quotes") ||
|
|
pathname.startsWith("/api/priority1") ||
|
|
pathname.startsWith("/api/flock") ||
|
|
pathname.startsWith("/api/markup-configs") ||
|
|
pathname.startsWith("/api/addresses") ||
|
|
pathname.startsWith("/api/host")
|
|
);
|
|
}
|
|
|
|
function extractBearerToken(request: NextRequest): string | null {
|
|
const header = request.headers.get("authorization");
|
|
if (!header?.startsWith("Bearer ")) {
|
|
return null;
|
|
}
|
|
const token = header.slice(7).trim();
|
|
return token || null;
|
|
}
|
|
|
|
function extractBullBoardCookieToken(request: NextRequest): string | null {
|
|
if (!request.nextUrl.pathname.startsWith("/api/admin/queues")) {
|
|
return null;
|
|
}
|
|
const raw = request.cookies.get("bull_board_auth")?.value;
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
try {
|
|
return decodeURIComponent(raw);
|
|
} catch {
|
|
return raw;
|
|
}
|
|
}
|
|
|
|
function jsonError(code: string, message: string, status: number): NextResponse {
|
|
return NextResponse.json(
|
|
{ code, message, data: null },
|
|
{ status, headers: { "Cache-Control": "no-store" } },
|
|
);
|
|
}
|
|
|
|
function passWithHeaders(
|
|
request: NextRequest,
|
|
headers: Record<string, string>,
|
|
): NextResponse {
|
|
const requestHeaders = new Headers(request.headers);
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
requestHeaders.set(key, value);
|
|
}
|
|
return NextResponse.next({ request: { headers: requestHeaders } });
|
|
}
|
|
|
|
export async function middleware(request: NextRequest) {
|
|
const { pathname } = request.nextUrl;
|
|
|
|
if (!pathname.startsWith("/api/")) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
if (isPublicApi(pathname)) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
if (isHostPublicApiEnabled() && isHostPublicRoute(pathname)) {
|
|
return passWithHeaders(request, {
|
|
"x-auth-type": "service",
|
|
"x-customer-id": hostPublicDefaultCustomerId(),
|
|
"x-permissions": "",
|
|
});
|
|
}
|
|
|
|
if (isHostServiceAuthDisabled() && isHostRoute(pathname)) {
|
|
const customerId =
|
|
request.headers.get("x-customer-id")?.trim() ||
|
|
hostPublicDefaultCustomerId() ||
|
|
DEFAULT_HOST_CUSTOMER_ID;
|
|
return passWithHeaders(request, {
|
|
"x-auth-type": "service",
|
|
"x-customer-id": customerId,
|
|
"x-permissions": "pricing:markup:write",
|
|
});
|
|
}
|
|
|
|
const token = extractBearerToken(request) ?? extractBullBoardCookieToken(request);
|
|
if (!token) {
|
|
const embedRaw = request.cookies.get(EMBED_DEMO_COOKIE)?.value;
|
|
if (embedRaw && isHostRoute(pathname)) {
|
|
try {
|
|
const embed = await verifyEmbedDemoToken(embedRaw);
|
|
return passWithHeaders(request, {
|
|
"x-auth-type": "service",
|
|
"x-customer-id": embed.sub,
|
|
"x-permissions": "pricing:markup:write",
|
|
"x-service-verified": "true",
|
|
});
|
|
} catch {
|
|
return jsonError("UNAUTHORIZED", "演示会话无效或已过期", 401);
|
|
}
|
|
}
|
|
return jsonError("UNAUTHORIZED", "缺少 Authorization 头", 401);
|
|
}
|
|
|
|
try {
|
|
if (isAdminRoute(pathname)) {
|
|
const payload = await verifyToken(token);
|
|
if (payload.role !== ADMIN_ROLE) {
|
|
return jsonError("FORBIDDEN", "需要管理员权限", 403);
|
|
}
|
|
return passWithHeaders(request, {
|
|
"x-auth-type": "admin",
|
|
"x-user-id": payload.sub,
|
|
"x-user-role": payload.role,
|
|
});
|
|
}
|
|
|
|
if (isHostRoute(pathname)) {
|
|
const customerId = request.headers.get("x-customer-id")?.trim() || null;
|
|
try {
|
|
if (customerId) {
|
|
const verified = resolveEnvServiceToken(token, customerId);
|
|
if (verified) {
|
|
return passWithHeaders(request, {
|
|
"x-auth-type": "service",
|
|
"x-customer-id": verified.customerId,
|
|
"x-permissions": verified.permissions.join(","),
|
|
"x-service-verified": "true",
|
|
});
|
|
}
|
|
} else {
|
|
const parsed = parseServiceTokenFromEnv(token);
|
|
if (parsed) {
|
|
return passWithHeaders(request, {
|
|
"x-auth-type": "service",
|
|
"x-customer-id": parsed.customerId,
|
|
"x-permissions": parsed.permissions.join(","),
|
|
"x-service-verified": "true",
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof AuthError) {
|
|
return jsonError(error.code, error.message, error.httpStatus);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
return passWithHeaders(request, {
|
|
"x-auth-type": "service",
|
|
"x-customer-id": customerId ?? "",
|
|
"x-bearer-token": token,
|
|
"x-permissions": "",
|
|
"x-service-verified": "false",
|
|
});
|
|
}
|
|
|
|
// 未分类 API 默认要求管理员 JWT
|
|
const payload = await verifyToken(token);
|
|
return passWithHeaders(request, {
|
|
"x-auth-type": "admin",
|
|
"x-user-id": payload.sub,
|
|
"x-user-role": payload.role,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof AuthError) {
|
|
return jsonError(error.code, error.message, error.httpStatus);
|
|
}
|
|
console.error("[middleware] 鉴权异常:", error);
|
|
return jsonError("INTERNAL_ERROR", "鉴权处理失败", 500);
|
|
}
|
|
}
|
|
|
|
export const config = {
|
|
matcher: ["/api/:path*"],
|
|
};
|