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

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*"],
};