all in
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user