Files
AIclinicalresearch/docs/04-开发规范/10-模块认证规范.md
HaHafeng 50657dd81f fix(auth): enforce single-session with DB tokenVersion + heartbeat detection
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
2026-03-09 13:11:37 +08:00

340 lines
11 KiB
Markdown
Raw Permalink 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.
# 模块认证规范
> 本文档定义了业务模块如何正确使用平台认证能力,确保所有 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`