import { NextResponse } from "next/server" import type { NextRequest } from "next/server" import { getToken } from "next-auth/jwt" import { Permissions } from "@/shared/types/permissions" // Route prefix → minimum required permission // Note: /admin/announcements is covered by /admin prefix (requires school:manage) // Note: /announcements is accessible to all authenticated users (no permission entry needed) // P0 修复:原先 /teacher 和 /parent 都使用 EXAM_READ,但 student/parent 也有 EXAM_READ, // 导致跨角色访问漏洞(学生可访问 /teacher/*,教师可访问 /parent/*)。 // 改为使用各角色独有的权限点,确保跨角色访问被拒绝。 const ROUTE_PERMISSIONS: Record = { "/admin": Permissions.SCHOOL_MANAGE, "/teacher": Permissions.EXAM_CREATE, "/student": Permissions.HOMEWORK_SUBMIT, "/parent": Permissions.DASHBOARD_PARENT_READ, "/management": Permissions.GRADE_MANAGE, } // 仪表盘路由的细粒度权限(覆盖 ROUTE_PERMISSIONS 的前缀匹配) // 防止拥有 EXAM_READ 的学生/家长访问 /teacher/dashboard 等 const DASHBOARD_ROUTE_PERMISSIONS: Record = { "/admin/dashboard": Permissions.DASHBOARD_ADMIN_READ, "/teacher/dashboard": Permissions.DASHBOARD_TEACHER_READ, "/student/dashboard": Permissions.DASHBOARD_STUDENT_READ, "/parent/dashboard": Permissions.DASHBOARD_PARENT_READ, } // 精确路由权限(优先级最高,覆盖 ROUTE_PERMISSIONS 的前缀匹配) // 用于将 /admin/* 下的特定页面开放给非管理员角色 // V3.1:/admin/ai-settings 对所有 AI_CHAT 用户开放(管理自己的 private provider) const SPECIFIC_ROUTE_PERMISSIONS: Record = { "/admin/ai-settings": Permissions.AI_CHAT, } // API route prefix → required permission const API_PERMISSIONS: Record = { "/api/ai/chat": Permissions.AI_CHAT, } function resolveDefaultPath(roles: string[]): string { if (roles.includes("admin")) return "/admin/dashboard" if (roles.includes("grade_head") || roles.includes("teaching_head")) return "/teacher/dashboard" if (roles.includes("teacher")) return "/teacher/dashboard" if (roles.includes("student")) return "/student/dashboard" if (roles.includes("parent")) return "/parent/dashboard" return "/dashboard" } // Next.js 16 renamed `middleware` to `proxy`. // See: https://nextjs.org/docs/messages/middleware-to-proxy export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl // Skip static assets and auth pages if ( pathname.startsWith("/_next") || pathname.startsWith("/api/auth") || pathname === "/login" || pathname === "/register" || pathname === "/favicon.ico" ) { return NextResponse.next() } const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET, }) // Not authenticated → redirect to login if (!token) { const loginUrl = new URL("/login", request.url) loginUrl.searchParams.set("callbackUrl", request.url) return NextResponse.redirect(loginUrl) } // Onboarding gate: 未完成引导的用户只能访问 /onboarding 与白名单路径 // 修复 P2-1:用 middleware 强制重定向替代客户端 Dialog const onboarded = Boolean(token.onboarded) const isOnboardingPath = pathname === "/onboarding" || pathname.startsWith("/onboarding/") const isWhitelistedApi = pathname.startsWith("/api/auth") || pathname.startsWith("/api/onboarding") if (!onboarded && !isOnboardingPath && !isWhitelistedApi) { const onboardingUrl = new URL("/onboarding", request.url) return NextResponse.redirect(onboardingUrl) } // 已完成 onboarding 的用户不应停留在 /onboarding if (onboarded && isOnboardingPath) { const roles: string[] = (token.roles as string[]) ?? [] const defaultPath = resolveDefaultPath(roles) return NextResponse.redirect(new URL(defaultPath, request.url)) } const permissions: string[] = (token.permissions as string[]) ?? [] const roles: string[] = (token.roles as string[]) ?? [] // Check API route permissions for (const [prefix, requiredPerm] of Object.entries(API_PERMISSIONS)) { if (pathname.startsWith(prefix)) { if (!permissions.includes(requiredPerm)) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }) } break } } // Check page route permissions // 优先级 1:精确路由权限(覆盖前缀匹配,用于将 /admin/* 下特定页面开放给非管理员) if (Object.prototype.hasOwnProperty.call(SPECIFIC_ROUTE_PERMISSIONS, pathname)) { const requiredPerm = SPECIFIC_ROUTE_PERMISSIONS[pathname] if (!permissions.includes(requiredPerm)) { const defaultPath = resolveDefaultPath(roles) const redirectUrl = new URL(defaultPath, request.url) redirectUrl.searchParams.set("from", pathname) redirectUrl.searchParams.set("reason", "forbidden") return NextResponse.redirect(redirectUrl) } return NextResponse.next() } // 优先级 2:仪表盘路由的细粒度权限(防止跨角色访问仪表盘) if (Object.prototype.hasOwnProperty.call(DASHBOARD_ROUTE_PERMISSIONS, pathname)) { const requiredPerm = DASHBOARD_ROUTE_PERMISSIONS[pathname] if (!permissions.includes(requiredPerm)) { const defaultPath = resolveDefaultPath(roles) const redirectUrl = new URL(defaultPath, request.url) redirectUrl.searchParams.set("from", pathname) redirectUrl.searchParams.set("reason", "forbidden") return NextResponse.redirect(redirectUrl) } return NextResponse.next() } for (const [prefix, requiredPerm] of Object.entries(ROUTE_PERMISSIONS)) { if (pathname.startsWith(prefix)) { if (!permissions.includes(requiredPerm)) { const defaultPath = resolveDefaultPath(roles) // Carry original path + reason in URL so the target page can explain // why the user was redirected (Web Interface Guidelines: URL reflects state). const redirectUrl = new URL(defaultPath, request.url) redirectUrl.searchParams.set("from", pathname) redirectUrl.searchParams.set("reason", "forbidden") return NextResponse.redirect(redirectUrl) } break } } return NextResponse.next() } export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], }