From 7dbeaac5f86cc00e77c12d3b305e3da068a62d64 Mon Sep 17 00:00:00 2001 From: wren <“porlong@qq.com”> Date: Wed, 29 Apr 2026 15:58:59 +0800 Subject: [PATCH] fix: bootstrap session after password login --- app/components/auth/ClientAuthGuard.tsx | 22 +- app/root.tsx | 967 ++++++++++++------------ app/routes/login.tsx | 33 +- 3 files changed, 502 insertions(+), 520 deletions(-) diff --git a/app/components/auth/ClientAuthGuard.tsx b/app/components/auth/ClientAuthGuard.tsx index b5f30cf..6000380 100644 --- a/app/components/auth/ClientAuthGuard.tsx +++ b/app/components/auth/ClientAuthGuard.tsx @@ -11,9 +11,11 @@ import { isAuthenticated } from '~/utils/auth-storage'; interface ClientAuthGuardProps { isPublicPath: boolean; + frontendJWT?: string; + userInfo?: Record; } -export function ClientAuthGuard({ isPublicPath }: ClientAuthGuardProps) { +export function ClientAuthGuard({ isPublicPath, frontendJWT, userInfo }: ClientAuthGuardProps) { const navigate = useNavigate(); const location = useLocation(); @@ -29,15 +31,17 @@ export function ClientAuthGuard({ isPublicPath }: ClientAuthGuardProps) { return; } - // 检查客户端是否已认证(localStorage 中有 token) + // 优先用服务端 session 回传的数据回填 localStorage,避免刚登录时客户端误判未登录 const token = localStorage.getItem('access_token'); - const authenticated = isAuthenticated(); + if (!token && frontendJWT) { + localStorage.setItem('access_token', frontendJWT); + if (userInfo) { + localStorage.setItem('user_info', JSON.stringify(userInfo)); + } + console.log('✅ [Auth Guard] 已根据服务端 session 回填本地认证数据'); + } - // console.log('🔍 [Auth Guard] 认证检查', { - // token: token ? `${token.substring(0, 20)}...` : null, - // authenticated, - // pathname: location.pathname - // }); + const authenticated = isAuthenticated() || !!frontendJWT; if (!authenticated) { console.log('🔒 [Auth Guard] 未认证,重定向到登录页'); @@ -50,7 +54,7 @@ export function ClientAuthGuard({ isPublicPath }: ClientAuthGuardProps) { } else { // console.log('✅ [Auth Guard] 已认证,允许访问'); } - }, [isPublicPath, navigate, location.pathname]); + }, [isPublicPath, navigate, location.pathname, frontendJWT, userInfo]); // 这个组件不渲染任何内容 return null; diff --git a/app/root.tsx b/app/root.tsx index 11da3c1..7cf9afe 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,144 +1,144 @@ -// import React from 'react'; -import { - Links, - // LiveReload, // 不再需要,使用Vite时会与内置HMR冲突 - Meta, - Outlet, - Scripts, - ScrollRestoration, - isRouteErrorResponse, - useRouteError, - type MetaFunction, - useLoaderData -} from "@remix-run/react"; -import { - LoaderFunctionArgs, - redirect, - ActionFunctionArgs -} from "@remix-run/node"; -import { Layout } from "~/components/layout/Layout"; -import { ErrorBoundary as AppErrorBoundary } from "~/components/error/ErrorBoundary"; -import { MessageModalProvider } from "~/components/ui/MessageModal"; -import { ToastProvider } from "~/components/ui/Toast"; -import { ClientAuthGuard } from "~/components/auth/ClientAuthGuard"; -import { getAccessToken } from "./utils/auth-storage"; -import "remixicon/fonts/remixicon.css"; -// 导入样式 -import styles from "~/styles/main.css?url"; -import messageModalStyles from "~/styles/components/message-modal.css?url"; -import toastStyles from "~/styles/components/toast.css?url"; -import sourceHanSansStyles from "~/styles/fonts/source-han-sans.css?url"; -import LoadingBarContainer from "~/components/ui/LoadingBar"; -import RouteChangeLoader from "~/components/ui/RouteChangeLoader"; -// import { useState, useEffect } from "react"; - - -// 导入认证相关的服务器端功能(仅在服务器端使用) -import { - // getUserSession, - logout, - type UserRole -} from "~/api/login/auth.server"; - -// 导入交叉评查专属模式配置 -import { - CROSS_CHECKING_ONLY_MODE, - CROSS_CHECKING_ONLY_PORT, - getCurrentPort -} from "~/config/api-config"; - -// 导入移动端检测工具 +// import React from 'react'; +import { + Links, + // LiveReload, // 不再需要,使用Vite时会与内置HMR冲突 + Meta, + Outlet, + Scripts, + ScrollRestoration, + isRouteErrorResponse, + useRouteError, + type MetaFunction, + useLoaderData +} from "@remix-run/react"; +import { + LoaderFunctionArgs, + redirect, + ActionFunctionArgs +} from "@remix-run/node"; +import { Layout } from "~/components/layout/Layout"; +import { ErrorBoundary as AppErrorBoundary } from "~/components/error/ErrorBoundary"; +import { MessageModalProvider } from "~/components/ui/MessageModal"; +import { ToastProvider } from "~/components/ui/Toast"; +import { ClientAuthGuard } from "~/components/auth/ClientAuthGuard"; +import { getAccessToken } from "./utils/auth-storage"; +import "remixicon/fonts/remixicon.css"; +// 导入样式 +import styles from "~/styles/main.css?url"; +import messageModalStyles from "~/styles/components/message-modal.css?url"; +import toastStyles from "~/styles/components/toast.css?url"; +import sourceHanSansStyles from "~/styles/fonts/source-han-sans.css?url"; +import LoadingBarContainer from "~/components/ui/LoadingBar"; +import RouteChangeLoader from "~/components/ui/RouteChangeLoader"; +// import { useState, useEffect } from "react"; + + +// 导入认证相关的服务器端功能(仅在服务器端使用) +import { + // getUserSession, + logout, + type UserRole +} from "~/api/login/auth.server"; + +// 导入交叉评查专属模式配置 +import { + CROSS_CHECKING_ONLY_MODE, + CROSS_CHECKING_ONLY_PORT, + getCurrentPort +} from "~/config/api-config"; + +// 导入移动端检测工具 import { isMobileDevice, isMobileAllowedPath, MOBILE_CHAT_PATH } from "~/utils/mobile-detect.server"; import { normalizeRoutePathForPermission } from "~/utils/route-alias"; - -// 定义需要高级权限的路径 -// export const developerOnlyPaths = [ -// '/settings', -// '/config-lists', -// '/document-types', -// '/prompts', -// ]; - -// 导出类型供客户端使用 -export type { UserRole }; - -// 辅助函数:从 MenuItem 数组中提取所有路径(包括子路由) -interface MenuItem { - path: string; - children?: MenuItem[]; -} - -function extractAllPaths(menuItems: MenuItem[]): string[] { - const paths: string[] = []; - - function traverse(items: MenuItem[]) { - for (const item of items) { - paths.push(item.path); - if (item.children && item.children.length > 0) { - traverse(item.children); - } - } - } - - traverse(menuItems); - return paths; -} - -/** - * 检查路径段是否看起来像动态ID(允许访问) - * - * 动态ID的特征: - * - 纯数字:123、456 - * - UUID格式:550e8400-e29b-41d4-a716-446655440000 - * - 包含数字+特殊字符的混合ID:abc-123、doc_456 - * - * 固定路由的特征(需要在菜单中明确配置): - * - 纯英文单词:upload、edit、create、list - * - 多单词路由:create-task、edit-profile - * - * @param segment 路径段(例如:'123' 或 'upload') - * @returns true 表示是动态ID,允许访问;false 表示是固定路由,需要权限检查 - */ -function isDynamicIdSegment(segment: string): boolean { - // 1. 纯数字(最常见的动态ID) - if (/^\d+$/.test(segment)) { - return true; - } - - // 2. UUID格式(包含连字符的十六进制字符串) - if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) { - return true; - } - - // 3. 包含数字的混合ID(如:doc-123、user_456、item123) - // 但排除纯英文单词+连字符的组合(如:create-task、edit-profile) - if (/\d/.test(segment) && !/^[a-z]+(-[a-z]+)*$/i.test(segment)) { - return true; - } - - // 其他情况视为固定路由(需要在菜单中明确配置) - return false; -} - -/** - * 辅助函数:检查路径是否在允许列表中 - * - * 匹配规则: - * 1. 精确匹配:pathname 完全在 allowedPaths 中 - * 2. 动态路由匹配:只允许看起来像动态ID的子路径 - * - 允许:/documents/123(纯数字) - * - 允许:/documents/550e8400-e29b-41d4-a716-446655440000(UUID) - * - 拒绝:/documents/upload(固定子路由,需要在菜单中明确配置) - * 3. 根路径特殊处理:'/' 始终允许 - * - * @param pathname 当前访问的路径 - * @param allowedPaths 允许访问的路径列表(从菜单配置中提取) - * @returns true 表示允许访问,false 表示拒绝访问 - */ + +// 定义需要高级权限的路径 +// export const developerOnlyPaths = [ +// '/settings', +// '/config-lists', +// '/document-types', +// '/prompts', +// ]; + +// 导出类型供客户端使用 +export type { UserRole }; + +// 辅助函数:从 MenuItem 数组中提取所有路径(包括子路由) +interface MenuItem { + path: string; + children?: MenuItem[]; +} + +function extractAllPaths(menuItems: MenuItem[]): string[] { + const paths: string[] = []; + + function traverse(items: MenuItem[]) { + for (const item of items) { + paths.push(item.path); + if (item.children && item.children.length > 0) { + traverse(item.children); + } + } + } + + traverse(menuItems); + return paths; +} + +/** + * 检查路径段是否看起来像动态ID(允许访问) + * + * 动态ID的特征: + * - 纯数字:123、456 + * - UUID格式:550e8400-e29b-41d4-a716-446655440000 + * - 包含数字+特殊字符的混合ID:abc-123、doc_456 + * + * 固定路由的特征(需要在菜单中明确配置): + * - 纯英文单词:upload、edit、create、list + * - 多单词路由:create-task、edit-profile + * + * @param segment 路径段(例如:'123' 或 'upload') + * @returns true 表示是动态ID,允许访问;false 表示是固定路由,需要权限检查 + */ +function isDynamicIdSegment(segment: string): boolean { + // 1. 纯数字(最常见的动态ID) + if (/^\d+$/.test(segment)) { + return true; + } + + // 2. UUID格式(包含连字符的十六进制字符串) + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) { + return true; + } + + // 3. 包含数字的混合ID(如:doc-123、user_456、item123) + // 但排除纯英文单词+连字符的组合(如:create-task、edit-profile) + if (/\d/.test(segment) && !/^[a-z]+(-[a-z]+)*$/i.test(segment)) { + return true; + } + + // 其他情况视为固定路由(需要在菜单中明确配置) + return false; +} + +/** + * 辅助函数:检查路径是否在允许列表中 + * + * 匹配规则: + * 1. 精确匹配:pathname 完全在 allowedPaths 中 + * 2. 动态路由匹配:只允许看起来像动态ID的子路径 + * - 允许:/documents/123(纯数字) + * - 允许:/documents/550e8400-e29b-41d4-a716-446655440000(UUID) + * - 拒绝:/documents/upload(固定子路由,需要在菜单中明确配置) + * 3. 根路径特殊处理:'/' 始终允许 + * + * @param pathname 当前访问的路径 + * @param allowedPaths 允许访问的路径列表(从菜单配置中提取) + * @returns true 表示允许访问,false 表示拒绝访问 + */ function isPathAllowed(pathname: string, allowedPaths: string[]): boolean { const checkPath = normalizeRoutePathForPermission(pathname); @@ -146,351 +146,354 @@ function isPathAllowed(pathname: string, allowedPaths: string[]): boolean { if (allowedPaths.includes(checkPath)) { return true; } - - // 2. 动态路由匹配(只允许看起来像ID的子路径) - for (const allowedPath of allowedPaths) { - if (checkPath.startsWith(allowedPath + '/')) { - // 提取子路径部分(例如:'/documents/123' -> '123') - const subPath = checkPath.substring(allowedPath.length + 1); - - // 支持多级嵌套路由(例如:/documents/123/edit) - const segments = subPath.split('/'); - - // 检查第一个路径段是否是动态ID - // 如果是动态ID,允许访问(后续路径段不再检查,因为通常是操作动作) - // 如果不是动态ID,则必须在 allowedPaths 中明确配置 - const firstSegment = segments[0]; - - if (isDynamicIdSegment(firstSegment)) { - return true; // 动态ID路由,允许访问 - } - // 如果不是动态ID,继续检查是否有精确匹配(已在第1步检查过) - } - } - - // 3. 根路径特殊处理(仅根路径 '/' 对所有已登录用户开放) - if (checkPath === '/') { - return true; // 根路径重定向到首页,始终允许 - } - - // /home 路由需要检查路由权限,不再特殊处理 - // 如果用户的 routes 数据中没有 /home,则返回 403 - - return false; -} - -// 添加action处理登录/登出请求 -export async function action({ request }: ActionFunctionArgs) { - const formData = await request.formData(); - const intent = formData.get("intent"); - - if (intent === "logout") { - return logout(request); - } - - return null; -} - -// 添加loader函数进行全局认证检查并传递环境变量给客户端 -export async function loader({ request }: LoaderFunctionArgs) { - // 获取当前路径 - const url = new URL(request.url); - const pathname = url.pathname; - - // 排除不需要登录验证的路径(公共路径) - const publicPaths = ['/login', '/favicon.ico', '/callback']; - const isPublicPath = publicPaths.some(path => pathname.startsWith(path)); - - // 获取用户角色和 JWT(从 Cookie Session) - let userRole: UserRole = 'common'; // 默认为普通用户 - let userArea: string = ''; - let frontendJWT: string | null = null; - let allowedPaths: string[] = []; // 用户允许访问的路由列表 - let permissionMap: Record = {}; // ✅ 权限映射表 - - if (!isPublicPath) { - try { - const { getUserSession } = await import("~/api/login/auth.server"); - const session = await getUserSession(request); - userRole = session.userRole; - userArea = session.userInfo?.area || ''; - frontendJWT = session.frontendJWT || null; - - // 🔑 检查用户角色和JWT是否为空 - if (!userRole || userRole === '') { - console.error("❌ [Root Loader] 用户角色为空,session数据异常"); - // 保存当前路径,登录后可以跳转回来 - const redirectTo = pathname !== '/login' ? pathname : '/'; - return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}&error=no_role`); - } - - if (!frontendJWT) { - console.error("❌ [Root Loader] JWT token为空,session数据异常"); - // 保存当前路径,登录后可以跳转回来 - const redirectTo = pathname !== '/login' ? pathname : '/'; - return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}&error=no_token`); - } - - // console.log("🔑 [Root Loader] 用户角色:", userRole, "JWT前20字符:", frontendJWT.substring(0, 20)); - - // 🔒 RBAC 路由权限检查 - const { getUserRoutesByRole } = await import("~/api/auth/user-routes"); - // 权限校验需要包含隐藏路由,确保用户可以访问隐藏的功能页面 - // console.log("🔒 [Root Loader] 开始调用 getUserRoutesByRole..."); - const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true); - // console.log("🔒 [Root Loader] getUserRoutesByRole 返回结果:", { - // success: routesResult.success, - // hasData: !!routesResult.data, - // error: routesResult.error, - // shouldRedirectToHome: routesResult.shouldRedirectToHome - // }); - - if (routesResult.success && routesResult.data) { - // 从菜单数据中提取所有允许的路径 - allowedPaths = extractAllPaths(routesResult.data); - // console.log("🔑 [Root Loader] 用户允许的路由:", allowedPaths); - - // ✅ 保存权限映射表 - if (routesResult.permissionMap) { - permissionMap = routesResult.permissionMap; - // console.log("🔑 [Root Loader] 权限映射表:", permissionMap); - } - - // 检查当前路径是否在允许列表中 - // console.log("🔑 [Root Loader] 检查当前路径是否在允许列表中...", pathname, allowedPaths); - const isAllowedPath = isPathAllowed(pathname, allowedPaths); - - if (!isAllowedPath) { - console.warn(`⚠️ [Root Loader] 用户尝试访问未授权路由: ${pathname}`); - // 返回 403 错误,而不是 redirect(避免循环) - throw new Response("无权访问此页面", { status: 403 }); - } - } else { - // 🔑 检查是否因为认证失败需要重定向到登录页 - if (routesResult.shouldRedirectToHome) { - console.error("❌ [Root Loader] 获取用户路由权限失败,可能是令牌已过期,重定向到登录页"); - console.error("❌ [Root Loader] 错误详情:", routesResult.error); - - // 清除会话并重定向到登录页 - const { sessionStorage } = await import("~/api/login/auth.server"); - const session = await sessionStorage.getSession(request.headers.get("Cookie")); - const destroyedSession = await sessionStorage.destroySession(session); - - return redirect("/login?expired=true", { - headers: { - "Set-Cookie": destroyedSession - } - }); - } - - // 其他错误,只记录警告,不阻止访问(避免影响正常使用) - console.warn("⚠️ [Root Loader] 获取用户路由权限失败,跳过权限检查"); - } - } catch (error) { - // 如果是 Response 对象(403 错误),直接抛出 - if (error instanceof Response) { - throw error; - } - - // 🔑 检查是否是 AuthenticationError(token 过期) - if (error instanceof Error && error.name === 'AuthenticationError') { - console.warn("⚠️ [Root Loader] Token 过期,重定向到登录页"); - // 保存当前路径,登录后可以跳转回来 - const redirectTo = pathname !== '/login' ? pathname : '/'; - return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}`); - } - - console.warn("⚠️ [Root Loader] 获取用户会话失败:", error); - // 保持默认值 'common' - } - - // 注意:认证检查和重定向已在 getUserSession() 中统一处理 - // 如果执行到这里,说明已通过认证或是公共路径 - } - - // 🔒 移动端路由守卫 - // 移动端用户只能访问对话页面,尝试访问其他页面时重定向 - const isMobile = isMobileDevice(request); - if (isMobile && !isPublicPath) { - if (!isMobileAllowedPath(pathname)) { - console.log(`📱 [Root Loader] 移动端用户尝试访问 ${pathname},重定向到对话页面`); - return redirect(MOBILE_CHAT_PATH); - } - } - - // 检查交叉评查专属模式访问控制 - // 当 CROSS_CHECKING_ONLY_MODE=true 且端口为指定端口时,只允许访问 /cross-checking 相关路由 - const currentPort = getCurrentPort(); - const isCrossCheckingOnlyMode = CROSS_CHECKING_ONLY_MODE && currentPort === CROSS_CHECKING_ONLY_PORT; - - if (isCrossCheckingOnlyMode && !isPublicPath) { - // 交叉评查专属模式:只允许访问首页和交叉评查相关路径 - const crossCheckingAllowedPaths = ['/', '/cross-checking']; - const isCrossCheckingAllowedPath = crossCheckingAllowedPaths.some(path => pathname === path) || - pathname.startsWith('/cross-checking/'); - - if (!isCrossCheckingAllowedPath) { - console.warn(`⚠️ [Root Loader] 交叉评查专属模式:拒绝访问 ${pathname}`); - throw new Response("交叉评查专属模式下无权访问此页面", { status: 403 }); - } - } - - // 🔒 交叉评查访问控制: - // - CROSS_CHECKING_ONLY_MODE=false 时,所有端口都可访问(根据后端权限) - // - CROSS_CHECKING_ONLY_MODE=true 时,只有 51707 端口可访问 - if (CROSS_CHECKING_ONLY_MODE && !isPublicPath && currentPort !== CROSS_CHECKING_ONLY_PORT) { - const isCrossCheckingPath = pathname === '/cross-checking' || pathname.startsWith('/cross-checking/'); - if (isCrossCheckingPath) { - console.warn(`⚠️ [Root Loader] CROSS_CHECKING_ONLY_MODE启用,非51707端口禁止访问交叉评查:端口=${currentPort},路径=${pathname}`); - throw new Response("当前端口无权访问交叉评查功能", { status: 403 }); - } - } - - // 向组件传递路径信息 - return Response.json({ - userRole, // ✅ 返回真实的用户角色 - userArea, // ✅ 返回用户所属地区 - pathname, - frontendJWT, - isPublicPath, // 传递给客户端,用于判断是否需要认证 - isMobile, // 🔒 传递移动端标识 - permissionMap, // ✅ 传递权限映射表 - ENV: { - // 客户端不再需要直接调用 Dify API - }, - }); -} - - -export const meta: MetaFunction = () => { - return [ - { charSet: "utf-8" }, - { name: "viewport", content: "width=device-width,initial-scale=1" }, - { title: "中国烟草AI合同及卷宗审核系统" }, - { name: "description", content: "专业的AI合同及卷宗评查系统,提供智能审核、风险评估和规范化建议" }, - { name: "robots", content: "noindex,nofollow" } // 内部系统,防止被搜索引擎索引 - ]; -}; - -// 使用links函数为应用加载CSS和其他资源 -export function links() { - return [ - { rel: "stylesheet", href: styles }, - { rel: "stylesheet", href: messageModalStyles }, - { rel: "stylesheet", href: toastStyles }, - { rel: "stylesheet", href: sourceHanSansStyles }, // 思源黑体字体 - // 添加 Antd 样式 - // { rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/antd@5/dist/reset.css" }, - { rel: "icon", type: "image/svg+xml", href: "/logo.svg" }, - // Google Fonts(已弃用,改用本地字体) - // { rel: "preconnect", href: "https://fonts.googleapis.com" }, - // { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" }, - // { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" } - ]; -} - -export default function App() { - const { userRole, ENV, frontendJWT, isPublicPath, isMobile } = useLoaderData(); - - - return ( - - - - -