- Update src/auth.ts auth configuration - Update src/env.mjs environment variable validation - Update src/i18n/request.ts locale handling - Update src/proxy.ts middleware - Update src/next-auth.d.ts type declarations - Update package.json and package-lock.json dependencies
128 lines
4.8 KiB
TypeScript
128 lines
4.8 KiB
TypeScript
import { NextResponse } from "next/server"
|
||
import type { NextRequest } from "next/server"
|
||
import { getToken } from "next-auth/jwt"
|
||
|
||
import { type Permission } from "@/shared/types/permissions"
|
||
import { resolveDefaultPath } from "@/shared/lib/route-resolver"
|
||
import { hasPermissionInBitmap } from "@/shared/lib/permission-bitmap"
|
||
import {
|
||
SPECIFIC_ROUTE_PERMISSIONS,
|
||
ROUTE_PREFIX_PERMISSIONS,
|
||
DASHBOARD_ROUTE_PERMISSIONS,
|
||
API_ROUTE_PERMISSIONS,
|
||
} from "@/shared/lib/route-permissions"
|
||
|
||
// 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 permissionsBitmap: string = (token.permissionsBitmap as string) ?? ""
|
||
const roles: string[] = (token.roles as string[]) ?? []
|
||
|
||
/**
|
||
* audit-P1-7:使用位图检查权限,避免每次路由检查都解码完整权限数组。
|
||
* proxy.ts 在 edge runtime 运行,每个请求都经过这里,性能至关重要。
|
||
*/
|
||
function hasPermission(requiredPerm: Permission): boolean {
|
||
return hasPermissionInBitmap(permissionsBitmap, requiredPerm)
|
||
}
|
||
|
||
// Check API route permissions
|
||
for (const [prefix, requiredPerm] of Object.entries(API_ROUTE_PERMISSIONS)) {
|
||
if (pathname.startsWith(prefix)) {
|
||
if (!hasPermission(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 (!hasPermission(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 (!hasPermission(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_PREFIX_PERMISSIONS)) {
|
||
if (pathname.startsWith(prefix)) {
|
||
if (!hasPermission(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).*)"],
|
||
}
|