Files
leaudit-platform-frontend/docs/Token管理架构优化总结.md
T
2025-12-05 00:09:32 +08:00

15 KiB
Raw Blame History

Token 管理架构优化总结

优化时间: 2025-11-17 问题描述: "获取文档列表失败: 用户身份验证失败,无法获取文档列表"


🔍 问题根源分析

原因 1: postgrest-client.ts 逻辑错误

位置: app/api/postgrest-client.ts:110

错误代码:

const token = explicitToken || 'undefined';  // ❌ 字符串 'undefined' 是 truthy

if (token) {  // 总是 true
    headers['Authorization'] = `Bearer ${token}`;  // 变成 "Bearer undefined"
}

问题: 当 explicitTokenundefined 时,代码将其设置为字符串 'undefined',导致请求头变成 Authorization: Bearer undefined,后端验证失败。

原因 2: OAuth 登录没有保存 token 到 localStorage

位置: app/routes/callback.tsx

问题流程:

  1. OAuth 登录成功后,callback.tsx loader 获取了 JWT
  2. 调用 createUserSession 保存到 Cookie Session
  3. 直接重定向到目标页面
  4. 但 localStorage 中没有 token
  5. 客户端组件尝试从 URL 参数获取 token,但 URL 中没有 token 参数
  6. 所有 API 请求因缺少 token 而失败

解决方案

1. 修复 postgrest-client.ts 的 token 获取逻辑

文件: app/api/postgrest-client.ts

修复后的代码:

// 优先使用显式传入的 token,否则尝试从客户端 localStorage 获取
const token = explicitToken || (typeof window !== 'undefined' ? localStorage.getItem('access_token') : undefined);

// 如果有有效的 token,添加到 Authorization 头部
if (token && token !== 'undefined') {
    headers['Authorization'] = `Bearer ${token}`;
}

变更说明:

  • 移除了字符串 'undefined' 的赋值
  • 添加了从 localStorage 获取 token 的 fallback 机制
  • 增加了 token !== 'undefined' 的验证

2. 修复 OAuth 登录 token 保存流程

文件: app/routes/callback.tsx

2.1 服务端 Loader - 将 token 通过 URL 传递

修改位置: 第 174-202 行

// 🔑 重要:将 token 和用户信息作为 URL 参数传递给客户端
// 客户端 useEffect 会将其保存到 localStorage
const callbackUrl = new URL('/callback', url.origin);
callbackUrl.searchParams.set('token', frontendJWT);
callbackUrl.searchParams.set('userInfo', encodeURIComponent(JSON.stringify({
  user_id: savedUserInfo.user_id,
  username: savedUserInfo.username,
  nick_name: savedUserInfo.nick_name,
  email: savedUserInfo.email,
  phone_number: savedUserInfo.phone_number,
  ou_id: savedUserInfo.ou_id,
  ou_name: savedUserInfo.ou_name,
  is_leader: savedUserInfo.is_leader,
  user_role: savedUserInfo.user_role,
  sub: userInfo.data.sub
})));
callbackUrl.searchParams.set('redirectTo', redirectTo);

// 使用统一的session创建函数
return createUserSession({
  isAuthenticated: true,
  userRole: savedUserInfo.user_role,
  redirectTo: callbackUrl.toString(), // 先跳转到 callback 页面保存 token
  accessToken: tokenResponse.access_token,
  refreshToken: tokenResponse.refresh_token,
  tokenExpiresIn: tokenResponse.expires_in,
  userInfo: enhancedUserInfo,
  frontendJWT
});

2.2 客户端组件 - 保存 token 后自动跳转

修改位置: 第 226-270 行

export default function Callback() {
  const [searchParams] = useSearchParams();

  useEffect(() => {
    const token = searchParams.get("token");
    const userInfo = searchParams.get("userInfo");
    const redirectTo = searchParams.get("redirectTo") || "/";

    if (token && typeof window !== 'undefined') {
      console.log('🔑 [Callback] 开始保存 token 到 localStorage');

      // 存储 token 到 localStorage
      localStorage.setItem('access_token', token);
      console.log('✅ [Callback] Token 已存储到 localStorage');

      // 存储用户信息
      if (userInfo) {
        try {
          const parsedUserInfo = JSON.parse(decodeURIComponent(userInfo));
          localStorage.setItem('user_info', JSON.stringify(parsedUserInfo));
          console.log('✅ [Callback] 用户信息已存储到 localStorage:', parsedUserInfo);
        } catch (error) {
          console.error('❌ [Callback] 解析用户信息失败:', error);
        }
      }

      // ⏱️ 短暂延迟后跳转,确保 localStorage 写入完成
      setTimeout(() => {
        console.log(`🚀 [Callback] 跳转到目标页面: ${redirectTo}`);
        window.location.href = redirectTo;
      }, 500);
    }
  }, [searchParams]);

  return (
    <div className="flex items-center justify-center min-h-screen">
      <div className="text-center">
        <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
        <p className="mt-4 text-gray-600">正在处理登录...</p>
        <p className="mt-2 text-sm text-gray-500">即将跳转...</p>
      </div>
    </div>
  );
}

3. 简化 documents.list.tsx - 统一使用 localStorage

文件: app/routes/documents.list.tsx

3.1 Loader 函数简化

修改前:

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { userInfo, frontendJWT } = await getUserSession(request);

  const typesResponse = await getDocumentTypes({ pageSize: 500 }, frontendJWT);

  return Response.json({
    userInfo,
    frontendJWT,  // ❌ 不需要传递
    // ...
  });
};

修改后:

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { userInfo } = await getUserSession(request);

  // token 由 axios 拦截器自动从 localStorage 获取
  const typesResponse = await getDocumentTypes({ pageSize: 500 });

  return Response.json({
    userInfo,  // ✅ 只传递必要的用户信息
    // ...
  });
};

3.2 客户端 fetchData 函数简化

修改前:

const fetchData = useCallback(async (storedReviewType: string) => {
  // 从 loader 获取 JWT
  let jwtToken = loaderData.frontendJWT;

  if (!jwtToken && typeof window !== 'undefined') {
    jwtToken = localStorage.getItem('access_token') || undefined;
  }

  const searchParams = {
    // ...
    token: jwtToken  // ❌ 手动传递 token
  };

  await getDocumentsWithVersionInfo(searchParams);
}, [loaderData.frontendJWT]);

修改后:

const fetchData = useCallback(async (storedReviewType: string) => {
  // token 由 axios 拦截器自动从 localStorage 获取
  const searchParams = {
    // ...
    // ✅ 不需要手动传递 token
  };

  await getDocumentsWithVersionInfo(searchParams);
}, [/* 移除了 loaderData.frontendJWT 依赖 */]);

3.3 移除客户端代码中的 frontendJWT 传递

修改位置:

  • 第 726 行: updateDocumentAuditStatus - 移除 token 参数
  • 第 806 行: appendContractAttachments - 移除 token 参数
  • 第 877 行: uploadContractTemplate - 移除 token 参数
  • 第 934 行: getDocumentHistory - 移除 token 参数

保留位置:

  • 第 131 行: action 函数(服务端)- 保留 frontendJWT,因为服务端无法访问 localStorage
  • 第 143 行: deleteDocument - 保留 token 参数(服务端调用)

📊 优化后的架构

Token 管理统一流程

┌─────────────────────────────────────────────────────────┐
│                     登录阶段                             │
├─────────────────────────────────────────────────────────┤
│ 1. 用户登录(OAuth/账号密码)                            │
│ 2. 服务端生成 JWT                                        │
│ 3. 存储到两个位置:                                      │
│    - Cookie Session (服务端): __lgsession               │
│    - localStorage (客户端): access_token                │
│                                                          │
│ OAuth 登录流程:                                         │
│   callback.tsx loader → createUserSession               │
│   ↓                                                      │
│   重定向到 /callback?token=xxx&userInfo=xxx             │
│   ↓                                                      │
│   客户端 useEffect 保存到 localStorage                   │
│   ↓                                                      │
│   跳转到目标页面                                         │
│                                                          │
│ 账号密码登录流程:                                       │
│   login.tsx → storeLoginData → localStorage             │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                   API 请求阶段                           │
├─────────────────────────────────────────────────────────┤
│ 客户端请求(浏览器环境):                               │
│ 1. axios 请求拦截器自动执行                              │
│ 2. 从 localStorage 获取 'access_token'                   │
│ 3. 自动添加到请求头: Authorization: Bearer <token>      │
│ 4. 发送请求到后端 API                                    │
│                                                          │
│ 服务端请求(SSR 环境):                                 │
│ 1. 从 Cookie Session 获取 frontendJWT                    │
│ 2. 手动传递给 API 函数作为参数                          │
│ 3. 通过 mergeAuthHeaders 添加到请求头                   │
│ 4. 发送请求到后端 API                                    │
└─────────────────────────────────────────────────────────┘

关键文件和职责

文件 职责 Token 获取方式
axios-client.ts axios 实例和拦截器 拦截器自动从 localStorage 获取
postgrest-client.ts PostgREST API 封装 fallback 到 localStorage
callback.tsx (Loader) OAuth 登录回调处理 通过 URL 参数传递给客户端
callback.tsx (组件) 保存 token 到 localStorage 从 URL 参数读取并保存
login.tsx 账号密码登录 调用 storeLoginData 保存
documents.list.tsx (Loader) SSR 数据加载 从 Cookie Session 获取(服务端)
documents.list.tsx (组件) 客户端数据请求 axios 拦截器自动处理

🧪 测试验证

测试步骤

  1. 清除浏览器缓存和存储:
// 在浏览器控制台执行
localStorage.clear();
sessionStorage.clear();
location.reload();
  1. 测试 OAuth 登录:

    • 访问登录页面
    • 点击 OAuth 登录
    • 观察控制台日志:
      🔑 [Callback] 开始保存 token 到 localStorage
      ✅ [Callback] Token 已存储到 localStorage
      ✅ [Callback] 用户信息已存储到 localStorage
      🚀 [Callback] 跳转到目标页面: /
      
    • 验证 localStorage 中有 access_tokenuser_info
  2. 测试账号密码登录:

    • 输入用户名和密码
    • 登录成功后检查 localStorage
    • 验证 access_tokenuser_info 已保存
  3. 测试文档列表加载:

    • 访问 /documents/list 页面
    • 观察 Network 标签中的 API 请求
    • 验证请求头中有 Authorization: Bearer <有效的JWT>
    • 验证文档列表正常加载
  4. 测试其他 API 调用:

    • 测试文档上传
    • 测试文档删除
    • 测试评查点设置
    • 所有请求都应自动携带 token

验证清单

  • OAuth 登录后 token 正确保存到 localStorage
  • 账号密码登录后 token 正确保存到 localStorage
  • 客户端 API 请求自动携带 Authorization
  • 服务端 API 请求(action)正确传递 token
  • 文档列表正常加载
  • 不再出现"用户身份验证失败"错误
  • Token 过期后自动跳转到登录页(axios 拦截器处理)

📝 注意事项

虽然所有客户端请求都从 localStorage 获取 token,但 Cookie Session 仍然重要:

  • 服务端渲染(SSR: Loader 函数需要从 Cookie Session 获取用户信息
  • Action 函数: 服务端表单处理需要 Cookie Session 中的 token
  • 认证检查: root.tsx 的全局认证仍依赖 Cookie Session

2. Token 刷新机制

  • OAuth token 的刷新由 token-manager.server.ts 处理
  • 账号密码登录的 token 有效期 2 小时
  • Token 过期后,axios 拦截器会清除 localStorage 并跳转到登录页

3. 安全性考虑

  • localStorage 中的 token 仅在客户端可见
  • Cookie Session 使用 httpOnly,防止 XSS 攻击
  • 所有 API 请求都需要 Bearer token 验证
  • Token 过期后自动清除并重新登录

4. 向后兼容

  • 保留了 Cookie Session 机制,确保 SSR 正常工作
  • 服务端代码(Loader/Action)仍然可以从 Session 获取 token
  • 客户端代码统一使用 axios 拦截器,代码更简洁

🎯 优化效果

修复前的问题

  1. OAuth 登录后 localStorage 中没有 token
  2. postgrest-client.ts 将 undefined 转为字符串 'undefined'
  3. API 请求头变成 Authorization: Bearer undefined
  4. 所有 API 请求失败,提示"用户身份验证失败"
  5. 文档列表无法加载

修复后的改进

  1. OAuth 登录正确保存 token 到 localStorage
  2. postgrest-client.ts 正确处理 undefined 并 fallback 到 localStorage
  3. API 请求头正确携带有效的 JWT
  4. 所有 API 请求成功
  5. 文档列表正常加载
  6. 代码更简洁,移除了大量不必要的 token 传递逻辑
  7. 统一的 token 管理机制,便于维护

🔗 相关文档

  • app/api/axios-client.ts - axios 拦截器实现
  • app/api/postgrest-client.ts - PostgREST API 封装
  • app/routes/callback.tsx - OAuth 登录回调
  • app/routes/login.tsx - 账号密码登录
  • app/utils/auth-storage.ts - localStorage 存储工具
  • docs/前端认证改造总结.md - 之前的认证改造文档

优化完成时间: 2025-11-17 优化者: Claude Code