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

413 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Token 管理架构优化总结
**优化时间**: 2025-11-17
**问题描述**: "获取文档列表失败: 用户身份验证失败,无法获取文档列表"
---
## 🔍 问题根源分析
### 原因 1: `postgrest-client.ts` 逻辑错误
**位置**: `app/api/postgrest-client.ts:110`
**❌ 错误代码**:
```typescript
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`
**问题流程**:
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`
**✅ 修复后的代码**:
```typescript
// 优先使用显式传入的 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 行
```typescript
// 🔑 重要:将 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 行
```typescript
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 函数简化
**修改前**:
```typescript
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { userInfo, frontendJWT } = await getUserSession(request);
const typesResponse = await getDocumentTypes({ pageSize: 500 }, frontendJWT);
return Response.json({
userInfo,
frontendJWT, // ❌ 不需要传递
// ...
});
};
```
**修改后**:
```typescript
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 函数简化
**修改前**:
```typescript
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]);
```
**修改后**:
```typescript
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. **清除浏览器缓存和存储**:
```javascript
// 在浏览器控制台执行
localStorage.clear();
sessionStorage.clear();
location.reload();
```
2. **测试 OAuth 登录**:
- 访问登录页面
- 点击 OAuth 登录
- 观察控制台日志:
```
🔑 [Callback] 开始保存 token 到 localStorage
✅ [Callback] Token 已存储到 localStorage
✅ [Callback] 用户信息已存储到 localStorage
🚀 [Callback] 跳转到目标页面: /
```
- 验证 localStorage 中有 `access_token` 和 `user_info`
3. **测试账号密码登录**:
- 输入用户名和密码
- 登录成功后检查 localStorage
- 验证 `access_token` 和 `user_info` 已保存
4. **测试文档列表加载**:
- 访问 `/documents/list` 页面
- 观察 Network 标签中的 API 请求
- 验证请求头中有 `Authorization: Bearer <有效的JWT>`
- 验证文档列表正常加载
5. **测试其他 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 拦截器,代码更简洁
---
## 🎯 优化效果
### 修复前的问题
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