15 KiB
15 KiB
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"
}
问题: 当 explicitToken 为 undefined 时,代码将其设置为字符串 'undefined',导致请求头变成 Authorization: Bearer undefined,后端验证失败。
原因 2: OAuth 登录没有保存 token 到 localStorage
位置: app/routes/callback.tsx
问题流程:
- OAuth 登录成功后,
callback.tsxloader 获取了 JWT - 调用
createUserSession保存到 Cookie Session - 直接重定向到目标页面
- 但 localStorage 中没有 token!
- 客户端组件尝试从 URL 参数获取 token,但 URL 中没有 token 参数
- 所有 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 拦截器自动处理 |
🧪 测试验证
测试步骤
- 清除浏览器缓存和存储:
// 在浏览器控制台执行
localStorage.clear();
sessionStorage.clear();
location.reload();
-
测试 OAuth 登录:
- 访问登录页面
- 点击 OAuth 登录
- 观察控制台日志:
🔑 [Callback] 开始保存 token 到 localStorage ✅ [Callback] Token 已存储到 localStorage ✅ [Callback] 用户信息已存储到 localStorage 🚀 [Callback] 跳转到目标页面: / - 验证 localStorage 中有
access_token和user_info
-
测试账号密码登录:
- 输入用户名和密码
- 登录成功后检查 localStorage
- 验证
access_token和user_info已保存
-
测试文档列表加载:
- 访问
/documents/list页面 - 观察 Network 标签中的 API 请求
- 验证请求头中有
Authorization: Bearer <有效的JWT> - 验证文档列表正常加载
- 访问
-
测试其他 API 调用:
- 测试文档上传
- 测试文档删除
- 测试评查点设置
- 所有请求都应自动携带 token
验证清单
- ✅ OAuth 登录后 token 正确保存到 localStorage
- ✅ 账号密码登录后 token 正确保存到 localStorage
- ✅ 客户端 API 请求自动携带
Authorization头 - ✅ 服务端 API 请求(action)正确传递 token
- ✅ 文档列表正常加载
- ✅ 不再出现"用户身份验证失败"错误
- ✅ Token 过期后自动跳转到登录页(axios 拦截器处理)
📝 注意事项
1. Cookie Session 的作用
虽然所有客户端请求都从 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 拦截器,代码更简洁
🎯 优化效果
修复前的问题
- ❌ OAuth 登录后 localStorage 中没有 token
- ❌
postgrest-client.ts将 undefined 转为字符串 'undefined' - ❌ API 请求头变成
Authorization: Bearer undefined - ❌ 所有 API 请求失败,提示"用户身份验证失败"
- ❌ 文档列表无法加载
修复后的改进
- ✅ OAuth 登录正确保存 token 到 localStorage
- ✅
postgrest-client.ts正确处理 undefined 并 fallback 到 localStorage - ✅ API 请求头正确携带有效的 JWT
- ✅ 所有 API 请求成功
- ✅ 文档列表正常加载
- ✅ 代码更简洁,移除了大量不必要的 token 传递逻辑
- ✅ 统一的 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