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
11 KiB
11 KiB
模块认证规范
本文档定义了业务模块如何正确使用平台认证能力,确保所有 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 实例(推荐)
// 导入带认证的 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)
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 路由添加认证中间件
// 导入认证中间件
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
/**
* 获取用户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 结构
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 登录响应结构
{
"success": true,
"data": {
"user": { "id": "...", "name": "...", "role": "SUPER_ADMIN" },
"tokens": {
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "..."
}
}
}
Token 提取路径:res.data.data.tokens.accessToken
5.3 测试脚本通用模板
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 运行测试脚本
# 前置条件:后端服务运行中 + 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 过期
解决:
- 检查前端 API 是否使用
apiClient或getAuthHeaders() - 检查 localStorage 中是否有
accessToken - 如果 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
排查步骤:
- 确认 Token 提取路径:
res.data.data.tokens.accessToken - 确认 Authorization header 格式:
Bearer <token>(注意空格) - 确认后端服务使用的 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