Move single-session invalidation from cache-based token version checks to a database-backed, atomic tokenVersion flow to eliminate concurrent login race conditions. Add a global auth heartbeat (visibility-aware) so kicked sessions are detected within ~10s when the page is visible. Made-with: Cursor
340 lines
11 KiB
Markdown
340 lines
11 KiB
Markdown
# 模块认证规范
|
||
|
||
> 本文档定义了业务模块如何正确使用平台认证能力,确保所有 API 都正确携带和验证用户身份。
|
||
|
||
## 1. 架构概览
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 前端 │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ common/api/axios.ts ← 带认证的 axios 实例 │ │
|
||
│ │ framework/auth/api.ts ← Token 管理 (getAccessToken)│ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
│ Authorization: Bearer <token>
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ 后端 │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ common/auth/auth.middleware.ts │ │
|
||
│ │ - authenticate: 验证 JWT Token │ │
|
||
│ │ - requirePermission: 权限检查 │ │
|
||
│ │ - requireRoles: 角色检查 │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
## 2. 前端规范
|
||
|
||
### 2.1 使用带认证的 axios 实例(推荐)
|
||
|
||
```typescript
|
||
// 导入带认证的 apiClient
|
||
import apiClient from '../../../common/api/axios';
|
||
|
||
// 使用方式与 axios 完全相同,自动携带 JWT Token
|
||
const response = await apiClient.get('/api/v2/xxx');
|
||
const response = await apiClient.post('/api/v2/xxx', data);
|
||
```
|
||
|
||
### 2.2 使用原生 fetch(需手动添加 Token)
|
||
|
||
```typescript
|
||
import { getAccessToken } from '../../../framework/auth/api';
|
||
|
||
// 创建 getAuthHeaders 函数
|
||
function getAuthHeaders(): HeadersInit {
|
||
const headers: Record<string, string> = {
|
||
'Content-Type': 'application/json',
|
||
};
|
||
const token = getAccessToken();
|
||
if (token) {
|
||
headers['Authorization'] = `Bearer ${token}`;
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
// 所有 fetch 请求使用 getAuthHeaders()
|
||
const response = await fetch(url, {
|
||
headers: getAuthHeaders(),
|
||
});
|
||
|
||
// 文件上传(不设置 Content-Type)
|
||
const token = getAccessToken();
|
||
const headers: HeadersInit = {};
|
||
if (token) {
|
||
headers['Authorization'] = `Bearer ${token}`;
|
||
}
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers,
|
||
body: formData,
|
||
});
|
||
```
|
||
|
||
## 3. 后端规范
|
||
|
||
### 3.1 路由添加认证中间件
|
||
|
||
```typescript
|
||
// 导入认证中间件
|
||
import { authenticate, requirePermission } from '../../../common/auth/auth.middleware.js';
|
||
|
||
// 添加到路由
|
||
fastify.get('/xxx', { preHandler: [authenticate] }, handler);
|
||
|
||
// 需要特定权限
|
||
fastify.post('/xxx', {
|
||
preHandler: [authenticate, requirePermission('module:action')]
|
||
}, handler);
|
||
```
|
||
|
||
### 3.2 控制器获取用户 ID
|
||
|
||
```typescript
|
||
/**
|
||
* 获取用户ID(从JWT Token中获取)
|
||
*/
|
||
function getUserId(request: FastifyRequest): string {
|
||
const userId = (request as any).user?.userId;
|
||
if (!userId) {
|
||
throw new Error('User not authenticated');
|
||
}
|
||
return userId;
|
||
}
|
||
|
||
// 在控制器方法中使用
|
||
async function myHandler(request: FastifyRequest, reply: FastifyReply) {
|
||
const userId = getUserId(request);
|
||
// ... 使用 userId
|
||
}
|
||
```
|
||
|
||
### 3.3 JWT Token 结构
|
||
|
||
```typescript
|
||
interface DecodedToken {
|
||
userId: string; // 用户ID
|
||
phone: string; // 手机号
|
||
role: string; // 角色
|
||
tenantId: string; // 租户ID
|
||
tenantCode?: string; // 租户Code
|
||
tokenVersion: number; // 会话版本号(单设备登录互踢)
|
||
iat: number; // 签发时间
|
||
exp: number; // 过期时间
|
||
}
|
||
```
|
||
|
||
### 3.4 单账号互踢(强一致)
|
||
|
||
- 后端使用 `platform_schema.users.token_version` 作为会话版本号(数据库强一致)
|
||
- 每次登录都会原子执行 `token_version = token_version + 1`
|
||
- Access/Refresh Token 均携带 `tokenVersion`
|
||
- 鉴权时要求 `tokenVersion === users.token_version`,不一致即判定“已在其他设备登录”
|
||
- 禁止依赖进程内缓存实现互踢(多实例/并发场景会失效)
|
||
|
||
## 4. 检查清单
|
||
|
||
### 4.1 新模块开发检查清单
|
||
|
||
- [ ] **前端 API 文件**
|
||
- [ ] 使用 `apiClient` 或添加 `getAuthHeaders()`
|
||
- [ ] 文件上传单独处理(不设置 Content-Type)
|
||
- [ ] 导出函数不包含测试用 userId 参数
|
||
|
||
- [ ] **后端路由文件**
|
||
- [ ] 导入 `authenticate` 中间件
|
||
- [ ] 所有需要认证的路由添加 `preHandler: [authenticate]`
|
||
- [ ] 公开 API(如模板列表)可不添加认证
|
||
|
||
- [ ] **后端控制器文件**
|
||
- [ ] 添加 `getUserId()` 辅助函数
|
||
- [ ] 移除所有 `MOCK_USER_ID` 或硬编码默认值
|
||
- [ ] 使用 `getUserId(request)` 获取用户 ID
|
||
|
||
### 4.2 已完成模块状态
|
||
|
||
| 模块 | 前端 API | 后端路由 | 后端控制器 | 状态 |
|
||
|------|---------|---------|-----------|------|
|
||
| RVW | ✅ apiClient | ✅ authenticate | ✅ getUserId | ✅ |
|
||
| PKB | ✅ 拦截器 | ✅ authenticate | ✅ getUserId | ✅ |
|
||
| ASL | ✅ getAuthHeaders | ✅ authenticate | ✅ getUserId | ✅ |
|
||
| DC Tool B | ✅ getAuthHeaders | ✅ authenticate | ✅ getUserId | ✅ |
|
||
| DC Tool C | ✅ apiClient | ✅ authenticate | ✅ getUserId | ✅ |
|
||
| IIT | N/A (企业微信) | N/A | ✅ 企业微信userId | ✅ |
|
||
| Prompt管理 | ✅ getAuthHeaders | ✅ authenticate | ✅ getUserId | ✅ |
|
||
|
||
## 5. 测试脚本认证规范
|
||
|
||
> 编写 API 测试脚本时,需要先通过登录接口获取 Token,再携带到后续请求中。
|
||
|
||
### 5.1 登录接口
|
||
|
||
```
|
||
POST /api/v1/auth/login/password
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"phone": "13800000001",
|
||
"password": "123456"
|
||
}
|
||
```
|
||
|
||
### 5.2 登录响应结构
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"user": { "id": "...", "name": "...", "role": "SUPER_ADMIN" },
|
||
"tokens": {
|
||
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
|
||
"refreshToken": "..."
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Token 提取路径**:`res.data.data.tokens.accessToken`
|
||
|
||
### 5.3 测试脚本通用模板
|
||
|
||
```typescript
|
||
const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3001';
|
||
const API_PREFIX = `${BASE_URL}/api/v1`;
|
||
|
||
const TEST_PHONE = process.env.TEST_PHONE || '13800000001';
|
||
const TEST_PASSWORD = process.env.TEST_PASSWORD || '123456';
|
||
|
||
let authToken = '';
|
||
|
||
async function api(path: string, options: RequestInit = {}) {
|
||
const res = await fetch(`${API_PREFIX}${path}`, {
|
||
...options,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
||
...(options.headers || {}),
|
||
},
|
||
});
|
||
const contentType = res.headers.get('content-type') || '';
|
||
const data = contentType.includes('json') ? await res.json() : await res.text();
|
||
return { status: res.status, data };
|
||
}
|
||
|
||
// 登录获取 Token
|
||
async function login() {
|
||
const res = await api('/auth/login/password', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ phone: TEST_PHONE, password: TEST_PASSWORD }),
|
||
});
|
||
if (res.status !== 200 || !res.data.success) {
|
||
throw new Error(`登录失败: ${JSON.stringify(res.data)}`);
|
||
}
|
||
authToken = res.data.data.tokens.accessToken;
|
||
}
|
||
```
|
||
|
||
### 5.4 测试账号说明
|
||
|
||
| 手机号 | 密码 | 角色 | 说明 |
|
||
|--------|------|------|------|
|
||
| 13800000001 | 123456 | SUPER_ADMIN | 超级管理员,可访问所有模块 |
|
||
| 13800000000 | 123456 | USER | 普通测试用户 |
|
||
|
||
> **注意**:`SUPER_ADMIN` 和 `PROMPT_ENGINEER` 角色在 `requireModule()` 中间件中自动豁免模块权限检查,无需在 `user_modules` 表中分配模块。普通 `USER` 角色需要先在 `platform_schema.user_modules` 中分配对应模块才能访问。
|
||
|
||
### 5.5 运行测试脚本
|
||
|
||
```bash
|
||
# 前置条件:后端服务运行中 + PostgreSQL 运行中
|
||
cd backend
|
||
|
||
# 方式一:使用默认测试账号
|
||
npx tsx src/modules/xxx/__tests__/test-script.ts
|
||
|
||
# 方式二:通过环境变量指定账号
|
||
$env:TEST_PHONE="13800000001"; $env:TEST_PASSWORD="123456"; npx tsx src/modules/xxx/__tests__/test-script.ts
|
||
```
|
||
|
||
### 5.6 用户表说明
|
||
|
||
用户数据存储在 `platform_schema.users` 表中(**不是** `public.users`)。Prisma schema 中的 `User` model 映射到此表。关键字段:
|
||
|
||
| 字段 | 说明 |
|
||
|------|------|
|
||
| `phone` | 登录手机号(唯一) |
|
||
| `password` | bcrypt 哈希密码 |
|
||
| `role` | 角色:SUPER_ADMIN / HOSPITAL_ADMIN / DEPARTMENT_ADMIN / PROMPT_ENGINEER / USER 等 |
|
||
| `tenant_id` | 租户 ID |
|
||
| `status` | active / disabled |
|
||
|
||
## 6. 常见错误和解决方案
|
||
|
||
### 6.1 401 Unauthorized
|
||
|
||
**原因**: 前端没有携带 JWT Token 或 Token 过期
|
||
|
||
**解决**:
|
||
1. 检查前端 API 是否使用 `apiClient` 或 `getAuthHeaders()`
|
||
2. 检查 localStorage 中是否有 `accessToken`
|
||
3. 如果 Token 过期,尝试刷新或重新登录
|
||
|
||
### 6.2 User not authenticated
|
||
|
||
**原因**: 后端路由没有添加 `authenticate` 中间件
|
||
|
||
**解决**: 在路由定义中添加 `preHandler: [authenticate]`
|
||
|
||
### 6.3 TypeError: Cannot read property 'userId' of undefined
|
||
|
||
**原因**: 使用了错误的属性名(`request.user.id` 而非 `request.user.userId`)
|
||
|
||
**解决**: 使用 `(request as any).user?.userId`
|
||
|
||
### 6.4 测试脚本 Token 为 NULL
|
||
|
||
**原因**: 登录返回的 Token 路径不对
|
||
|
||
**解决**: Token 位于 `res.data.data.tokens.accessToken`,不是 `res.data.data.accessToken` 或 `res.data.data.token`
|
||
|
||
### 6.5 测试脚本登录成功但 API 返回 401
|
||
|
||
**排查步骤**:
|
||
1. 确认 Token 提取路径:`res.data.data.tokens.accessToken`
|
||
2. 确认 Authorization header 格式:`Bearer <token>`(注意空格)
|
||
3. 确认后端服务使用的 JWT_SECRET 与签发时一致
|
||
|
||
## 7. 参考文件
|
||
|
||
- 前端 axios 实例: `frontend-v2/src/common/api/axios.ts`
|
||
- 前端 Token 管理: `frontend-v2/src/framework/auth/api.ts`
|
||
- 后端认证中间件: `backend/src/common/auth/auth.middleware.ts`
|
||
- 后端 JWT 服务: `backend/src/common/auth/jwt.service.ts`
|
||
- 测试脚本示例: `backend/src/modules/asl/__tests__/deep-research-v2-smoke.ts`
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|