- Add libs/api/admin with admin API endpoints: sync models, workspace credit, billing alert check - Add workspace_alert_config model and alert service - Add Session::no_op() for background tasks without user context - Add admin/ Next.js admin panel (AI models, billing, workspaces, audit) - Start billing alert background task every 30 minutes
173 lines
5.7 KiB
TypeScript
173 lines
5.7 KiB
TypeScript
/**
|
||
* Next.js Middleware
|
||
* 1. 前端路由保护(未登录重定向)
|
||
* 2. API 路由权限控制(RBAC + API Token)
|
||
*
|
||
* 使用 Node.js Runtime 以支持 bcrypt/ioredis/crypto
|
||
*/
|
||
export const runtime = "nodejs";
|
||
|
||
import { NextRequest, NextResponse } from "next/server";
|
||
import { createHash } from "crypto";
|
||
import { parseSessionCookie, loadAdminSession, canAccess } from "@/lib/auth";
|
||
import { query } from "@/lib/db";
|
||
|
||
const PUBLIC_PATHS = ["/login", "/api/auth/login", "/api/auth/oidc"];
|
||
const PROTECTED_PATHS = ["/dashboard", "/admin", "/platform"];
|
||
|
||
function getRequiredPermission(path: string, method: string): string | null {
|
||
if (path.startsWith("/api/users")) {
|
||
if (method === "GET") return "user:read";
|
||
if (method === "POST") return "user:create";
|
||
if (method === "PUT" || method === "PATCH") return "user:update";
|
||
if (method === "DELETE") return "user:delete";
|
||
}
|
||
if (path.startsWith("/api/roles")) {
|
||
if (method === "GET") return "role:read";
|
||
if (method === "POST") return "role:create";
|
||
if (method === "PUT" || method === "PATCH") return "role:update";
|
||
if (method === "DELETE") return "role:delete";
|
||
}
|
||
if (path.startsWith("/api/permissions")) {
|
||
if (method === "GET") return "permission:read";
|
||
if (method === "POST") return "permission:create";
|
||
if (method === "PUT" || method === "PATCH") return "permission:update";
|
||
if (method === "DELETE") return "permission:delete";
|
||
}
|
||
if (path.startsWith("/api/logs")) {
|
||
if (method === "GET") return "log:read";
|
||
}
|
||
if (path.startsWith("/api/sessions")) {
|
||
return "session:manage";
|
||
}
|
||
if (path.startsWith("/api/platform")) {
|
||
if (method === "GET") return "platform:read";
|
||
return "platform:manage";
|
||
}
|
||
if (path.startsWith("/api/admin/projects")) {
|
||
if (method === "GET") return "platform:read";
|
||
return "platform:manage";
|
||
}
|
||
if (path.startsWith("/api/api-tokens")) {
|
||
return null; // Token 管理需要 session,不允许 token 访问自己
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 从 Authorization: Bearer <token> 验证 API Token */
|
||
async function verifyApiToken(req: NextRequest): Promise<
|
||
{ valid: true; tokenId: number; permissions: string[] } |
|
||
{ valid: false }
|
||
> {
|
||
const authHeader = req.headers.get("Authorization");
|
||
if (!authHeader?.startsWith("Bearer ")) return { valid: false };
|
||
|
||
const token = authHeader.slice("Bearer ".length);
|
||
if (!token) return { valid: false };
|
||
|
||
try {
|
||
const hash = createHash("sha256").update(token).digest("hex");
|
||
const result = await query<{
|
||
id: number;
|
||
permissions: string[];
|
||
is_active: boolean;
|
||
expires_at: Date | null;
|
||
}>(
|
||
`SELECT id, permissions, is_active, expires_at FROM admin_api_token WHERE token_hash = $1`,
|
||
[hash]
|
||
);
|
||
|
||
if (!result.rows.length) return { valid: false };
|
||
const row = result.rows[0];
|
||
if (!row.is_active) return { valid: false };
|
||
if (row.expires_at && new Date(row.expires_at) < new Date()) return { valid: false };
|
||
|
||
// 更新 last_used_at(fire-and-forget)
|
||
query(`UPDATE admin_api_token SET last_used_at = NOW() WHERE id = $1`, [row.id]).catch(() => {});
|
||
|
||
return { valid: true, tokenId: row.id, permissions: row.permissions || [] };
|
||
} catch {
|
||
return { valid: false };
|
||
}
|
||
}
|
||
|
||
export async function middleware(req: NextRequest) {
|
||
const { pathname } = req.nextUrl;
|
||
const method = req.method;
|
||
|
||
// 静态资源跳过
|
||
if (
|
||
pathname.startsWith("/_next") ||
|
||
pathname.startsWith("/favicon") ||
|
||
pathname.endsWith(".ico") ||
|
||
pathname.endsWith(".png") ||
|
||
pathname.endsWith(".svg")
|
||
) {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// 公开路由放行
|
||
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
|
||
return NextResponse.next();
|
||
}
|
||
|
||
// ========== API 路由权限控制 ==========
|
||
if (pathname.startsWith("/api/")) {
|
||
const headers = new Headers(req.headers);
|
||
let authType: "session" | "token" = "session";
|
||
let permissions: string[] = [];
|
||
|
||
// 优先尝试 API Token 认证
|
||
const tokenResult = await verifyApiToken(req);
|
||
if (tokenResult.valid) {
|
||
authType = "token";
|
||
permissions = tokenResult.permissions || [];
|
||
headers.set("x-admin-auth-type", "token");
|
||
headers.set("x-admin-token-id", String(tokenResult.tokenId));
|
||
} else {
|
||
// 回退到 Session 认证
|
||
const cookieHeader = req.headers.get("cookie");
|
||
const sessionId = parseSessionCookie(cookieHeader);
|
||
|
||
if (!sessionId) {
|
||
return NextResponse.json({ error: "未登录或未提供 API Token" }, { status: 401 });
|
||
}
|
||
|
||
const session = await loadAdminSession(sessionId);
|
||
if (!session) {
|
||
return NextResponse.json({ error: "会话已过期" }, { status: 401 });
|
||
}
|
||
|
||
headers.set("x-admin-user-id", String(session.userId));
|
||
headers.set("x-admin-username", session.username);
|
||
headers.set("x-admin-permissions", session.permissions.join(","));
|
||
permissions = session.permissions;
|
||
}
|
||
|
||
const requiredPermission = getRequiredPermission(pathname, method);
|
||
if (requiredPermission && !permissions.some((p) => p === "*" || p === requiredPermission)) {
|
||
return NextResponse.json({ error: "权限不足" }, { status: 403 });
|
||
}
|
||
|
||
return NextResponse.next({ request: { headers } });
|
||
}
|
||
|
||
// ========== 前端页面保护(仅 Session) ==========
|
||
if (PROTECTED_PATHS.some((p) => pathname.startsWith(p))) {
|
||
const cookieHeader = req.headers.get("cookie");
|
||
const sessionCookie = cookieHeader
|
||
?.split(";")
|
||
.find((c) => c.trim().startsWith("admin_session="));
|
||
|
||
if (!sessionCookie) {
|
||
return NextResponse.redirect(new URL("/login", req.url));
|
||
}
|
||
}
|
||
|
||
return NextResponse.next();
|
||
}
|
||
|
||
export const config = {
|
||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||
};
|