1
1
mirror of https://github.com/ZeroCatDev/ClassworksKV.git synced 2025-12-09 07:33:10 +00:00

Compare commits

..

No commits in common. "main" and "v1.2.0" have entirely different histories.
main ... v1.2.0

47 changed files with 3781 additions and 14756 deletions

8
.idea/.gitignore generated vendored
View File

@ -1,8 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated
View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/FixClassworksKV.iml" filepath="$PROJECT_DIR$/.idea/FixClassworksKV.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated
View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -1,129 +0,0 @@
# Refresh Token系统迁移检查清单
## 🔧 服务端迁移
### 数据库
- [ ] 运行Prisma迁移: `npx prisma migrate dev --name add_refresh_token_system`
- [ ] 验证Account表新增字段: refreshToken, refreshTokenExpiry, tokenVersion
### 环境配置
- [ ] 添加 `ACCESS_TOKEN_EXPIRES_IN=15m`
- [ ] 添加 `REFRESH_TOKEN_EXPIRES_IN=7d`
- [ ] 添加 `REFRESH_TOKEN_SECRET=your-refresh-token-secret`
- [ ] 可选配置RSA密钥对
### 代码验证
- [ ] `utils/tokenManager.js` 文件已创建
- [ ] `utils/jwt.js` 已更新(保持向后兼容)
- [ ] `middleware/jwt-auth.js` 已升级
- [ ] `routes/accounts.js` 新增refresh相关端点
## 🖥️ 前端迁移
### OAuth回调处理
- [ ] 更新回调URL参数解析支持access_token和refresh_token
- [ ] 保持对旧版token参数的兼容性
- [ ] 实现TokenManager类
### Token管理
- [ ] 实现Token刷新逻辑
- [ ] 添加请求拦截器检查X-New-Access-Token响应头
- [ ] 实现401错误自动重试机制
- [ ] 添加登出功能(单设备/全设备)
### 存储策略
- [ ] Access Token存储localStorage/sessionStorage
- [ ] Refresh Token安全存储
- [ ] 实现Token清理逻辑
## 🧪 测试验证
### 功能测试
- [ ] OAuth登录流程测试
- [ ] Token自动刷新测试
- [ ] 手动refresh接口测试
- [ ] 登出功能测试(单设备)
- [ ] 登出功能测试(全设备)
- [ ] Token信息查看测试
### 兼容性测试
- [ ] 旧版JWT token仍然有效
- [ ] 新旧token混合使用场景
- [ ] API向后兼容性验证
### 错误处理测试
- [ ] 过期token处理
- [ ] 无效refresh token处理
- [ ] 网络错误重试
- [ ] 并发刷新场景
## 📊 监控配置
### 日志记录
- [ ] Token生成日志
- [ ] Token刷新日志
- [ ] 认证失败日志
- [ ] 登出操作日志
### 性能监控
- [ ] Token刷新频率统计
- [ ] API响应时间监控
- [ ] 数据库查询性能
## 🔒 安全检查
### Token安全
- [ ] 密钥强度验证
- [ ] Token过期时间配置合理
- [ ] HTTPS传输确认
- [ ] 敏感信息不在日志中暴露
### 访问控制
- [ ] Token撤销功能正常
- [ ] 版本控制机制有效
- [ ] 设备隔离正确
## 📚 文档检查
- [ ] API文档已更新
- [ ] 前端集成指南已提供
- [ ] 迁移步骤文档完整
- [ ] 错误处理指南清晰
## 🚀 上线准备
### 部署前
- [ ] 代码review完成
- [ ] 单元测试通过
- [ ] 集成测试通过
- [ ] 性能测试通过
### 部署时
- [ ] 数据库迁移执行
- [ ] 环境变量配置
- [ ] 服务重启验证
- [ ] 健康检查通过
### 部署后
- [ ] 新用户登录测试
- [ ] 现有用户功能正常
- [ ] 监控指标正常
- [ ] 错误日志检查
## 🔄 回滚计划
### 紧急回滚
- [ ] 回滚代码到上一版本
- [ ] 恢复原环境变量
- [ ] 数据库回滚方案(如需要)
### 数据迁移回滚
- [ ] 备份新增字段数据
- [ ] 移除新增字段的迁移脚本
- [ ] 验证旧版功能正常
---
**检查完成人员**: ___________
**检查完成时间**: ___________
**环境**: [ ] 开发 [ ] 测试 [ ] 生产

View File

@ -1,489 +0,0 @@
# Refresh Token系统API文档
## 概述
ClassworksKV现在支持标准的Refresh Token认证系统提供更安全的用户认证机制。新系统包含
- **Access Token**: 短期令牌默认15分钟用于API访问
- **Refresh Token**: 长期令牌默认7天用于刷新Access Token
- **Token版本控制**: 支持令牌失效和安全登出
- **向后兼容**: 支持旧版JWT令牌
## 配置选项
可以通过环境变量配置token系统
```bash
# Access Token配置
ACCESS_TOKEN_EXPIRES_IN=15m # Access Token过期时间
REFRESH_TOKEN_EXPIRES_IN=7d # Refresh Token过期时间
# 密钥配置HS256算法
JWT_SECRET=your-access-token-secret
REFRESH_TOKEN_SECRET=your-refresh-token-secret
# RSA密钥配置RS256算法可选
JWT_ALG=RS256
ACCESS_TOKEN_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n..."
ACCESS_TOKEN_PUBLIC_KEY="-----BEGIN RSA PUBLIC KEY-----\n..."
REFRESH_TOKEN_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n..."
REFRESH_TOKEN_PUBLIC_KEY="-----BEGIN RSA PUBLIC KEY-----\n..."
```
## API端点
### 1. OAuth登录回调
OAuth登录成功后系统会返回令牌对。
**回调URL参数新版**
```
https://your-frontend.com/?access_token=eyJ...&refresh_token=eyJ...&expires_in=15m&success=true&provider=github
```
**旧版兼容参数:**
```
https://your-frontend.com/?token=eyJ...&success=true&provider=github
```
### 2. 刷新访问令牌
当Access Token即将过期或已过期时使用Refresh Token获取新的Access Token。
**端点:** `POST /api/accounts/refresh`
**请求体:**
```json
{
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
**响应(成功):**
```json
{
"success": true,
"message": "令牌刷新成功",
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": "15m",
"account": {
"id": "clxxxx",
"provider": "github",
"email": "user@example.com",
"name": "User Name",
"avatarUrl": "https://..."
}
}
}
```
**错误响应:**
```json
{
"success": false,
"message": "刷新令牌已过期"
}
```
**错误状态码:**
- `400`: 缺少刷新令牌
- `401`: 无效的刷新令牌、令牌已过期、账户不存在、令牌版本不匹配
### 3. 登出(当前设备)
撤销当前设备的Refresh Token但不影响其他设备。
**端点:** `POST /api/accounts/logout`
**请求头:**
```
Authorization: Bearer <access_token>
```
**响应:**
```json
{
"success": true,
"message": "登出成功"
}
```
### 4. 登出所有设备
撤销账户的所有令牌,强制所有设备重新登录。
**端点:** `POST /api/accounts/logout-all`
**请求头:**
```
Authorization: Bearer <access_token>
```
**响应:**
```json
{
"success": true,
"message": "已从所有设备登出"
}
```
### 5. 获取令牌信息
查看当前令牌的详细信息和状态。
**端点:** `GET /api/accounts/token-info`
**请求头:**
```
Authorization: Bearer <access_token>
```
**响应:**
```json
{
"success": true,
"data": {
"accountId": "clxxxx",
"tokenType": "access",
"tokenVersion": 1,
"issuedAt": "2024-11-01T08:00:00.000Z",
"expiresAt": "2024-11-01T08:15:00.000Z",
"expiresIn": 900,
"isExpired": false,
"isLegacyToken": false,
"hasRefreshToken": true,
"refreshTokenExpiry": "2024-11-08T08:00:00.000Z"
}
}
```
## 自动刷新机制
### 响应头刷新
当Access Token剩余有效期少于5分钟时系统会在响应头中提供新的Access Token
**响应头:**
```
X-New-Access-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
X-Token-Refreshed: true
```
前端应检查这些响应头并更新本地存储的token。
## 前端集成示例
### JavaScript/TypeScript
```javascript
class TokenManager {
constructor() {
this.accessToken = localStorage.getItem('access_token');
this.refreshToken = localStorage.getItem('refresh_token');
}
// 设置令牌对
setTokens(accessToken, refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', refreshToken);
}
// 清除令牌
clearTokens() {
this.accessToken = null;
this.refreshToken = null;
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
}
// 刷新访问令牌
async refreshAccessToken() {
if (!this.refreshToken) {
throw new Error('No refresh token available');
}
try {
const response = await fetch('/api/accounts/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
refresh_token: this.refreshToken
}),
});
const data = await response.json();
if (data.success) {
this.accessToken = data.data.access_token;
localStorage.setItem('access_token', this.accessToken);
return this.accessToken;
} else {
throw new Error(data.message);
}
} catch (error) {
this.clearTokens();
throw error;
}
}
// API请求拦截器
async request(url, options = {}) {
const headers = {
'Authorization': `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
...options.headers,
};
const response = await fetch(url, {
...options,
headers,
});
// 检查是否有新的访问令牌
const newAccessToken = response.headers.get('X-New-Access-Token');
if (newAccessToken) {
this.accessToken = newAccessToken;
localStorage.setItem('access_token', newAccessToken);
}
// 如果token过期尝试刷新
if (response.status === 401) {
try {
await this.refreshAccessToken();
// 重试请求
return this.request(url, options);
} catch (refreshError) {
// 刷新失败,重定向到登录页
window.location.href = '/login';
throw refreshError;
}
}
return response;
}
// 登出
async logout() {
try {
await this.request('/api/accounts/logout', {
method: 'POST',
});
} catch (error) {
console.error('Logout error:', error);
} finally {
this.clearTokens();
window.location.href = '/login';
}
}
// 登出所有设备
async logoutAll() {
try {
await this.request('/api/accounts/logout-all', {
method: 'POST',
});
} catch (error) {
console.error('Logout all error:', error);
} finally {
this.clearTokens();
window.location.href = '/login';
}
}
}
// 使用示例
const tokenManager = new TokenManager();
// OAuth回调处理
function handleOAuthCallback() {
const params = new URLSearchParams(window.location.search);
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
if (accessToken && refreshToken) {
tokenManager.setTokens(accessToken, refreshToken);
// 重定向到应用主页
window.location.href = '/dashboard';
} else {
// 处理旧版回调
const legacyToken = params.get('token');
if (legacyToken) {
tokenManager.setTokens(legacyToken, null);
window.location.href = '/dashboard';
}
}
}
// API调用示例
async function getUserProfile() {
try {
const response = await tokenManager.request('/api/accounts/profile');
const data = await response.json();
return data;
} catch (error) {
console.error('Failed to get user profile:', error);
throw error;
}
}
```
### React Hook
```jsx
import { useState, useCallback, useEffect } from 'react';
export function useAuth() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const tokenManager = new TokenManager();
const checkAuth = useCallback(async () => {
if (!tokenManager.accessToken) {
setIsAuthenticated(false);
setLoading(false);
return;
}
try {
const response = await tokenManager.request('/api/accounts/profile');
const data = await response.json();
if (data.success) {
setUser(data.data);
setIsAuthenticated(true);
} else {
setIsAuthenticated(false);
}
} catch (error) {
setIsAuthenticated(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
checkAuth();
}, [checkAuth]);
const login = useCallback((accessToken, refreshToken) => {
tokenManager.setTokens(accessToken, refreshToken);
setIsAuthenticated(true);
checkAuth();
}, [checkAuth]);
const logout = useCallback(async () => {
await tokenManager.logout();
setIsAuthenticated(false);
setUser(null);
}, []);
return {
isAuthenticated,
user,
loading,
login,
logout,
tokenManager,
};
}
```
## 安全考虑
### 1. Token存储
- **Access Token**: 可以存储在内存或localStorage中
- **Refresh Token**: 建议存储在httpOnly cookie中需要后端支持或者安全的本地存储
### 2. HTTPS
- 生产环境必须使用HTTPS传输令牌
### 3. Token轮换
- 系统支持令牌版本控制,可以快速失效所有令牌
### 4. 过期时间
- Access Token短期有效15分钟
- Refresh Token长期有效7天
- 可根据安全需求调整
## 迁移指南
### 从旧版JWT系统迁移
1. **前端更新**
- 更新OAuth回调处理逻辑
- 实现Token刷新机制
- 处理新的响应头
2. **向后兼容**
- 旧版JWT token仍然有效
- 系统会在响应中标记`isLegacyToken: true`
- 建议用户重新登录获取新令牌
3. **数据库迁移**
```bash
# 运行Prisma迁移
npm run prisma migrate dev --name add_refresh_token_support
```
## 错误处理
### 常见错误
| 错误代码 | 错误信息 | 处理方式 |
|---------|---------|---------|
| 401 | JWT token已过期 | 使用refresh token刷新 |
| 401 | 无效的刷新令牌 | 重新登录 |
| 401 | 令牌版本不匹配 | 重新登录 |
| 401 | 账户不存在 | 重新登录 |
### 错误处理流程
```mermaid
graph TD
A[API请求] --> B{Token有效?}
B -->|是| C[返回数据]
B -->|否,401错误| D{有Refresh Token?}
D -->|否| E[重定向登录]
D -->|是| F[尝试刷新Token]
F --> G{刷新成功?}
G -->|是| H[重试原请求]
G -->|否| E
H --> C
```
## 监控和日志
### 建议监控指标
- Token刷新频率
- Token刷新失败率
- 用户登出频率
- 异常登录尝试
### 日志记录
系统会记录以下事件:
- Token生成
- Token刷新
- Token撤销
- 认证失败
## 性能优化
### 1. Token缓存
- 在内存中缓存已验证的token适用于高并发场景
### 2. 数据库优化
- 为`refreshToken`字段添加索引
- 定期清理过期的refresh token
### 3. 前端优化
- 实现Token预刷新机制
- 使用Web Workers处理Token逻辑

View File

@ -1,112 +0,0 @@
# Refresh Token系统 - 快速使用指南
## 🚀 快速开始
### 1. 环境变量配置
```bash
# 添加到 .env 文件
ACCESS_TOKEN_EXPIRES_IN=15m
REFRESH_TOKEN_EXPIRES_IN=7d
REFRESH_TOKEN_SECRET=your-refresh-token-secret-change-this
```
### 2. 数据库迁移
```bash
npx prisma migrate dev --name add_refresh_token_system
```
### 3. 新的OAuth回调参数
登录成功后回调URL现在包含
```
?access_token=eyJ...&refresh_token=eyJ...&expires_in=15m&success=true
```
## 📝 核心API
### 刷新Token
```http
POST /api/accounts/refresh
Content-Type: application/json
{
"refresh_token": "eyJ..."
}
```
### 登出当前设备
```http
POST /api/accounts/logout
Authorization: Bearer <access_token>
```
### 登出所有设备
```http
POST /api/accounts/logout-all
Authorization: Bearer <access_token>
```
## 💻 前端集成
### 基础Token管理
```javascript
class TokenManager {
setTokens(accessToken, refreshToken) {
localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', refreshToken);
}
async refreshToken() {
const refreshToken = localStorage.getItem('refresh_token');
const response = await fetch('/api/accounts/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken })
});
const data = await response.json();
if (data.success) {
localStorage.setItem('access_token', data.data.access_token);
return data.data.access_token;
}
throw new Error(data.message);
}
}
```
### 自动刷新拦截器
```javascript
// 检查响应头中的新token
const newToken = response.headers.get('X-New-Access-Token');
if (newToken) {
localStorage.setItem('access_token', newToken);
}
// 401错误时自动刷新
if (response.status === 401) {
await tokenManager.refreshToken();
// 重试请求
}
```
## 🔒 安全特性
- ✅ 短期Access Token15分钟
- ✅ 长期Refresh Token7天
- ✅ Token版本控制
- ✅ 设备级登出
- ✅ 全局登出
- ✅ 自动刷新机制
- ✅ 向后兼容
## 🔄 迁移步骤
1. **更新环境变量**
2. **运行数据库迁移**
3. **更新前端OAuth回调处理**
4. **实现Token刷新逻辑**
5. **测试登出功能**
详细文档请参考:`REFRESH_TOKEN_API.md`

View File

@ -1,174 +0,0 @@
# 账户登录密钥系统重构完成报告
## 📋 项目概述
已成功重构ClassworksKV的账户登录密钥系统从单一JWT令牌升级为标准的Refresh Token系统大幅提升了安全性和用户体验。
## ✅ 完成的工作
### 1. 数据库架构更新
- 在`Account`模型中添加了`refreshToken``refreshTokenExpiry``tokenVersion`字段
- 支持令牌版本控制,可快速失效所有设备的令牌
- 向后兼容现有数据
### 2. 核心Token管理系统
- **创建 `utils/tokenManager.js`**: 全新的令牌管理核心
- 生成Access Token15分钟有效期
- 生成Refresh Token7天有效期
- 支持HS256和RS256算法
- 令牌刷新和撤销功能
- 安全验证机制
- **重构 `utils/jwt.js`**: 保持向后兼容性
- 重新导出新的令牌管理功能
- 保留旧版API供现有代码使用
### 3. 认证中间件升级
- **更新 `middleware/jwt-auth.js`**:
- 支持新的Access Token验证
- 自动检测即将过期的令牌并在响应头提供新令牌
- 向后兼容旧版JWT令牌
- 新增可选认证中间件
### 4. API端点扩展
- **更新 `routes/accounts.js`**:
- OAuth回调现在返回令牌对access_token + refresh_token
- 新增 `/api/accounts/refresh` - 刷新访问令牌
- 新增 `/api/accounts/logout` - 单设备登出
- 新增 `/api/accounts/logout-all` - 全设备登出
- 新增 `/api/accounts/token-info` - 查看令牌状态
### 5. 安全特性
- **短期Access Token**: 默认15分钟降低泄露风险
- **长期Refresh Token**: 默认7天用户体验友好
- **令牌版本控制**: 支持立即失效所有设备的令牌
- **自动刷新机制**: 在令牌即将过期时自动提供新令牌
- **设备级管理**: 支持单设备或全设备登出
## 📚 文档输出
### 1. 详细API文档
**文件**: `REFRESH_TOKEN_API.md`
- 完整的API接口说明
- 前端集成示例JavaScript/React
- 安全考虑和最佳实践
- 错误处理指南
- 性能优化建议
### 2. 快速使用指南
**文件**: `REFRESH_TOKEN_QUICKSTART.md`
- 环境配置说明
- 核心API使用方法
- 前端集成代码示例
- 迁移步骤指导
## 🔧 配置说明
### 环境变量
```bash
# Access Token配置
ACCESS_TOKEN_EXPIRES_IN=15m # 访问令牌过期时间
REFRESH_TOKEN_EXPIRES_IN=7d # 刷新令牌过期时间
# 密钥配置
JWT_SECRET=your-access-token-secret # Access Token密钥
REFRESH_TOKEN_SECRET=your-refresh-token-secret # Refresh Token密钥
# 可选RSA算法配置
JWT_ALG=RS256
ACCESS_TOKEN_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----..."
ACCESS_TOKEN_PUBLIC_KEY="-----BEGIN RSA PUBLIC KEY-----..."
REFRESH_TOKEN_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----..."
REFRESH_TOKEN_PUBLIC_KEY="-----BEGIN RSA PUBLIC KEY-----..."
```
## 🚀 部署步骤
### 1. 数据库迁移
```bash
npx prisma migrate dev --name add_refresh_token_system
```
### 2. 环境变量更新
```bash
# 添加新的环境变量到 .env 文件
echo "ACCESS_TOKEN_EXPIRES_IN=15m" >> .env
echo "REFRESH_TOKEN_EXPIRES_IN=7d" >> .env
echo "REFRESH_TOKEN_SECRET=your-refresh-token-secret-change-this" >> .env
```
### 3. 前端更新
- 更新OAuth回调处理逻辑
- 实现Token刷新机制
- 添加自动重试逻辑
## 🔄 向后兼容性
- ✅ 现有JWT令牌继续有效
- ✅ 旧版API端点保持不变
- ✅ 渐进式迁移支持
- ✅ 中间件自动检测令牌类型
## 📊 系统架构
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ 前端应用 │ │ ClassworksKV │ │ 数据库 │
│ │ │ 服务端 │ │ │
├─────────────────┤ ├──────────────────┤ ├─────────────────┤
│ • Token存储 │◄──►│ • OAuth认证 │◄──►│ • Account表 │
│ • 自动刷新 │ │ • Token生成 │ │ • refreshToken │
│ • 请求拦截 │ │ • Token验证 │ │ • tokenVersion │
│ • 错误处理 │ │ • Token刷新 │ │ • 过期时间 │
└─────────────────┘ └──────────────────┘ └─────────────────┘
```
## 🛡️ 安全增强
### 改进前(旧系统)
- 单一JWT令牌
- 长期有效7天
- 泄露风险高
- 无法远程登出
### 改进后(新系统)
- 双令牌系统
- Access Token短期15分钟
- Refresh Token长期7天
- 令牌版本控制
- 设备级管理
- 自动刷新机制
## 📈 性能考虑
- **数据库**: 为refreshToken字段添加索引
- **内存**: Token缓存机制可选
- **网络**: 预刷新机制减少延迟
- **存储**: 定期清理过期令牌
## 🧪 测试建议
### 功能测试
1. OAuth登录流程测试
2. Token刷新功能测试
3. 登出功能测试
4. 过期处理测试
### 安全测试
1. 令牌篡改测试
2. 过期令牌测试
3. 并发刷新测试
4. 版本不匹配测试
## 📞 后续支持
- 监控令牌刷新频率
- 分析用户登录模式
- 优化过期时间配置
- 收集用户反馈
---
**重构完成时间**: 2025年11月1日
**文档版本**: v1.0
**兼容性**: 向后兼容,支持渐进式迁移

64
app.js
View File

@ -1,13 +1,19 @@
import "./utils/instrumentation.js";
// import createError from "http-errors";
import express from "express";
import {dirname, join} from "path";
import {fileURLToPath} from "url";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
// import cookieParser from "cookie-parser";
import logger from "morgan";
import bodyParser from "body-parser";
import errorHandler from "./middleware/errorHandler.js";
import errors from "./utils/errors.js";
import {
globalLimiter,
apiLimiter,
methodBasedRateLimiter,
tokenBasedRateLimiter,
} from "./middleware/rateLimiter.js";
import kvRouter from "./routes/kv-token.js";
import appsRouter from "./routes/apps.js";
@ -15,20 +21,15 @@ import deviceRouter from "./routes/device.js";
import deviceAuthRouter from "./routes/device-auth.js";
import accountsRouter from "./routes/accounts.js";
import autoAuthRouter from "./routes/auto-auth.js";
import {register} from "./utils/metrics.js";
import cors from "cors";
var app = express();
import cors from "cors";
app.options("/{*path}", cors());
app.use(
cors({
exposedHeaders: ["ratelimit-policy", "retry-after", "ratelimit"], // 告诉浏览器这些响应头可以暴露
maxAge: 86400, // 设置OPTIONS请求的结果缓存24小时(86400秒),减少预检请求
credentials: true, // 允许跨域请求携带凭证
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With", "Accept"], // 允许的请求头
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], // 允许的HTTP方法
withCredentials: true, // 允许携带cookie等凭证信息
})
);
app.disable("x-powered-by");
@ -37,14 +38,17 @@ app.disable("x-powered-by");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 应用全局限速
app.use(globalLimiter);
// view engine setup
app.set("views", join(__dirname, "views"));
app.set("view engine", "ejs");
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({extended: false}));
app.use(express.urlencoded({ extended: false }));
// app.use(cookieParser());
app.use(express.static(join(__dirname, "public")));
@ -73,53 +77,31 @@ app.use((req, res, next) => {
app.get("/", (req, res) => {
res.render("index.ejs");
});
app.get("/check", (req, res) => {
app.get("/check", apiLimiter, (req, res) => {
res.json({
status: "success",
message: "Classworks KV is running",
message: "API is running",
time: new Date().getTime(),
});
});
// Prometheus metrics endpoint with token auth
app.get("/metrics", async (req, res) => {
try {
// 检查 token 验证
const metricsToken = process.env.METRICS_TOKEN;
if (metricsToken) {
const providedToken = req.headers.authorization?.replace('Bearer ', '') || req.query.token;
if (!providedToken || providedToken !== metricsToken) {
return res.status(401).json({
error: "Unauthorized",
message: "Valid metrics token required"
});
}
}
res.set("Content-Type", register.contentType);
res.end(await register.metrics());
} catch (err) {
res.status(500).end(err.message);
}
});
// Mount the Apps router with API rate limiting
app.use("/apps", appsRouter);
app.use("/apps", apiLimiter, appsRouter);
// Mount the Auto Auth router with API rate limiting
app.use("/auto-auth", autoAuthRouter);
app.use("/auto-auth", apiLimiter, autoAuthRouter);
// Mount the Device router with API rate limiting
app.use("/devices", deviceRouter);
app.use("/devices", apiLimiter, deviceRouter);
// Mount the KV store router
app.use("/kv", kvRouter);
// Mount the KV store router with token-based rate limiting (更宽松的限速)
app.use("/kv", tokenBasedRateLimiter, kvRouter);
// Mount the Device Authorization router with API rate limiting
app.use("/auth", deviceAuthRouter);
app.use("/auth", apiLimiter, deviceAuthRouter);
// Mount the Accounts router with API rate limiting
app.use("/accounts", accountsRouter);
app.use("/accounts", apiLimiter, accountsRouter);
// 兜底404路由 - 处理所有未匹配的路由
app.use((req, res, next) => {

View File

@ -5,9 +5,8 @@
*/
import app from '../app.js';
import {createServer} from 'http';
import {initSocket} from '../utils/socket.js';
import {initializeMetrics} from '../utils/metrics.js';
import { createServer } from 'http';
import { initSocket } from '../utils/socket.js';
/**
* Get port from environment and store in Express.
@ -25,9 +24,6 @@ var server = createServer(app);
// 初始化 Socket.IO 并绑定到 HTTP Server
initSocket(server);
// 初始化 Prometheus 指标
initializeMetrics();
/**
* Listen on provided port, on all network interfaces.
*/

View File

@ -1,5 +1,5 @@
#!/usr/bin/env node
import {execSync} from "child_process";
import { execSync } from "child_process";
import dotenv from "dotenv";
dotenv.config();
@ -9,7 +9,7 @@ dotenv.config();
function runDatabaseMigration() {
try {
console.log("🔄 执行数据库迁移...");
execSync("npx prisma migrate deploy", {stdio: "inherit"});
execSync("npx prisma migrate deploy", { stdio: "inherit" });
console.log("✅ 数据库迁移完成");
} catch (error) {
console.error("❌ 数据库迁移失败:", error.message);
@ -33,8 +33,8 @@ function buildLocal() {
try {
// 确保数据库迁移已执行
runDatabaseMigration();
execSync("npm install", {stdio: "inherit"}); // 安装依赖
execSync("npx prisma generate", {stdio: "inherit"}); // 生成 Prisma 客户端
execSync("npm install", { stdio: "inherit" }); // 安装依赖
execSync("npx prisma generate", { stdio: "inherit" }); // 生成 Prisma 客户端
console.log("✅ 构建完成");
} catch (error) {
console.error("❌ 构建失败:", error.message);
@ -45,7 +45,7 @@ function buildLocal() {
// 🚀 启动服务函数
function startServer() {
try {
execSync("npm run start", {stdio: "inherit"}); // 启动项目
execSync("npm run start", { stdio: "inherit" }); // 启动项目
} catch (error) {
console.error("❌ 服务启动失败:", error.message);
process.exit(1);
@ -56,7 +56,7 @@ function startServer() {
function runPrismaCommand(args) {
try {
const command = `npx prisma ${args.join(" ")}`;
execSync(command, {stdio: "inherit"});
execSync(command, { stdio: "inherit" });
} catch (error) {
console.error("❌ Prisma 命令执行失败:", error.message);
process.exit(1);

View File

@ -13,7 +13,7 @@
import http from 'http';
import url from 'url';
import {randomBytes} from 'crypto';
import { randomBytes } from 'crypto';
// 配置
const CONFIG = {
@ -128,11 +128,11 @@ function createCallbackServer(state) {
const parsedUrl = url.parse(req.url, true);
if (parsedUrl.pathname === CONFIG.callbackPath) {
const {token, error, state: returnedState} = parsedUrl.query;
const { token, error, state: returnedState } = parsedUrl.query;
// 验证状态参数
if (returnedState !== state) {
res.writeHead(400, {'Content-Type': 'text/html; charset=utf-8'});
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<!DOCTYPE html>
<html>
@ -158,7 +158,7 @@ function createCallbackServer(state) {
}
if (error) {
res.writeHead(400, {'Content-Type': 'text/html; charset=utf-8'});
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<!DOCTYPE html>
<html>
@ -184,7 +184,7 @@ function createCallbackServer(state) {
}
if (token) {
res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<!DOCTYPE html>
<html>
@ -212,7 +212,7 @@ function createCallbackServer(state) {
}
// 如果没有token和error参数
res.writeHead(400, {'Content-Type': 'text/html; charset=utf-8'});
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<!DOCTYPE html>
<html>
@ -236,7 +236,7 @@ function createCallbackServer(state) {
reject(new Error('缺少必要的参数'));
} else {
// 404 for other paths
res.writeHead(404, {'Content-Type': 'text/plain'});
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
};
@ -271,7 +271,7 @@ function createCallbackServer(state) {
// 打开浏览器
async function openBrowser(url) {
const {spawn} = await import('child_process');
const { spawn } = await import('child_process');
let command;
let args;
@ -288,7 +288,7 @@ async function openBrowser(url) {
}
try {
spawn(command, args, {detached: true, stdio: 'ignore'});
spawn(command, args, { detached: true, stdio: 'ignore' });
logSuccess('已尝试打开浏览器');
} catch (error) {
logWarning('无法自动打开浏览器,请手动打开授权链接');
@ -323,7 +323,7 @@ async function saveToken(token) {
try {
// 确保目录存在
if (!fs.existsSync(tokenDir)) {
fs.mkdirSync(tokenDir, {recursive: true});
fs.mkdirSync(tokenDir, { recursive: true });
}
// 写入令牌

View File

@ -10,6 +10,8 @@
* 或配置为可执行chmod +x cli/get-token.js && ./cli/get-token.js
*/
import readline from 'readline';
// 配置
const CONFIG = {
// API服务器地址
@ -164,7 +166,7 @@ async function saveToken(token) {
try {
// 确保目录存在
if (!fs.existsSync(tokenDir)) {
fs.mkdirSync(tokenDir, {recursive: true});
fs.mkdirSync(tokenDir, { recursive: true });
}
// 写入令牌
@ -189,7 +191,7 @@ async function main() {
}
// 1. 生成设备代码
const {device_code, expires_in} = await generateDeviceCode();
const { device_code, expires_in } = await generateDeviceCode();
logSuccess('设备授权码生成成功!');
// 2. 显示设备代码和授权链接

View File

@ -67,24 +67,6 @@ export const oauthProviders = {
website: "https://houlang.cloud",
pkce: true, // 启用PKCE支持
},
dlass: {
// DlassCasdoor- 标准 OIDC Provider
clientId: process.env.DLASS_CLIENT_ID,
clientSecret: process.env.DLASS_CLIENT_SECRET,
// Casdoor 标准端点
authorizationURL: "https://auth.wiki.forum/login/oauth/authorize",
tokenURL: "https://auth.wiki.forum/api/login/oauth/access_token",
userInfoURL: "https://auth.wiki.forum/api/userinfo",
scope: "openid profile email offline_access",
// 展示相关
name: "dlass",
displayName: "Dlass 账户",
icon: "casdoor",
color: "#3498db",
description: "使用Dlass账户登录",
website: "https://dlass.tech",
tokenRequestFormat: "json", // Casdoor 推荐 JSON 提交
},
};
// 获取OAuth回调URL

View File

@ -7,39 +7,17 @@
* 3. passwordMiddleware - 验证设备密码
*/
import {PrismaClient} from "@prisma/client";
import { PrismaClient } from "@prisma/client";
import errors from "../utils/errors.js";
import {verifyDevicePassword} from "../utils/crypto.js";
import {analyzeDevice} from "../utils/deviceDetector.js";
import { verifyDevicePassword } from "../utils/crypto.js";
const prisma = new PrismaClient();
/**
* 为新设备创建默认的自动登录配置
* @param {number} deviceId - 设备ID
*/
async function createDefaultAutoAuth(deviceId) {
try {
// 创建默认的自动授权配置不需要密码、类型是classroom一体机
await prisma.autoAuth.create({
data: {
deviceId: deviceId,
password: null, // 无密码
deviceType: "classroom", // 一体机类型
isReadOnly: false, // 非只读
},
});
} catch (error) {
console.error('创建默认自动登录配置失败:', error);
// 这里不抛出错误,避免影响设备创建流程
}
}
/**
* 设备中间件 - 统一处理设备UUID
*
* 从req.params.deviceUuid或req.body.deviceUuid获取UUID
* 如果设备不存在则自动创建并智能生成设备名称
* 如果设备不存在则自动创建
* 将设备信息存储到res.locals.device
*
* 使用方式
@ -55,33 +33,20 @@ export const deviceMiddleware = errors.catchAsync(async (req, res, next) => {
// 查找或创建设备
let device = await prisma.device.findUnique({
where: {uuid: deviceUuid},
where: { uuid: deviceUuid },
});
if (!device) {
// 设备不存在,自动创建并生成智能设备名称
const userAgent = req.headers['user-agent'];
const customDeviceType = req.body.deviceType || req.query.deviceType;
const note = req.body.note || req.query.note;
// 生成设备名称,确保不为空
const deviceName = analyzeDevice(userAgent, req.headers, customDeviceType, note).generatedName;
// 设备不存在,自动创建
device = await prisma.device.create({
data: {
uuid: deviceUuid,
name: deviceName,
name: null,
password: null,
passwordHint: null,
accountId: null,
},
});
// 为新创建的设备添加默认的自动登录配置
await createDefaultAutoAuth(device.id);
// 将设备分析结果添加到响应中
res.locals.deviceAnalysis = deviceAnalysis;
}
// 将设备信息存储到res.locals
@ -100,7 +65,7 @@ export const deviceMiddleware = errors.catchAsync(async (req, res, next) => {
* router.get('/path/:deviceUuid', deviceInfoMiddleware, handler)
*/
export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) => {
const deviceUuid = req.params.deviceUuid;
const deviceUuid = req.params.deviceUuid ;
if (!deviceUuid) {
return next(errors.createError(400, "缺少设备UUID"));
@ -108,7 +73,7 @@ export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) =>
// 查找设备
const device = await prisma.device.findUnique({
where: {uuid: deviceUuid},
where: { uuid: deviceUuid },
});
if (!device) {
@ -134,7 +99,7 @@ export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) =>
*/
export const passwordMiddleware = errors.catchAsync(async (req, res, next) => {
const device = res.locals.device;
const {password} = req.body;
const { password } = req.body;
if (!device) {
return next(errors.createError(500, "设备信息未加载请先使用deviceMiddleware"));

View File

@ -1,4 +1,4 @@
import {isDevelopment} from "../utils/config.js";
import { isDevelopment } from "../utils/config.js";
const errorHandler = (err, req, res, next) => {
// 判断响应是否已经发送
@ -17,13 +17,11 @@ const errorHandler = (err, req, res, next) => {
const statusCode = err.statusCode || err.status || 500;
const message = err.message || "服务器错误";
const details = err.details || null;
const code = err.code || undefined;
// 返回统一格式的错误响应
return res.status(statusCode).json({
success: false,
message: message,
code: code,
details: details,
error:
process.env.NODE_ENV === "production"

View File

@ -1,20 +1,19 @@
/**
* 纯账户JWT认证中间件
*
* 支持新的refresh token系统验证access token
* 如果access token即将过期会在响应头中提供新的token
* 只验证账户JWT是否正确不需要设备上下文
* 适用于只需要账户验证的接口
*/
import {generateAccessToken, validateAccountToken, verifyAccessToken} from "../utils/tokenManager.js";
import {verifyToken} from "../utils/jwt.js";
import {PrismaClient} from "@prisma/client";
import { verifyToken } from "../utils/jwt.js";
import { PrismaClient } from "@prisma/client";
import errors from "../utils/errors.js";
const prisma = new PrismaClient();
/**
* 新的JWT认证中间件支持refresh token系统
* 纯JWT认证中间件
* 只验证Bearer token并将账户信息存储到res.locals
*/
export const jwtAuth = async (req, res, next) => {
try {
@ -26,37 +25,12 @@ export const jwtAuth = async (req, res, next) => {
const token = authHeader.substring(7);
try {
// 尝试使用新的token验证系统
const decoded = verifyAccessToken(token);
// 验证账户并检查token版本
const account = await validateAccountToken(decoded);
// 将账户信息存储到res.locals
res.locals.account = account;
res.locals.tokenDecoded = decoded;
// 检查token是否即将过期剩余时间少于5分钟
const now = Math.floor(Date.now() / 1000);
const timeUntilExpiry = decoded.exp - now;
if (timeUntilExpiry < 300) { // 5分钟 = 300秒
// 生成新的access token
const newAccessToken = generateAccessToken(account);
res.set('X-New-Access-Token', newAccessToken);
res.set('X-Token-Refreshed', 'true');
}
next();
} catch (newTokenError) {
// 如果新token系统验证失败尝试旧的验证方式向后兼容
try {
// 验证JWT token
const decoded = verifyToken(token);
// 从数据库获取账户信息
const account = await prisma.account.findUnique({
where: {id: decoded.accountId},
where: { id: decoded.accountId },
});
if (!account) {
@ -65,43 +39,16 @@ export const jwtAuth = async (req, res, next) => {
// 将账户信息存储到res.locals
res.locals.account = account;
res.locals.tokenDecoded = decoded;
res.locals.isLegacyToken = true; // 标记为旧版token
next();
} catch (legacyTokenError) {
// 两种验证方式都失败
if (newTokenError.name === 'JsonWebTokenError' || legacyTokenError.name === 'JsonWebTokenError') {
} catch (error) {
if (error.name === 'JsonWebTokenError') {
return next(errors.createError(401, "无效的JWT token"));
}
if (newTokenError.name === 'TokenExpiredError' || legacyTokenError.name === 'TokenExpiredError') {
// 统一的账户JWT过期返回
// message: JWT_EXPIRED用于客户端稳定识别
// code: AUTH_JWT_EXPIRED业务错误码
return next(errors.createError(401, "JWT_EXPIRED", null, "AUTH_JWT_EXPIRED"));
if (error.name === 'TokenExpiredError') {
return next(errors.createError(401, "JWT token已过期"));
}
return next(errors.createError(401, "token验证失败"));
}
}
} catch (error) {
return next(errors.createError(500, "认证过程出错"));
}
};
/**
* 可选的JWT认证中间件
* 如果提供了token则验证没有提供则跳过
*/
export const optionalJwtAuth = async (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
// 没有提供token跳过认证
return next();
}
// 有token则进行验证
return jwtAuth(req, res, next);
};

View File

@ -5,7 +5,7 @@
* 适用于所有KV相关的接口
*/
import {PrismaClient} from "@prisma/client";
import { PrismaClient } from "@prisma/client";
import errors from "../utils/errors.js";
const prisma = new PrismaClient();
@ -25,7 +25,7 @@ export const kvTokenAuth = async (req, res, next) => {
// 查找token对应的应用安装信息
const appInstall = await prisma.appInstall.findUnique({
where: {token},
where: { token },
include: {
device: true,
},

View File

@ -30,31 +30,53 @@ export const getRateLimitKey = (req) => {
return `ip:${getClientIp(req)}`;
};
// 纯基于Token的keyGenerator用于KV Token专用路由
// 这个函数假设token已经通过中间件设置在req对象上
export const getTokenOnlyKey = (req) => {
// 尝试从多个位置获取token
const token =
req.locals?.token || // 如果token被设置在req.locals中
req.res?.locals?.token || // 如果token在res.locals中
extractToken(req); // 从headers/query/body提取
// 配置全局限速中间件
export const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
limit: 200, // 每个IP在windowMs时间内最多允许200个请求
standardHeaders: "draft-7", // 返回标准的RateLimit头信息
legacyHeaders: false, // 禁用X-RateLimit-*头
message: "请求过于频繁,请稍后再试",
keyGenerator: getClientIp, // 使用真实IP作为限速键
skipSuccessfulRequests: false, // 成功的请求也计入限制
skipFailedRequests: false, // 失败的请求也计入限制
});
if (!token) {
// 如果没有token返回一个特殊键用于统一限制
return "no-token";
}
return `token:${token}`;
};
// API限速器
export const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟
limit: 100, // 每个IP在windowMs时间内最多允许100个请求
standardHeaders: "draft-7",
legacyHeaders: false,
message: "API请求过于频繁请稍后再试",
keyGenerator: getClientIp,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
// 创建一个中间件来将res.locals.token复制到req.locals.token以便限速器使用
export const prepareTokenForRateLimit = (req, res, next) => {
if (res.locals.token) {
req.locals = req.locals || {};
req.locals.token = res.locals.token;
}
next();
};
// 写操作限速器(更严格)
export const writeLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟
limit: 20, // 每个IP在windowMs时间内最多允许20个写操作
standardHeaders: "draft-7",
legacyHeaders: false,
message: "写操作请求过于频繁,请稍后再试",
keyGenerator: getClientIp,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
// 删除操作限速器(最严格)
export const deleteLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 5分钟
limit: 10, // 每个IP在windowMs时间内最多允许10个删除操作
standardHeaders: "draft-7",
legacyHeaders: false,
message: "删除操作请求过于频繁,请稍后再试",
keyGenerator: getClientIp,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
// 认证相关路由限速器(防止暴力破解)
export const authLimiter = rateLimit({
@ -68,7 +90,19 @@ export const authLimiter = rateLimit({
skipFailedRequests: false, // 失败的认证计入限制
});
// === Token 专用限速器更宽松的限制纯基于Token ===
// 批量操作限速器(比写操作更严格)
export const batchLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 5分钟
limit: 10, // 每个IP在windowMs时间内最多允许10个批量操作
standardHeaders: "draft-7",
legacyHeaders: false,
message: "批量操作请求过于频繁,请稍后再试",
keyGenerator: getClientIp,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
// === Token 专用限速器(更宽松的限制) ===
// Token 读操作限速器
export const tokenReadLimiter = rateLimit({
@ -77,7 +111,7 @@ export const tokenReadLimiter = rateLimit({
standardHeaders: "draft-7",
legacyHeaders: false,
message: "读操作请求过于频繁,请稍后再试",
keyGenerator: getTokenOnlyKey,
keyGenerator: getRateLimitKey,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
@ -89,7 +123,7 @@ export const tokenWriteLimiter = rateLimit({
standardHeaders: "draft-7",
legacyHeaders: false,
message: "写操作请求过于频繁,请稍后再试",
keyGenerator: getTokenOnlyKey,
keyGenerator: getRateLimitKey,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
@ -101,7 +135,7 @@ export const tokenDeleteLimiter = rateLimit({
standardHeaders: "draft-7",
legacyHeaders: false,
message: "删除操作请求过于频繁,请稍后再试",
keyGenerator: getTokenOnlyKey,
keyGenerator: getRateLimitKey,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
@ -113,7 +147,53 @@ export const tokenBatchLimiter = rateLimit({
standardHeaders: "draft-7",
legacyHeaders: false,
message: "批量操作请求过于频繁,请稍后再试",
keyGenerator: getTokenOnlyKey,
keyGenerator: getRateLimitKey,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
// 创建一个路由处理中间件根据HTTP方法应用不同的限速器
export const methodBasedRateLimiter = (req, res, next) => {
// 检查是否是批量导入路由
if (req.method === "POST" && req.path.endsWith("/batch-import")) {
return batchLimiter(req, res, next);
} else if (req.method === "GET") {
// 读操作使用普通API限速
return apiLimiter(req, res, next);
} else if (
req.method === "POST" ||
req.method === "PUT" ||
req.method === "PATCH"
) {
// 写操作使用更严格的限速
return writeLimiter(req, res, next);
} else if (req.method === "DELETE") {
// 删除操作使用最严格的限速
return deleteLimiter(req, res, next);
}
// 其他方法使用API限速
return apiLimiter(req, res, next);
};
// Token 专用路由中间件根据HTTP方法应用不同的Token限速器
export const tokenBasedRateLimiter = (req, res, next) => {
// 检查是否是批量导入路由
if (req.method === "POST" && (req.path.endsWith("/_batchimport") || req.path.endsWith("/batch-import"))) {
return tokenBatchLimiter(req, res, next);
} else if (req.method === "GET") {
// 读操作使用Token读限速
return tokenReadLimiter(req, res, next);
} else if (
req.method === "POST" ||
req.method === "PUT" ||
req.method === "PATCH"
) {
// 写操作使用Token写限速
return tokenWriteLimiter(req, res, next);
} else if (req.method === "DELETE") {
// 删除操作使用Token删除限速
return tokenDeleteLimiter(req, res, next);
}
// 其他方法使用Token读限速
return tokenReadLimiter(req, res, next);
};

View File

@ -6,10 +6,10 @@
* 3. 适用于需要设备上下文的接口
*/
import {PrismaClient} from "@prisma/client";
import { PrismaClient } from "@prisma/client";
import errors from "../utils/errors.js";
import {verifyToken as verifyAccountJWT} from "../utils/jwt.js";
import {verifyDevicePassword} from "../utils/crypto.js";
import { verifyToken as verifyAccountJWT } from "../utils/jwt.js";
import { verifyDevicePassword } from "../utils/crypto.js";
const prisma = new PrismaClient();
@ -26,7 +26,7 @@ export const uuidAuth = async (req, res, next) => {
// 2. 查找设备并存储到locals
const device = await prisma.device.findUnique({
where: {uuid},
where: { uuid },
});
if (!device) {
@ -46,11 +46,11 @@ export const uuidAuth = async (req, res, next) => {
try {
const accountPayload = await verifyAccountJWT(jwt);
const account = await prisma.account.findUnique({
where: {id: accountPayload.accountId},
where: { id: accountPayload.accountId },
include: {
devices: {
where: {uuid},
select: {id: true}
where: { uuid },
select: { id: true }
}
}
});
@ -93,22 +93,6 @@ export const uuidAuth = async (req, res, next) => {
next(error);
}
};
export const extractDeviceInfo = async (req, res, next) => {
var uuid = extractUuid(req);
if (!uuid) {
throw errors.createError(400, "需要提供设备UUID");
}
const device = await prisma.device.findUnique({
where: {uuid},
});
if (!device) {
throw errors.createError(404, "设备不存在");
}
res.locals.device = device;
res.locals.deviceId = device.id;
next();
}
/**
* 从请求中提取UUID

9032
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "ClassworksKV",
"version": "1.3.8",
"version": "1.2.0",
"private": true,
"scripts": {
"start": "node ./bin/www",
@ -30,8 +30,6 @@
"js-base64": "^3.7.7",
"jsonwebtoken": "^9.0.2",
"morgan": "~1.10.0",
"node-device-detector": "^2.2.4",
"prom-client": "^15.1.3",
"socket.io": "^4.8.1",
"uuid": "^11.1.0"
},

33
pnpm-lock.yaml generated
View File

@ -71,12 +71,6 @@ importers:
morgan:
specifier: ~1.10.0
version: 1.10.0
node-device-detector:
specifier: ^2.2.4
version: 2.2.4
prom-client:
specifier: ^15.1.3
version: 15.1.3
socket.io:
specifier: ^4.8.1
version: 4.8.1
@ -1439,9 +1433,6 @@ packages:
bignumber.js@9.3.0:
resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==}
bintrees@1.0.2:
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
birpc@2.6.1:
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
@ -2100,10 +2091,6 @@ packages:
resolution: {integrity: sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==}
engines: {node: ^18 || ^20 || >= 21}
node-device-detector@2.2.4:
resolution: {integrity: sha512-0nhi8XWLViGKeQyLLlg3bcUGdhTKc56ARAHx6kKWvwy39ITk7BZn5Gy6AmTSX4slM35iQMJaKAIxagR/xXsS+Q==}
engines: {node: '>= 10.x', npm: '>= 6.x'}
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
@ -2225,10 +2212,6 @@ packages:
typescript:
optional: true
prom-client@15.1.3:
resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==}
engines: {node: ^16 || ^18 || >=20}
protobufjs@7.5.4:
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
engines: {node: '>=12.0.0'}
@ -2408,9 +2391,6 @@ packages:
resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==}
engines: {node: '>=18'}
tdigest@0.1.2:
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
tinyexec@1.0.1:
resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==}
@ -4055,8 +4035,6 @@ snapshots:
bignumber.js@9.3.0: {}
bintrees@1.0.2: {}
birpc@2.6.1: {}
body-parser@2.2.0:
@ -4713,8 +4691,6 @@ snapshots:
node-addon-api@8.3.1: {}
node-device-detector@2.2.4: {}
node-fetch-native@1.6.7: {}
node-fetch@2.7.0:
@ -4816,11 +4792,6 @@ snapshots:
transitivePeerDependencies:
- magicast
prom-client@15.1.3:
dependencies:
'@opentelemetry/api': 1.9.0
tdigest: 0.1.2
protobufjs@7.5.4:
dependencies:
'@protobufjs/aspromise': 1.1.2
@ -5096,10 +5067,6 @@ snapshots:
minizlib: 3.1.0
yallist: 5.0.0
tdigest@0.1.2:
dependencies:
bintrees: 1.0.2
tinyexec@1.0.1: {}
tinyglobby@0.2.15:

View File

@ -1,4 +0,0 @@
-- AlterTable
ALTER TABLE `Account` ADD COLUMN `refreshToken` TEXT NULL,
ADD COLUMN `refreshTokenExpiry` DATETIME(3) NULL,
ADD COLUMN `tokenVersion` INTEGER NOT NULL DEFAULT 1;

View File

@ -30,9 +30,6 @@ model Account {
avatarUrl String? // 用户头像URL
providerData Json? // OAuth提供者返回的完整信息
accessToken String? @db.Text // 账户访问令牌
refreshToken String? @db.Text // 刷新令牌
refreshTokenExpiry DateTime? // 刷新令牌过期时间
tokenVersion Int @default(1) // 令牌版本,用于令牌失效
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录失败</title>
<style>
* {
@ -50,15 +50,9 @@
}
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-5px);
}
20%, 40%, 60%, 80% {
transform: translateX(5px);
}
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
20%, 40%, 60%, 80% { transform: translateX(5px); }
}
h1 {
@ -126,10 +120,10 @@
</style>
</head>
<body>
<div class="container">
<div class="container">
<div class="error-icon">
<svg fill="none" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
@ -140,7 +134,7 @@
<div class="error-code" id="errorCode"></div>
</div>
<a class="retry-btn" href="javascript:history.back()">返回重试</a>
<a href="javascript:history.back()" class="retry-btn">返回重试</a>
<button class="close-btn" onclick="window.close()">关闭窗口</button>
<div class="help-text">
@ -149,9 +143,9 @@
• 回调URL是否已添加到OAuth应用中<br>
• 环境变量是否配置正确
</div>
</div>
</div>
<script>
<script>
// 从URL获取错误信息
const params = new URLSearchParams(window.location.search);
const error = params.get('error');
@ -167,6 +161,6 @@
document.getElementById('errorMsg').textContent = errorMsg;
document.getElementById('errorCode').textContent = `错误代码: ${error}`;
}
</script>
</script>
</body>
</html>

View File

@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录成功</title>
<style>
* {
@ -132,10 +132,10 @@
</style>
</head>
<body>
<div class="container">
<div class="container">
<div class="success-icon">
<svg fill="none" viewBox="0 0 24 24">
<path d="M5 13l4 4L19 7" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
@ -152,9 +152,9 @@
<div class="auto-close">
窗口将在 <span class="countdown" id="countdown">10</span> 秒后自动关闭
</div>
</div>
</div>
<script>
<script>
// 从URL获取参数
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
@ -249,6 +249,6 @@
clearInterval(timer);
document.querySelector('.auto-close').style.display = 'none';
});
</script>
</script>
</body>
</html>

View File

@ -1,10 +1,9 @@
import {Router} from "express";
import {PrismaClient} from "@prisma/client";
import { Router } from "express";
import { PrismaClient } from "@prisma/client";
import crypto from "crypto";
import {generateState, getCallbackURL, oauthProviders} from "../config/oauth.js";
import {generateTokenPair, refreshAccessToken, revokeAllTokens, revokeRefreshToken} from "../utils/jwt.js";
import {jwtAuth} from "../middleware/jwt-auth.js";
import errors from "../utils/errors.js";
import { oauthProviders, getCallbackURL, generateState } from "../config/oauth.js";
import { generateAccountToken, verifyToken } from "../utils/jwt.js";
import { jwtAuth } from "../middleware/jwt-auth.js";
const router = Router();
const prisma = new PrismaClient();
@ -27,7 +26,7 @@ function generatePkcePair() {
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return {codeVerifier, codeChallenge: challenge};
return { codeVerifier, codeChallenge: challenge };
}
/**
@ -81,8 +80,8 @@ router.get("/oauth/providers", (req, res) => {
* - redirect_uri: 前端回调地址可选
*/
router.get("/oauth/:provider", (req, res) => {
const {provider} = req.params;
const {redirect_uri} = req.query;
const { provider } = req.params;
const { redirect_uri } = req.query;
const providerConfig = oauthProviders[provider];
if (!providerConfig) {
@ -157,8 +156,8 @@ router.get("/oauth/:provider", (req, res) => {
* GET /accounts/oauth/:provider/callback
*/
router.get("/oauth/:provider/callback", async (req, res) => {
const {provider} = req.params;
const {code, state, error} = req.query;
const { provider } = req.params;
const { code, state, error } = req.query;
// 如果OAuth提供者返回错误
if (error) {
@ -198,12 +197,12 @@ router.get("/oauth/:provider/callback", async (req, res) => {
},
body: JSON.stringify({
client_id: providerConfig.clientId,
...(providerConfig.clientSecret ? {client_secret: providerConfig.clientSecret} : {}),
...(providerConfig.clientSecret ? { client_secret: providerConfig.clientSecret } : {}),
code: code,
grant_type: "authorization_code",
redirect_uri: getCallbackURL(provider),
// PKCE: 携带code_verifier
...(stateData?.codeVerifier ? {code_verifier: stateData.codeVerifier} : {}),
...(stateData?.codeVerifier ? { code_verifier: stateData.codeVerifier } : {}),
}),
});
} else {
@ -215,12 +214,12 @@ router.get("/oauth/:provider/callback", async (req, res) => {
},
body: new URLSearchParams({
client_id: providerConfig.clientId,
...(providerConfig.clientSecret ? {client_secret: providerConfig.clientSecret} : {}),
...(providerConfig.clientSecret ? { client_secret: providerConfig.clientSecret } : {}),
code: code,
grant_type: "authorization_code",
redirect_uri: getCallbackURL(provider),
// PKCE: 携带code_verifier
...(stateData?.codeVerifier ? {code_verifier: stateData.codeVerifier} : {}),
...(stateData?.codeVerifier ? { code_verifier: stateData.codeVerifier } : {}),
}),
});
}
@ -237,7 +236,7 @@ router.get("/oauth/:provider/callback", async (req, res) => {
if (provider === 'stcn') {
const url = new URL(providerConfig.userInfoURL);
url.searchParams.set('accessToken', tokenData.access_token);
userResponse = await fetch(url, {headers: {"Accept": "application/json"}});
userResponse = await fetch(url, { headers: { "Accept": "application/json" } });
} else {
userResponse = await fetch(providerConfig.userInfoURL, {
headers: {
@ -282,14 +281,6 @@ router.get("/oauth/:provider/callback", async (req, res) => {
name: userData.name || userData.preferred_username || userData.nickname,
avatarUrl: userData.picture,
};
} else if (provider === "dlass") {
// DlassCasdoor标准OIDC用户信息
normalizedUser = {
providerId: userData.sub,
email: userData.email_verified ? userData.email : userData.email || null,
name: userData.name || userData.preferred_username || userData.nickname,
avatarUrl: userData.picture,
};
}
// 名称为空时,用邮箱@前部分回填(若邮箱可用)
@ -313,7 +304,7 @@ router.get("/oauth/:provider/callback", async (req, res) => {
if (account) {
// 更新账户信息
account = await prisma.account.update({
where: {id: account.id},
where: { id: account.id },
data: {
email: normalizedUser.email || account.email,
name: normalizedUser.name || account.name,
@ -340,15 +331,13 @@ router.get("/oauth/:provider/callback", async (req, res) => {
});
}
// 5. 生成令牌对(访问令牌 + 刷新令牌)
const tokens = await generateTokenPair(account);
// 5. 生成JWT token
const jwtToken = generateAccountToken(account);
// 6. 重定向到前端根路径携带JWT token
const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
const callbackUrl = new URL(frontendBaseUrl);
callbackUrl.searchParams.append("access_token", tokens.accessToken);
callbackUrl.searchParams.append("refresh_token", tokens.refreshToken);
callbackUrl.searchParams.append("expires_in", tokens.accessTokenExpiresIn);
callbackUrl.searchParams.append("token", jwtToken);
callbackUrl.searchParams.append("provider", provider);
// 附带展示信息,便于前端显示品牌与名称
const pconf = oauthProviders[provider] || {};
@ -386,7 +375,7 @@ router.get("/profile", jwtAuth, async (req, res, next) => {
const accountContext = res.locals.account;
const account = await prisma.account.findUnique({
where: {id: accountContext.id},
where: { id: accountContext.id },
include: {
devices: {
select: {
@ -447,7 +436,7 @@ router.get("/profile", jwtAuth, async (req, res, next) => {
router.post("/devices/bind", jwtAuth, async (req, res, next) => {
try {
const accountContext = res.locals.account;
const {uuid} = req.body;
const { uuid } = req.body;
if (!uuid) {
return res.status(400).json({
@ -458,7 +447,7 @@ router.post("/devices/bind", jwtAuth, async (req, res, next) => {
// 查找设备
const device = await prisma.device.findUnique({
where: {uuid},
where: { uuid },
});
if (!device) {
@ -478,7 +467,7 @@ router.post("/devices/bind", jwtAuth, async (req, res, next) => {
// 绑定设备到账户
const updatedDevice = await prisma.device.update({
where: {uuid},
where: { uuid },
data: {
accountId: accountContext.id,
},
@ -514,7 +503,7 @@ router.post("/devices/bind", jwtAuth, async (req, res, next) => {
router.post("/devices/unbind", jwtAuth, async (req, res, next) => {
try {
const accountContext = res.locals.account;
const {uuid, uuids} = req.body;
const { uuid, uuids } = req.body;
// 支持单个解绑或批量解绑
const uuidsToUnbind = uuids || (uuid ? [uuid] : []);
@ -529,7 +518,7 @@ router.post("/devices/unbind", jwtAuth, async (req, res, next) => {
// 查找所有设备并验证所有权
const devices = await prisma.device.findMany({
where: {
uuid: {in: uuidsToUnbind},
uuid: { in: uuidsToUnbind },
},
});
@ -555,7 +544,7 @@ router.post("/devices/unbind", jwtAuth, async (req, res, next) => {
// 批量解绑设备
await prisma.device.updateMany({
where: {
uuid: {in: uuidsToUnbind},
uuid: { in: uuidsToUnbind },
accountId: accountContext.id,
},
data: {
@ -585,14 +574,13 @@ router.get("/devices", jwtAuth, async (req, res, next) => {
const accountContext = res.locals.account;
// 获取账户的设备列表
const account = await prisma.account.findUnique({
where: {id: accountContext.id},
where: { id: accountContext.id },
include: {
devices: {
select: {
id: true,
uuid: true,
name: true,
namespace: true,
createdAt: true,
updatedAt: true,
},
@ -617,11 +605,11 @@ router.get("/devices", jwtAuth, async (req, res, next) => {
*/
router.get("/device/:uuid/account", async (req, res, next) => {
try {
const {uuid} = req.params;
const { uuid } = req.params;
// 查找设备及其关联的账户
const device = await prisma.device.findUnique({
where: {uuid},
where: { uuid },
include: {
account: {
select: {
@ -664,136 +652,4 @@ router.get("/device/:uuid/account", async (req, res, next) => {
}
});
/**
* 刷新访问令牌
* POST /api/accounts/refresh
*
* Body:
* {
* refresh_token: string // 刷新令牌
* }
*/
router.post("/refresh", async (req, res, next) => {
try {
const {refresh_token} = req.body;
if (!refresh_token) {
return res.status(400).json({
success: false,
message: "缺少刷新令牌",
});
}
// 刷新访问令牌
const result = await refreshAccessToken(refresh_token);
res.json({
success: true,
message: "令牌刷新成功",
data: {
access_token: result.accessToken,
expires_in: result.accessTokenExpiresIn,
account: result.account,
},
});
} catch (error) {
if (error.message === 'Account not found') {
return next(errors.createError(401, "账户不存在"));
}
if (error.message === 'Invalid refresh token') {
return next(errors.createError(401, "无效的刷新令牌"));
}
if (error.message === 'Refresh token expired') {
return next(errors.createError(401, "刷新令牌已过期"));
}
if (error.message === 'Token version mismatch') {
return next(errors.createError(401, "令牌版本不匹配,请重新登录"));
}
next(error);
}
});
/**
* 登出撤销当前设备的刷新令牌
* POST /api/accounts/logout
*
* Headers:
* Authorization: Bearer <JWT Token>
*/
router.post("/logout", jwtAuth, async (req, res, next) => {
try {
const accountContext = res.locals.account;
// 撤销当前设备的刷新令牌
await revokeRefreshToken(accountContext.id);
res.json({
success: true,
message: "登出成功",
});
} catch (error) {
next(error);
}
});
/**
* 登出所有设备撤销所有令牌
* POST /api/accounts/logout-all
*
* Headers:
* Authorization: Bearer <JWT Token>
*/
router.post("/logout-all", jwtAuth, async (req, res, next) => {
try {
const accountContext = res.locals.account;
// 撤销所有令牌
await revokeAllTokens(accountContext.id);
res.json({
success: true,
message: "已从所有设备登出",
});
} catch (error) {
next(error);
}
});
/**
* 获取令牌信息
* GET /api/accounts/token-info
*
* Headers:
* Authorization: Bearer <JWT Token>
*/
router.get("/token-info", jwtAuth, async (req, res, next) => {
try {
const decoded = res.locals.tokenDecoded;
const account = res.locals.account;
// 计算token剩余有效时间
const now = Math.floor(Date.now() / 1000);
const expiresIn = decoded.exp - now;
res.json({
success: true,
data: {
accountId: account.id,
tokenType: decoded.type || 'legacy',
tokenVersion: decoded.tokenVersion || account.tokenVersion,
issuedAt: new Date(decoded.iat * 1000),
expiresAt: new Date(decoded.exp * 1000),
expiresIn: expiresIn,
isExpired: expiresIn <= 0,
isLegacyToken: res.locals.isLegacyToken || false,
hasRefreshToken: !!account.refreshToken,
refreshTokenExpiry: account.refreshTokenExpiry,
},
});
} catch (error) {
next(error);
}
});
export default router;

View File

@ -1,11 +1,12 @@
import {Router} from "express";
import {uuidAuth} from "../middleware/uuidAuth.js";
import {PrismaClient} from "@prisma/client";
import { Router } from "express";
const router = Router();
import { uuidAuth } from "../middleware/uuidAuth.js";
import { jwtAuth } from "../middleware/jwt-auth.js";
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
import { PrismaClient } from "@prisma/client";
import crypto from "crypto";
import errors from "../utils/errors.js";
import {verifyDevicePassword} from "../utils/crypto.js";
const router = Router();
import { verifyDevicePassword } from "../utils/crypto.js";
const prisma = new PrismaClient();
@ -16,11 +17,11 @@ const prisma = new PrismaClient();
router.get(
"/devices/:uuid/apps",
errors.catchAsync(async (req, res, next) => {
const {uuid} = req.params;
const { uuid } = req.params;
// 查找设备
const device = await prisma.device.findUnique({
where: {uuid},
where: { uuid },
});
if (!device) {
@ -28,7 +29,7 @@ router.get(
}
const installations = await prisma.appInstall.findMany({
where: {deviceId: device.id},
where: { deviceId: device.id },
});
const apps = installations.map(install => ({
@ -55,8 +56,8 @@ router.post(
uuidAuth,
errors.catchAsync(async (req, res, next) => {
const device = res.locals.device;
const {appId} = req.params;
const {note} = req.body;
const { appId } = req.params;
const { note } = req.body;
// 生成token
const token = crypto.randomBytes(32).toString("hex");
@ -91,10 +92,10 @@ router.delete(
uuidAuth,
errors.catchAsync(async (req, res, next) => {
const device = res.locals.device;
const {installId} = req.params;
const { installId } = req.params;
const installation = await prisma.appInstall.findUnique({
where: {id: installId},
where: { id: installId },
});
if (!installation) {
@ -107,7 +108,7 @@ router.delete(
}
await prisma.appInstall.delete({
where: {id: installation.id},
where: { id: installation.id },
});
return res.status(204).end();
@ -121,7 +122,7 @@ router.delete(
router.get(
"/tokens",
errors.catchAsync(async (req, res, next) => {
const {uuid} = req.query;
const { uuid } = req.query;
if (!uuid) {
return next(errors.createError(400, "需要提供设备UUID"));
@ -129,7 +130,7 @@ router.get(
// 查找设备
const device = await prisma.device.findUnique({
where: {uuid},
where: { uuid },
});
if (!device) {
@ -138,8 +139,8 @@ router.get(
// 获取该设备的所有应用安装记录即token
const installations = await prisma.appInstall.findMany({
where: {deviceId: device.id},
orderBy: {installedAt: 'desc'},
where: { deviceId: device.id },
orderBy: { installedAt: 'desc' },
});
const tokens = installations.map(install => ({
@ -167,7 +168,7 @@ router.get(
router.post(
"/auth/token",
errors.catchAsync(async (req, res, next) => {
const {namespace, password, appId} = req.body;
const { namespace, password, appId } = req.body;
if (!namespace) {
return next(errors.createError(400, "需要提供 namespace"));
@ -179,7 +180,7 @@ router.post(
// 通过 namespace 查找设备
const device = await prisma.device.findUnique({
where: {namespace},
where: { namespace },
include: {
autoAuths: true,
},
@ -207,8 +208,8 @@ router.post(
// 自动迁移:将哈希密码更新为明文密码
await prisma.autoAuth.update({
where: {id: autoAuth.id},
data: {password: password}, // 保存明文密码
where: { id: autoAuth.id },
data: { password: password }, // 保存明文密码
});
console.log(`AutoAuth ${autoAuth.id} 密码已自动迁移为明文`);
@ -216,7 +217,7 @@ router.post(
}
} catch (err) {
// 如果验证失败,继续尝试下一个
continue;
}
}
}
@ -266,8 +267,8 @@ router.post(
router.post(
"/tokens/:token/set-student-name",
errors.catchAsync(async (req, res, next) => {
const {token} = req.params;
const {name} = req.body;
const { token } = req.params;
const { name } = req.body;
if (!name) {
return next(errors.createError(400, "需要提供学生名称"));
@ -275,7 +276,7 @@ router.post(
// 查找 token 对应的应用安装记录
const appInstall = await prisma.appInstall.findUnique({
where: {token},
where: { token },
include: {
device: true,
},
@ -286,8 +287,8 @@ router.post(
}
// 验证 token 类型是否为 student
if (!['student', 'parent'].includes(appInstall.deviceType)) {
return next(errors.createError(403, "只有学生和家长类型的 token 可以设置名称"));
if (appInstall.deviceType !== 'student') {
return next(errors.createError(403, "只有学生类型的 token 可以设置名称"));
}
// 读取设备的 classworks-list-main 键值
@ -324,8 +325,8 @@ router.post(
// 更新 AppInstall 的 note 字段
const updatedInstall = await prisma.appInstall.update({
where: {id: appInstall.id},
data: {note: appInstall.deviceType === 'parent' ? `${name} 家长` : name},
where: { id: appInstall.id },
data: { note: name },
});
return res.json({
@ -346,12 +347,12 @@ router.post(
router.put(
"/tokens/:token/note",
errors.catchAsync(async (req, res, next) => {
const {token} = req.params;
const {note} = req.body;
const { token } = req.params;
const { note } = req.body;
// 查找 token 对应的应用安装记录
const appInstall = await prisma.appInstall.findUnique({
where: {token},
where: { token },
});
if (!appInstall) {
@ -360,8 +361,8 @@ router.put(
// 更新 AppInstall 的 note 字段
const updatedInstall = await prisma.appInstall.update({
where: {id: appInstall.id},
data: {note: note || null},
where: { id: appInstall.id },
data: { note: note || null },
});
return res.json({

View File

@ -1,9 +1,8 @@
import {Router} from "express";
import {jwtAuth} from "../middleware/jwt-auth.js";
import {PrismaClient} from "@prisma/client";
import errors from "../utils/errors.js";
import { Router } from "express";
const router = Router();
import { jwtAuth } from "../middleware/jwt-auth.js";
import { PrismaClient } from "@prisma/client";
import errors from "../utils/errors.js";
const prisma = new PrismaClient();
@ -15,12 +14,12 @@ router.get(
"/devices/:uuid/auth-configs",
jwtAuth,
errors.catchAsync(async (req, res, next) => {
const {uuid} = req.params;
const { uuid } = req.params;
const account = res.locals.account;
// 查找设备并验证是否属于当前账户
const device = await prisma.device.findUnique({
where: {uuid},
where: { uuid },
});
if (!device) {
@ -33,8 +32,8 @@ router.get(
}
const autoAuths = await prisma.autoAuth.findMany({
where: {deviceId: device.id},
orderBy: {createdAt: 'desc'},
where: { deviceId: device.id },
orderBy: { createdAt: 'desc' },
});
// 返回配置,智能处理密码显示
@ -69,13 +68,13 @@ router.post(
"/devices/:uuid/auth-configs",
jwtAuth,
errors.catchAsync(async (req, res, next) => {
const {uuid} = req.params;
const { uuid } = req.params;
const account = res.locals.account;
const {password, deviceType, isReadOnly} = req.body;
const { password, deviceType, isReadOnly } = req.body;
// 查找设备并验证是否属于当前账户
const device = await prisma.device.findUnique({
where: {uuid},
where: { uuid },
});
if (!device) {
@ -98,7 +97,7 @@ router.post(
// 查询该设备的所有自动授权配置,本地检查是否存在相同密码
const allAuths = await prisma.autoAuth.findMany({
where: {deviceId: device.id},
where: { deviceId: device.id },
});
const existingAuth = allAuths.find(auth => auth.password === plainPassword);
@ -128,8 +127,7 @@ router.post(
},
});
})
);
/**
);/**
* PUT /auto-auth/devices/:uuid/auth-configs/:configId
* 更新自动授权配置 (需要 JWT 认证且设备必须绑定到该账户)
* Body: { password?: string, deviceType?: string, isReadOnly?: boolean }
@ -138,13 +136,13 @@ router.put(
"/devices/:uuid/auth-configs/:configId",
jwtAuth,
errors.catchAsync(async (req, res, next) => {
const {uuid, configId} = req.params;
const { uuid, configId } = req.params;
const account = res.locals.account;
const {password, deviceType, isReadOnly} = req.body;
const { password, deviceType, isReadOnly } = req.body;
// 查找设备并验证是否属于当前账户
const device = await prisma.device.findUnique({
where: {uuid},
where: { uuid },
});
if (!device) {
@ -158,7 +156,7 @@ router.put(
// 查找自动授权配置
const autoAuth = await prisma.autoAuth.findUnique({
where: {id: configId},
where: { id: configId },
});
if (!autoAuth) {
@ -185,7 +183,7 @@ router.put(
// 查询该设备的所有配置,本地检查新密码是否与其他配置冲突
const allAuths = await prisma.autoAuth.findMany({
where: {deviceId: device.id},
where: { deviceId: device.id },
});
const conflictAuth = allAuths.find(auth =>
@ -209,7 +207,7 @@ router.put(
// 更新配置
const updatedAuth = await prisma.autoAuth.update({
where: {id: configId},
where: { id: configId },
data: updateData,
});
@ -234,12 +232,12 @@ router.delete(
"/devices/:uuid/auth-configs/:configId",
jwtAuth,
errors.catchAsync(async (req, res, next) => {
const {uuid, configId} = req.params;
const { uuid, configId } = req.params;
const account = res.locals.account;
// 查找设备并验证是否属于当前账户
const device = await prisma.device.findUnique({
where: {uuid},
where: { uuid },
});
if (!device) {
@ -253,7 +251,7 @@ router.delete(
// 查找自动授权配置
const autoAuth = await prisma.autoAuth.findUnique({
where: {id: configId},
where: { id: configId },
});
if (!autoAuth) {
@ -267,7 +265,7 @@ router.delete(
// 删除配置
await prisma.autoAuth.delete({
where: {id: configId},
where: { id: configId },
});
return res.status(204).end();
@ -283,9 +281,9 @@ router.put(
"/devices/:uuid/namespace",
jwtAuth,
errors.catchAsync(async (req, res, next) => {
const {uuid} = req.params;
const { uuid } = req.params;
const account = res.locals.account;
const {namespace} = req.body;
const { namespace } = req.body;
if (!namespace) {
return next(errors.createError(400, "需要提供 namespace"));
@ -300,7 +298,7 @@ router.put(
// 查找设备并验证是否属于当前账户
const device = await prisma.device.findUnique({
where: {uuid},
where: { uuid },
});
if (!device) {
@ -315,7 +313,7 @@ router.put(
// 检查新的 namespace 是否已被其他设备使用
if (device.namespace !== trimmedNamespace) {
const existingDevice = await prisma.device.findUnique({
where: {namespace: trimmedNamespace},
where: { namespace: trimmedNamespace },
});
if (existingDevice) {
@ -325,8 +323,8 @@ router.put(
// 更新设备的 namespace
const updatedDevice = await prisma.device.update({
where: {id: device.id},
data: {namespace: trimmedNamespace},
where: { id: device.id },
data: { namespace: trimmedNamespace },
});
return res.json({

View File

@ -1,12 +1,13 @@
import {Router} from "express";
import { Router } from "express";
import deviceCodeStore from "../utils/deviceCodeStore.js";
import errors from "../utils/errors.js";
import {PrismaClient} from "@prisma/client";
import { PrismaClient } from "@prisma/client";
const router = Router();
const prisma = new PrismaClient();
/**
* POST /device/code
* 生成设备授权码
@ -54,7 +55,7 @@ router.post(
router.post(
"/device/bind",
errors.catchAsync(async (req, res, next) => {
const {device_code, token} = req.body;
const { device_code, token } = req.body;
if (!device_code || !token) {
return next(
@ -64,7 +65,7 @@ router.post(
// 验证token是否有效检查数据库
const appInstall = await prisma.appInstall.findUnique({
where: {token},
where: { token },
});
if (!appInstall) {
@ -118,7 +119,7 @@ router.post(
router.get(
"/device/token",
errors.catchAsync(async (req, res, next) => {
const {device_code} = req.query;
const { device_code } = req.query;
if (!device_code) {
return next(errors.createError(400, "请提供 device_code"));
@ -173,7 +174,7 @@ router.get(
router.get(
"/device/status",
errors.catchAsync(async (req, res, next) => {
const {device_code} = req.query;
const { device_code } = req.query;
if (!device_code) {
return next(errors.createError(400, "请提供 device_code"));

View File

@ -1,35 +1,14 @@
import {Router} from "express";
import {extractDeviceInfo} from "../middleware/uuidAuth.js";
import {PrismaClient} from "@prisma/client";
import errors from "../utils/errors.js";
import {getOnlineDevices} from "../utils/socket.js";
import {registeredDevicesTotal} from "../utils/metrics.js";
import { Router } from "express";
const router = Router();
import { uuidAuth } from "../middleware/uuidAuth.js";
import { PrismaClient } from "@prisma/client";
import crypto from "crypto";
import errors from "../utils/errors.js";
import { hashPassword, verifyDevicePassword } from "../utils/crypto.js";
import { getOnlineDevices } from "../utils/socket.js";
const prisma = new PrismaClient();
/**
* 为新设备创建默认的自动登录配置
* @param {number} deviceId - 设备ID
*/
async function createDefaultAutoAuth(deviceId) {
try {
// 创建默认的自动授权配置不需要密码、类型是classroom一体机
await prisma.autoAuth.create({
data: {
deviceId: deviceId,
password: null, // 无密码
deviceType: "classroom", // 一体机类型
isReadOnly: false, // 非只读
},
});
} catch (error) {
console.error('创建默认自动登录配置失败:', error);
// 这里不抛出错误,避免影响设备创建流程
}
}
/**
* POST /devices
* 注册新设备
@ -37,7 +16,7 @@ async function createDefaultAutoAuth(deviceId) {
router.post(
"/",
errors.catchAsync(async (req, res, next) => {
const {uuid, deviceName, namespace} = req.body;
const { uuid, deviceName, namespace } = req.body;
if (!uuid) {
return next(errors.createError(400, "设备UUID是必需的"));
@ -47,10 +26,9 @@ router.post(
return next(errors.createError(400, "设备名称是必需的"));
}
try {
// 检查UUID是否已存在
const existingDevice = await prisma.device.findUnique({
where: {uuid},
where: { uuid },
});
if (existingDevice) {
@ -62,7 +40,7 @@ router.post(
// 检查 namespace 是否已被使用
const existingNamespace = await prisma.device.findUnique({
where: {namespace: deviceNamespace},
where: { namespace: deviceNamespace },
});
if (existingNamespace) {
@ -78,13 +56,6 @@ router.post(
},
});
// 为新设备创建默认的自动登录配置
await createDefaultAutoAuth(device.id);
// 更新注册设备总数指标
const totalDevices = await prisma.device.count();
registeredDevicesTotal.set(totalDevices);
return res.status(201).json({
success: true,
device: {
@ -95,9 +66,6 @@ router.post(
createdAt: device.createdAt,
},
});
} catch (error) {
throw error;
}
})
);
@ -108,11 +76,11 @@ router.post(
router.get(
"/:uuid",
errors.catchAsync(async (req, res, next) => {
const {uuid} = req.params;
const { uuid } = req.params;
// 查找设备,包含绑定的账户信息
const device = await prisma.device.findUnique({
where: {uuid},
where: { uuid },
include: {
account: {
select: {
@ -146,16 +114,15 @@ router.get(
namespace: device.namespace,
});
})
);
/**
);/**
* PUT /devices/:uuid/name
* 设置设备名称 (需要UUID认证)
*/
router.put(
"/:uuid/name",
extractDeviceInfo,
uuidAuth,
errors.catchAsync(async (req, res, next) => {
const {name} = req.body;
const { name } = req.body;
const device = res.locals.device;
if (!name) {
@ -163,8 +130,8 @@ router.put(
}
const updatedDevice = await prisma.device.update({
where: {id: device.id},
data: {name},
where: { id: device.id },
data: { name },
});
return res.json({
@ -180,6 +147,198 @@ router.put(
})
);
/**
* POST /devices/:uuid/password
* 初次设置设备密码 (无需认证仅当设备未设置密码时)
*/
router.post(
"/:uuid/password",
errors.catchAsync(async (req, res, next) => {
const { uuid } = req.params;
const newPassword = req.query.newPassword || req.body.newPassword;
if (!newPassword) {
return next(errors.createError(400, "新密码是必需的"));
}
// 查找设备
const device = await prisma.device.findUnique({
where: { uuid },
});
if (!device) {
return next(errors.createError(404, "设备不存在"));
}
// 只有在设备未设置密码时才允许无认证设置
if (device.password) {
return next(errors.createError(403, "设备已设置密码,请使用修改密码接口"));
}
const hashedPassword = await hashPassword(newPassword);
await prisma.device.update({
where: { id: device.id },
data: {
password: hashedPassword,
},
});
return res.json({
success: true,
message: "密码设置成功",
});
})
);
/**
* PUT /devices/:uuid/password
* 修改设备密码 (需要UUID认证和当前密码验证账户拥有者除外)
*/
router.put(
"/:uuid/password",
uuidAuth,
errors.catchAsync(async (req, res, next) => {
const currentPassword = req.query.currentPassword;
const newPassword = req.query.newPassword || req.body.newPassword;
const passwordHint = req.query.passwordHint || req.body.passwordHint;
const device = res.locals.device;
const isAccountOwner = res.locals.isAccountOwner;
if (!newPassword) {
return next(errors.createError(400, "新密码是必需的"));
}
// 如果是账户拥有者,无需验证当前密码
if (!isAccountOwner) {
if (!device.password) {
return next(errors.createError(400, "设备未设置密码,请使用设置密码接口"));
}
if (!currentPassword) {
return next(errors.createError(400, "当前密码是必需的"));
}
// 验证当前密码
const isCurrentPasswordValid = await verifyDevicePassword(currentPassword, device.password);
if (!isCurrentPasswordValid) {
return next(errors.createError(401, "当前密码错误"));
}
}
const hashedNewPassword = await hashPassword(newPassword);
await prisma.device.update({
where: { id: device.id },
data: {
password: hashedNewPassword,
passwordHint: passwordHint !== undefined ? passwordHint : device.passwordHint,
},
});
return res.json({
success: true,
message: "密码修改成功",
});
})
);
/**
* PUT /devices/:uuid/password-hint
* 设置密码提示 (需要UUID认证)
*/
router.put(
"/:uuid/password-hint",
uuidAuth,
errors.catchAsync(async (req, res, next) => {
const { passwordHint } = req.body;
const device = res.locals.device;
await prisma.device.update({
where: { id: device.id },
data: { passwordHint: passwordHint || null },
});
return res.json({
success: true,
message: "密码提示设置成功",
passwordHint: passwordHint || null,
});
})
);
/**
* GET /devices/:uuid/password-hint
* 获取设备密码提示 (无需认证)
*/
router.get(
"/:uuid/password-hint",
errors.catchAsync(async (req, res, next) => {
const { uuid } = req.params;
const device = await prisma.device.findUnique({
where: { uuid },
select: {
passwordHint: true,
},
});
if (!device) {
return next(errors.createError(404, "设备不存在"));
}
return res.json({
success: true,
passwordHint: device.passwordHint || null,
});
})
);
/**
* DELETE /devices/:uuid/password
* 删除设备密码 (需要UUID认证和密码验证账户拥有者除外)
*/
router.delete(
"/:uuid/password",
uuidAuth,
errors.catchAsync(async (req, res, next) => {
const password = req.query.password;
const device = res.locals.device;
const isAccountOwner = res.locals.isAccountOwner;
if (!device.password) {
return next(errors.createError(400, "设备未设置密码"));
}
// 如果不是账户拥有者,需要验证密码
if (!isAccountOwner) {
if (!password) {
return next(errors.createError(400, "密码是必需的"));
}
// 验证密码
const isPasswordValid = await verifyDevicePassword(password, device.password);
if (!isPasswordValid) {
return next(errors.createError(401, "密码错误"));
}
}
await prisma.device.update({
where: { id: device.id },
data: {
password: null,
passwordHint: null,
},
});
return res.json({
success: true,
message: "密码删除成功",
});
})
);
export default router;
/**
* GET /devices/online
@ -192,14 +351,14 @@ router.get(
const list = getOnlineDevices();
if (list.length === 0) {
return res.json({success: true, devices: []});
return res.json({ success: true, devices: [] });
}
// 补充设备名称
const uuids = list.map((x) => x.uuid);
const rows = await prisma.device.findMany({
where: {uuid: {in: uuids}},
select: {uuid: true, name: true},
where: { uuid: { in: uuids } },
select: { uuid: true, name: true },
});
const nameMap = new Map(rows.map((r) => [r.uuid, r.name]));
@ -209,8 +368,6 @@ router.get(
name: nameMap.get(x.uuid) || null,
}));
res.json({success: true, devices});
res.json({ success: true, devices });
})
);
export default router;

View File

@ -1,5 +1,4 @@
import {Router} from "express";
import { Router } from "express";
var router = Router();
/* GET home page. */

View File

@ -1,35 +1,23 @@
import { Router } from "express";
const router = Router();
import kvStore from "../utils/kvStore.js";
import { broadcastKeyChanged } from "../utils/socket.js";
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
import {
prepareTokenForRateLimit,
tokenBatchLimiter,
tokenDeleteLimiter,
tokenReadLimiter,
tokenWriteLimiter
} from "../middleware/rateLimiter.js";
import errors from "../utils/errors.js";
import { PrismaClient } from "@prisma/client";
const router = Router();
const prisma = new PrismaClient();
// 使用KV专用token认证
router.use(kvTokenAuth);
// 准备token用于限速器
router.use(prepareTokenForRateLimit);
/**
* GET /_info
* 获取当前token所属设备的信息如果关联了账号也返回账号信息
*/
router.get(
"/_info",
tokenReadLimiter,
errors.catchAsync(async (req, res, next) => {
errors.catchAsync(async (req, res) => {
const deviceId = res.locals.deviceId;
// 获取设备信息,包含关联的账号
@ -44,24 +32,17 @@ router.get(
return next(errors.createError(404, "设备不存在"));
}
// 构建响应对象:当设备没有关联账号时返回 uuid若已关联账号则不返回 uuid
// 构建响应对象
const response = {
device: {
id: device.id,
uuid: device.uuid,
name: device.name,
createdAt: device.createdAt,
updatedAt: device.updatedAt,
},
};
// 仅当设备未绑定账号时,包含 uuid 字段
if (!device.account) {
response.device.uuid = device.uuid;
}
// 标识是否已绑定账号
response.hasAccount = !!device.account;
// 如果关联了账号,添加账号信息
if (device.account) {
response.account = {
@ -81,7 +62,6 @@ router.get(
*/
router.get(
"/_token",
tokenReadLimiter,
errors.catchAsync(async (req, res, next) => {
const token = res.locals.token;
const deviceId = res.locals.deviceId;
@ -130,7 +110,6 @@ router.get(
*/
router.get(
"/_keys",
tokenReadLimiter,
errors.catchAsync(async (req, res) => {
const deviceId = res.locals.deviceId;
const { sortBy, sortDir, limit, skip } = req.query;
@ -181,7 +160,6 @@ router.get(
*/
router.get(
"/",
tokenReadLimiter,
errors.catchAsync(async (req, res) => {
const deviceId = res.locals.deviceId;
const { sortBy, sortDir, limit, skip } = req.query;
@ -227,7 +205,6 @@ router.get(
*/
router.get(
"/:key",
tokenReadLimiter,
errors.catchAsync(async (req, res, next) => {
const deviceId = res.locals.deviceId;
const { key } = req.params;
@ -250,7 +227,6 @@ router.get(
*/
router.get(
"/:key/metadata",
tokenReadLimiter,
errors.catchAsync(async (req, res, next) => {
const deviceId = res.locals.deviceId;
const { key } = req.params;
@ -271,7 +247,6 @@ router.get(
*/
router.post(
"/_batchimport",
tokenBatchLimiter,
errors.catchAsync(async (req, res, next) => {
// 检查token是否为只读
if (res.locals.appInstall?.isReadOnly) {
@ -298,25 +273,43 @@ router.post(
req.connection.socket?.remoteAddress ||
"";
// 使用优化的批量upsert方法
const { results, errors: errorList } = await kvStore.batchUpsert(deviceId, data, creatorIp);
const results = [];
const errorList = [];
// 批量处理所有键值对
for (const [key, value] of Object.entries(data)) {
try {
const result = await kvStore.upsert(deviceId, key, value, creatorIp);
results.push({
key: result.key,
created: result.createdAt.getTime() === result.updatedAt.getTime(),
});
// 广播每个键的变更
const uuid = res.locals.device?.uuid;
if (uuid) {
broadcastKeyChanged(uuid, {
key: result.key,
action: "upsert",
created: result.createdAt.getTime() === result.updatedAt.getTime(),
updatedAt: result.updatedAt,
batch: true,
});
}
} catch (error) {
errorList.push({
key,
error: error.message,
});
}
}
return res.status(200).json({
code: 200,
message: "批量导入成功",
data: {
deviceId,
summary: {
total: Object.keys(data).length,
successful: results.length,
failed: errorList.length,
},
results: results.map(r => ({
key: r.key,
isNew: r.created,
})),
...(errorList.length > 0 && { errors: errorList }),
},
results,
errors: errorList.length > 0 ? errorList : undefined,
});
})
);
@ -327,7 +320,6 @@ router.post(
*/
router.post(
"/:key",
tokenWriteLimiter,
errors.catchAsync(async (req, res, next) => {
// 检查token是否为只读
if (res.locals.appInstall?.isReadOnly) {
@ -336,20 +328,10 @@ router.post(
const deviceId = res.locals.deviceId;
const { key } = req.params;
let value = req.body;
const value = req.body;
// 处理空值,转换为空对象
if (value === null || value === undefined || value === '') {
value = {};
}
// 验证是否能被 JSON 序列化
try {
JSON.stringify(value);
} catch (error) {
return next(
errors.createError(400, "无效的数据格式")
);
if (!value || Object.keys(value).length === 0) {
return next(errors.createError(400, "请提供有效的JSON值"));
}
// 获取客户端IP
@ -388,7 +370,6 @@ router.post(
*/
router.delete(
"/:key",
tokenDeleteLimiter,
errors.catchAsync(async (req, res, next) => {
// 检查token是否为只读
if (res.locals.appInstall?.isReadOnly) {

View File

@ -1,5 +1,4 @@
import dotenv from "dotenv";
dotenv.config();
export const siteKey = process.env.SITE_KEY || "";

View File

@ -1,5 +1,5 @@
import bcrypt from "bcrypt";
import {Base64} from "js-base64";
import { Base64 } from "js-base64";
const SALT_ROUNDS = 8;

View File

@ -1,18 +1,16 @@
/**
* 创建标准错误对象
* @param {number} statusCode - HTTP状态码
* @param {string} [message] - 错误消息推荐使用稳定的机器可读文本 JWT_EXPIRED
* @param {string} [message] - 错误消息
* @param {object} [details] - 附加信息
* @param {string} [code] - 业务错误码 AUTH_JWT_EXPIRED
* @returns {object} 标准错误对象
*/
const createError = (statusCode, message, details = null, code = null) => {
const createError = (statusCode, message, details = null) => {
// 直接返回错误对象,不抛出异常
const error = {
statusCode: statusCode,
message: message || '服务器错误',
details: details,
code: code || undefined,
details: details
};
return error;
};

View File

@ -1,11 +1,10 @@
import "dotenv/config";
import {NodeSDK} from "@opentelemetry/sdk-node";
import {getNodeAutoInstrumentations} from "@opentelemetry/auto-instrumentations-node";
import {OTLPTraceExporter} from "@opentelemetry/exporter-trace-otlp-proto";
import {BatchSpanProcessor} from "@opentelemetry/sdk-trace-base";
import {resourceFromAttributes} from "@opentelemetry/resources";
import {SemanticResourceAttributes} from "@opentelemetry/semantic-conventions";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
if (process.env.AXIOM_TOKEN && process.env.AXIOM_DATASET) {
// Initialize OTLP trace exporter with the endpoint URL and headers
// Initialize OTLP trace exporter with the endpoint URL and headers

View File

@ -1,12 +1,4 @@
import jwt from 'jsonwebtoken';
import {
generateAccessToken,
generateTokenPair,
refreshAccessToken,
revokeAllTokens,
revokeRefreshToken,
verifyAccessToken,
} from './tokenManager.js';
// JWT 配置(支持 HS256 与 RS256
const JWT_ALG = (process.env.JWT_ALG || 'HS256').toUpperCase();
@ -24,18 +16,17 @@ function getSignVerifyKeys() {
if (!JWT_PRIVATE_KEY || !JWT_PUBLIC_KEY) {
throw new Error('RS256 需要同时提供 JWT_PRIVATE_KEY 与 JWT_PUBLIC_KEY');
}
return {signKey: JWT_PRIVATE_KEY, verifyKey: JWT_PUBLIC_KEY};
return { signKey: JWT_PRIVATE_KEY, verifyKey: JWT_PUBLIC_KEY };
}
// 默认 HS256
return {signKey: JWT_SECRET, verifyKey: JWT_SECRET};
return { signKey: JWT_SECRET, verifyKey: JWT_SECRET };
}
/**
* 签发JWT token向后兼容
* @deprecated 建议使用 generateAccessToken
* 签发JWT token
*/
export function signToken(payload) {
const {signKey} = getSignVerifyKeys();
const { signKey } = getSignVerifyKeys();
return jwt.sign(payload, signKey, {
expiresIn: JWT_EXPIRES_IN,
algorithm: JWT_ALG,
@ -43,17 +34,15 @@ export function signToken(payload) {
}
/**
* 验证JWT token向后兼容
* @deprecated 建议使用 verifyAccessToken
* 验证JWT token
*/
export function verifyToken(token) {
const {verifyKey} = getSignVerifyKeys();
return jwt.verify(token, verifyKey, {algorithms: [JWT_ALG]});
const { verifyKey } = getSignVerifyKeys();
return jwt.verify(token, verifyKey, { algorithms: [JWT_ALG] });
}
/**
* 为账户生成JWT token向后兼容
* @deprecated 建议使用 generateTokenPair 获取完整的令牌对
* 为账户生成JWT token
*/
export function generateAccountToken(account) {
return signToken({
@ -64,13 +53,3 @@ export function generateAccountToken(account) {
avatarUrl: account.avatarUrl,
});
}
// 重新导出新的token管理功能
export {
generateAccessToken,
verifyAccessToken,
generateTokenPair,
refreshAccessToken,
revokeAllTokens,
revokeRefreshToken,
};

View File

@ -1,8 +1,5 @@
import {PrismaClient} from "@prisma/client";
import {keysTotal} from "./metrics.js";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
class KVStore {
/**
* 通过设备ID和键名获取值
@ -77,7 +74,7 @@ class KVStore {
},
update: {
value,
...(creatorIp && {creatorIp}),
...(creatorIp && { creatorIp }),
},
create: {
deviceId: deviceId,
@ -87,10 +84,6 @@ class KVStore {
},
});
// 更新键总数指标
const totalKeys = await prisma.kVStore.count();
keysTotal.set(totalKeys);
// 返回带有设备ID和原始键的结果
return {
deviceId,
@ -102,62 +95,6 @@ class KVStore {
};
}
/**
* 批量创建或更新键值对优化性能
* @param {number} deviceId - 设备ID
* @param {object} data - 键值对数据 {key1: value1, key2: value2, ...}
* @param {string} creatorIp - 创建者IP可选
* @returns {object} {results: Array, errors: Array}
*/
async batchUpsert(deviceId, data, creatorIp = "") {
const results = [];
const errors = [];
// 使用事务处理所有操作
await prisma.$transaction(async (tx) => {
for (const [key, value] of Object.entries(data)) {
try {
const item = await tx.kVStore.upsert({
where: {
deviceId_key: {
deviceId: deviceId,
key: key,
},
},
update: {
value,
...(creatorIp && {creatorIp}),
},
create: {
deviceId: deviceId,
key: key,
value,
creatorIp,
},
});
results.push({
key: item.key,
created: item.createdAt.getTime() === item.updatedAt.getTime(),
createdAt: item.createdAt,
updatedAt: item.updatedAt,
});
} catch (error) {
errors.push({
key,
error: error.message,
});
}
}
});
// 在事务完成后,一次性更新指标
const totalKeys = await prisma.kVStore.count();
keysTotal.set(totalKeys);
return { results, errors };
}
/**
* 通过设备ID和键名删除
* @param {number} deviceId - 设备ID
@ -174,17 +111,10 @@ class KVStore {
},
},
});
// 更新键总数指标
const totalKeys = await prisma.kVStore.count();
keysTotal.set(totalKeys);
return item ? {...item, deviceId, key} : null;
return item ? { ...item, deviceId, key } : null;
} catch (error) {
// 忽略记录不存在的错误
if (error.code === "P2025") {
return null;
}
if (error.code === "P2025") return null;
throw error;
}
}
@ -196,7 +126,7 @@ class KVStore {
* @returns {Array} 键名和元数据数组
*/
async list(deviceId, options = {}) {
const {sortBy = "key", sortDir = "asc", limit = 100, skip = 0} = options;
const { sortBy = "key", sortDir = "asc", limit = 100, skip = 0 } = options;
// 构建排序条件
const orderBy = {};
@ -239,7 +169,7 @@ class KVStore {
* @returns {Array} 键名列表
*/
async listKeysOnly(deviceId, options = {}) {
const {sortBy = "key", sortDir = "asc", limit = 100, skip = 0} = options;
const { sortBy = "key", sortDir = "asc", limit = 100, skip = 0 } = options;
// 构建排序条件
const orderBy = {};
@ -275,37 +205,6 @@ class KVStore {
});
return count;
}
/**
* 获取指定设备的统计信息
* @param {number} deviceId - 设备ID
* @returns {object} 统计信息
*/
async getStats(deviceId) {
const [totalKeys, oldestKey, newestKey] = await Promise.all([
prisma.kVStore.count({
where: { deviceId },
}),
prisma.kVStore.findFirst({
where: { deviceId },
orderBy: { createdAt: "asc" },
select: { createdAt: true, key: true },
}),
prisma.kVStore.findFirst({
where: { deviceId },
orderBy: { updatedAt: "desc" },
select: { updatedAt: true, key: true },
}),
]);
return {
totalKeys,
oldestKey: oldestKey?.key,
oldestCreatedAt: oldestKey?.createdAt,
newestKey: newestKey?.key,
newestUpdatedAt: newestKey?.updatedAt,
};
}
}
export default new KVStore();

View File

@ -1,49 +0,0 @@
import client from 'prom-client';
// 创建自定义注册表(不包含默认指标)
const register = new client.Registry();
// 当前在线设备数(连接了 SocketIO 的设备)
export const onlineDevicesGauge = new client.Gauge({
name: 'classworks_online_devices_total',
help: 'Total number of online devices (connected via SocketIO)',
registers: [register],
});
// 已注册设备总数
export const registeredDevicesTotal = new client.Gauge({
name: 'classworks_registered_devices_total',
help: 'Total number of registered devices',
registers: [register],
});
// 已创建键总数(不区分设备)
export const keysTotal = new client.Gauge({
name: 'classworks_keys_total',
help: 'Total number of keys across all devices',
registers: [register],
});
// 初始化指标数据
export async function initializeMetrics() {
try {
const {PrismaClient} = await import('@prisma/client');
const prisma = new PrismaClient();
// 获取已注册设备总数
const deviceCount = await prisma.device.count();
registeredDevicesTotal.set(deviceCount);
// 获取已创建键总数
const keyCount = await prisma.kVStore.count();
keysTotal.set(keyCount);
await prisma.$disconnect();
console.log('Prometheus metrics initialized - Devices:', deviceCount, 'Keys:', keyCount);
} catch (error) {
console.error('Failed to initialize metrics:', error);
}
}
// 导出注册表用于 /metrics 端点
export {register};

View File

@ -1,4 +1,4 @@
import {PrismaClient} from "@prisma/client";
import { PrismaClient } from "@prisma/client";
import kvStore from "./kvStore.js";
const prisma = new PrismaClient();
@ -24,8 +24,8 @@ async function getSystemDeviceId() {
if (systemDeviceId) return systemDeviceId;
let device = await prisma.device.findUnique({
where: {uuid: SYSTEM_DEVICE_UUID},
select: {id: true},
where: { uuid: SYSTEM_DEVICE_UUID },
select: { id: true },
});
if (!device) {
@ -34,7 +34,7 @@ async function getSystemDeviceId() {
uuid: SYSTEM_DEVICE_UUID,
name: "系统设备",
},
select: {id: true},
select: { id: true },
});
}
@ -65,7 +65,7 @@ export const initReadme = async () => {
});
// 确保在异常情况下也有默认值
readmeValue = {...defaultReadme};
readmeValue = { ...defaultReadme };
}
};
@ -74,7 +74,7 @@ export const initReadme = async () => {
* @returns {Object} readme 值对象
*/
export const getReadmeValue = () => {
return readmeValue || {...defaultReadme};
return readmeValue || { ...defaultReadme };
};
/**

View File

@ -7,88 +7,18 @@
* - 同一设备的不同 token 会被归入同一频道
* - 维护在线设备列表
* - 提供广播 KV 键变更的工具方法
* - 支持任意类型事件转发客户端可发送自定义事件类型和JSON内容到其他设备
* - 记录事件历史包含时间戳来源令牌设备类型权限等完整元数据
* - 令牌信息缓存在连接时预加载令牌详细信息以提高性能
*/
import { Server } from "socket.io";
import { PrismaClient } from "@prisma/client";
import { onlineDevicesGauge } from "./metrics.js";
import DeviceDetector from "node-device-detector";
import ClientHints from "node-device-detector/client-hints.js";
// Socket.IO 单例实例
let io = null;
// 设备检测器实例
const deviceDetector = new DeviceDetector({
clientIndexes: true,
deviceIndexes: true,
deviceAliasCode: false,
});
const clientHints = new ClientHints();
// 在线设备映射uuid -> Set<socketId>
const onlineMap = new Map();
// 在线 token 映射token -> Set<socketId> (用于指标统计)
const onlineTokens = new Map();
// 令牌信息缓存token -> {appId, isReadOnly, deviceType, note, deviceUuid, deviceName}
const tokenInfoCache = new Map();
// 事件历史记录每个设备最多保存1000条事件记录
const eventHistory = new Map(); // uuid -> Array<EventRecord>
const MAX_EVENT_HISTORY = 1000;
const prisma = new PrismaClient();
/**
* 检测设备并生成友好的设备名称
* @param {string} userAgent 用户代理字符串
* @param {object} headers HTTP headers对象
* @returns {string} 生成的设备名称
*/
function detectDeviceName(userAgent, headers = {}) {
if (!userAgent) return "Unknown Device";
try {
const clientHintsData = clientHints.parse(headers);
const deviceInfo = deviceDetector.detect(userAgent, clientHintsData);
const botInfo = deviceDetector.parseBot(userAgent);
// 如果是bot返回bot名称
if (botInfo && botInfo.name) {
return `Bot: ${botInfo.name}`;
}
// 构建设备名称
let deviceName = "";
if (deviceInfo.device && deviceInfo.device.brand && deviceInfo.device.model) {
deviceName = `${deviceInfo.device.brand} ${deviceInfo.device.model}`;
} else if (deviceInfo.os && deviceInfo.os.name) {
deviceName = deviceInfo.os.name;
if (deviceInfo.os.version) {
deviceName += ` ${deviceInfo.os.version}`;
}
}
// 添加客户端信息
if (deviceInfo.client && deviceInfo.client.name) {
deviceName += deviceName ? ` (${deviceInfo.client.name}` : deviceInfo.client.name;
if (deviceInfo.client.version) {
deviceName += ` ${deviceInfo.client.version}`;
}
if (deviceName.includes("(")) {
deviceName += ")";
}
}
return deviceName || "Unknown Device";
} catch (error) {
console.warn("Device detection error:", error);
return "Unknown Device";
}
}
/**
* 初始化 Socket.IO
* @param {import('http').Server} server HTTP Server 实例
@ -96,16 +26,14 @@ function detectDeviceName(userAgent, headers = {}) {
export function initSocket(server) {
if (io) return io;
const allowOrigin = process.env.FRONTEND_URL || "*";
io = new Server(server, {
cors: {
origin: "*",
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
allowedHeaders: ["*"],
credentials: false
origin: allowOrigin,
methods: ["GET", "POST"],
credentials: true,
},
// 传输方式回退策略优先使用WebSocket,回退到轮询
transports: ["polling", "websocket"],
});
io.on("connection", (socket) => {
@ -115,16 +43,14 @@ export function initSocket(server) {
// 仅允许通过 query.token/apptoken 加入
const qToken = socket.handshake?.query?.token || socket.handshake?.query?.apptoken;
if (qToken && typeof qToken === "string") {
joinByToken(socket, qToken).catch(() => {
});
joinByToken(socket, qToken).catch(() => {});
}
// 客户端使用 KV token 加入房间
socket.on("join-token", (payload) => {
const token = payload?.token || payload?.apptoken;
if (typeof token === "string" && token.length > 0) {
joinByToken(socket, token).catch(() => {
});
joinByToken(socket, token).catch(() => {});
}
});
@ -138,12 +64,7 @@ export function initSocket(server) {
include: { device: { select: { uuid: true } } },
});
const uuid = appInstall?.device?.uuid;
if (uuid) {
leaveDeviceRoom(socket, uuid);
// 移除 token 连接跟踪
removeTokenConnection(token, socket.id);
if (socket.data.tokens) socket.data.tokens.delete(token);
}
if (uuid) leaveDeviceRoom(socket, uuid);
} catch {
// ignore
}
@ -155,148 +76,35 @@ export function initSocket(server) {
uuids.forEach((u) => leaveDeviceRoom(socket, u));
});
// 获取事件历史记录
socket.on("get-event-history", (data) => {
// 聊天室:发送文本消息到加入的设备频道
socket.on("chat:send", (data) => {
try {
const { limit = 50, offset = 0 } = data || {};
const text = typeof data === "string" ? data : data?.text;
if (typeof text !== "string") return;
const trimmed = text.trim();
if (!trimmed) return;
// 限制消息最大长度,避免滥用
const MAX_LEN = 2000;
const safeText = trimmed.length > MAX_LEN ? trimmed.slice(0, MAX_LEN) : trimmed;
const uuids = Array.from(socket.data.deviceUuids || []);
if (uuids.length === 0) return;
if (uuids.length === 0) {
socket.emit("event-history-error", { reason: "not_joined_any_device" });
return;
}
const at = new Date().toISOString();
const payload = { text: safeText, at, senderId: socket.id };
// 返回所有加入设备的事件历史
const historyData = {};
uuids.forEach((uuid) => {
historyData[uuid] = getEventHistory(uuid, limit, offset);
io.to(uuid).emit("chat:message", { uuid, ...payload });
});
socket.emit("event-history", {
devices: historyData,
timestamp: new Date().toISOString(),
requestedBy: {
deviceType: socket.data.tokenInfo?.deviceType,
deviceName: socket.data.tokenInfo?.deviceName,
isReadOnly: socket.data.tokenInfo?.isReadOnly
}
});
} catch (err) {
console.error("get-event-history error:", err);
socket.emit("event-history-error", { reason: "internal_error" });
}
});
// 通用事件转发:允许发送任意类型事件到其他设备
socket.on("send-event", (data) => {
try {
// 验证数据结构
if (!data || typeof data !== "object") {
socket.emit("event-error", { reason: "invalid_data_format" });
return;
}
const { type, content } = data;
// 验证事件类型
if (typeof type !== "string" || type.trim().length === 0) {
socket.emit("event-error", { reason: "invalid_event_type" });
return;
}
// 验证内容格式必须是对象或null
if (content !== null && (typeof content !== "object" || Array.isArray(content))) {
socket.emit("event-error", { reason: "content_must_be_object_or_null" });
return;
}
// 获取当前socket所在的设备房间
const uuids = Array.from(socket.data.deviceUuids || []);
if (uuids.length === 0) {
socket.emit("event-error", { reason: "not_joined_any_device" });
return;
}
// 检查只读权限
const tokenInfo = socket.data.tokenInfo;
if (tokenInfo?.isReadOnly) {
socket.emit("event-error", { reason: "readonly_token_cannot_send_events" });
return;
}
// 限制序列化后内容大小,避免滥用
const MAX_SIZE = 10240; // 10KB
const serializedContent = JSON.stringify(content);
if (serializedContent.length > MAX_SIZE) {
socket.emit("event-error", { reason: "content_too_large", maxSize: MAX_SIZE });
return;
}
const timestamp = new Date().toISOString();
const eventId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// 构建完整的事件载荷,包含发送者信息
const eventPayload = {
eventId,
content,
timestamp,
senderId: socket.id,
senderInfo: {
appId: tokenInfo?.appId,
deviceType: tokenInfo?.deviceType,
deviceName: tokenInfo?.deviceName,
isReadOnly: tokenInfo?.isReadOnly || false,
note: tokenInfo?.note
}
};
// 记录事件到历史记录包含type用于历史记录
const historyPayload = {
...eventPayload,
type: type.trim()
};
uuids.forEach((uuid) => {
recordEventHistory(uuid, historyPayload);
});
// 直接使用事件名称发送到所有相关设备房间排除发送者所在的socket
uuids.forEach((uuid) => {
socket.to(uuid).emit(type.trim(), eventPayload);
});
// 发送确认回执给发送者
socket.emit("event-sent", {
eventId: eventPayload.eventId,
eventName: type.trim(),
timestamp: eventPayload.timestamp,
targetDevices: uuids.length,
senderInfo: eventPayload.senderInfo
});
} catch (err) {
console.error("send-event error:", err);
socket.emit("event-error", { reason: "internal_error" });
console.error("chat:send error:", err);
}
});
socket.on("disconnect", () => {
const uuids = Array.from(socket.data.deviceUuids || []);
uuids.forEach((u) => removeOnline(u, socket.id));
// 清理 token 连接跟踪
const tokens = Array.from(socket.data.tokens || []);
tokens.forEach((token) => removeTokenConnection(token, socket.id));
// 清理socket相关缓存
if (socket.data.currentToken) {
// 如果这是该token的最后一个连接,考虑清理缓存
const tokenSet = onlineTokens.get(socket.data.currentToken);
if (!tokenSet || tokenSet.size === 0) {
// 可以选择保留缓存一段时间,这里暂时保留
// tokenInfoCache.delete(socket.data.currentToken);
}
}
});
});
@ -325,24 +133,6 @@ function joinDeviceRoom(socket, uuid) {
io.to(uuid).emit("device-joined", { uuid, connections: set.size });
}
/**
* 跟踪 token 连接用于指标统计
* @param {import('socket.io').Socket} socket
* @param {string} token
*/
function trackTokenConnection(socket, token) {
if (!socket.data.tokens) socket.data.tokens = new Set();
socket.data.tokens.add(token);
// 记录 token 连接
const set = onlineTokens.get(token) || new Set();
set.add(socket.id);
onlineTokens.set(token, set);
// 更新在线设备数指标(基于不同的 token 数量)
onlineDevicesGauge.set(onlineTokens.size);
}
/**
* socket 离开设备房间并更新在线表
* @param {import('socket.io').Socket} socket
@ -365,24 +155,6 @@ function removeOnline(uuid, socketId) {
}
}
/**
* 移除 token 连接跟踪
* @param {string} token
* @param {string} socketId
*/
function removeTokenConnection(token, socketId) {
const set = onlineTokens.get(token);
if (!set) return;
set.delete(socketId);
if (set.size === 0) {
onlineTokens.delete(token);
} else {
onlineTokens.set(token, set);
}
// 更新在线设备数指标(基于不同的 token 数量)
onlineDevicesGauge.set(onlineTokens.size);
}
/**
* 广播某设备下 KV 键已变更
* @param {string} uuid 设备 uuid
@ -390,144 +162,17 @@ function removeTokenConnection(token, socketId) {
*/
export function broadcastKeyChanged(uuid, payload) {
if (!io || !uuid) return;
// 发送KV变更事件
const timestamp = new Date().toISOString();
const eventId = `kv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const eventPayload = {
eventId,
content: payload,
timestamp,
senderId: "realtime",
senderInfo: {
appId: "5c2a54d553951a37b47066ead68c8642",
deviceType: "server",
deviceName: "realtime",
isReadOnly: false,
note: "Database realtime sync"
}
};
// 记录到事件历史包含type用于历史记录
const historyPayload = {
...eventPayload,
type: "kv-key-changed"
};
recordEventHistory(uuid, historyPayload);
// 直接发送kv-key-changed事件
io.to(uuid).emit("kv-key-changed", eventPayload);
}
/**
* 向指定设备广播自定义事件
* @param {string} uuid 设备 uuid
* @param {string} type 事件类型
* @param {object|null} content 事件内容JSON对象或null
* @param {string} [senderId] 发送者ID可选
*/
export function broadcastDeviceEvent(uuid, type, content = null, senderId = "system") {
if (!io || !uuid || typeof type !== "string" || type.trim().length === 0) return;
// 验证内容格式
if (content !== null && (typeof content !== "object" || Array.isArray(content))) {
console.warn("broadcastDeviceEvent: content must be object or null");
return;
}
const timestamp = new Date().toISOString();
const eventId = `sys-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const eventPayload = {
eventId,
content,
timestamp,
senderId,
senderInfo: {
appId: "system",
deviceType: "system",
deviceName: "System",
isReadOnly: false,
note: "System broadcast"
}
};
// 记录系统事件到历史包含type用于历史记录
const historyPayload = {
...eventPayload,
type: type.trim()
};
recordEventHistory(uuid, historyPayload);
io.to(uuid).emit(type.trim(), eventPayload);
}
/**
* 记录事件到历史记录
* @param {string} uuid 设备UUID
* @param {object} eventPayload 事件载荷
*/
function recordEventHistory(uuid, eventPayload) {
if (!eventHistory.has(uuid)) {
eventHistory.set(uuid, []);
}
const history = eventHistory.get(uuid);
history.push({
...eventPayload,
recordedAt: new Date().toISOString()
});
// 保持历史记录在限制范围内
if (history.length > MAX_EVENT_HISTORY) {
history.splice(0, history.length - MAX_EVENT_HISTORY);
}
}
/**
* 获取设备事件历史记录
* @param {string} uuid 设备UUID
* @param {number} [limit=50] 返回记录数量限制
* @param {number} [offset=0] 偏移量
* @returns {Array} 事件历史记录
*/
export function getEventHistory(uuid, limit = 50, offset = 0) {
const history = eventHistory.get(uuid) || [];
return history.slice(offset, offset + limit);
}
/**
* 获取令牌信息
* @param {string} token 令牌
* @returns {object|null} 令牌信息
*/
export function getTokenInfo(token) {
return tokenInfoCache.get(token) || null;
}
/**
* 清理设备相关缓存
* @param {string} uuid 设备UUID
*/
function cleanupDeviceCache(uuid) {
// 清理事件历史
eventHistory.delete(uuid);
// 清理相关令牌缓存
for (const [token, info] of tokenInfoCache.entries()) {
if (info.deviceUuid === uuid) {
tokenInfoCache.delete(token);
}
}
io.to(uuid).emit("kv-key-changed", { uuid, ...payload });
}
/**
* 获取在线设备列表
* @returns {Array<{token:string, connections:number}>}
* @returns {Array<{uuid:string, connections:number}>}
*/
export function getOnlineDevices() {
const list = [];
for (const [token, set] of onlineTokens.entries()) {
list.push({ token, connections: set.size });
for (const [uuid, set] of onlineMap.entries()) {
list.push({ uuid, connections: set.size });
}
// 默认按连接数降序
return list.sort((a, b) => b.connections - a.connections);
@ -537,10 +182,7 @@ export default {
initSocket,
getIO,
broadcastKeyChanged,
broadcastDeviceEvent,
getOnlineDevices,
getEventHistory,
getTokenInfo,
};
/**
@ -549,69 +191,16 @@ export default {
* @param {string} token
*/
async function joinByToken(socket, token) {
try {
const appInstall = await prisma.appInstall.findUnique({
where: { token },
include: {
device: {
select: {
uuid: true,
name: true
}
}
},
include: { device: { select: { uuid: true } } },
});
const uuid = appInstall?.device?.uuid;
if (uuid && appInstall) {
// 检测设备信息
const userAgent = socket.handshake?.headers?.['user-agent'];
const detectedDeviceName = detectDeviceName(userAgent, socket.handshake?.headers);
// 拼接设备名称:检测到的设备信息 + token的note
let finalDeviceName = detectedDeviceName;
if (appInstall.note && appInstall.note.trim()) {
finalDeviceName = `${detectedDeviceName} - ${appInstall.note.trim()}`;
}
// 缓存令牌信息,使用拼接后的设备名称
const tokenInfo = {
appId: appInstall.appId,
isReadOnly: appInstall.isReadOnly,
deviceType: appInstall.deviceType,
note: appInstall.note,
deviceUuid: uuid,
deviceName: finalDeviceName, // 使用拼接后的设备名称
detectedDevice: detectedDeviceName, // 保留检测到的设备信息
originalNote: appInstall.note, // 保留原始备注
installedAt: appInstall.installedAt
};
tokenInfoCache.set(token, tokenInfo);
// 在socket上记录当前令牌信息
socket.data.currentToken = token;
socket.data.tokenInfo = tokenInfo;
if (uuid) {
joinDeviceRoom(socket, uuid);
// 跟踪 token 连接用于指标统计
trackTokenConnection(socket, token);
// 可选:回执
socket.emit("joined", {
by: "token",
uuid,
token,
tokenInfo: {
isReadOnly: tokenInfo.isReadOnly,
deviceType: tokenInfo.deviceType,
deviceName: tokenInfo.deviceName,
userAgent: userAgent
}
});
socket.emit("joined", { by: "token", uuid });
} else {
socket.emit("join-error", { by: "token", reason: "invalid_token" });
}
} catch (error) {
console.error("joinByToken error:", error);
socket.emit("join-error", { by: "token", reason: "database_error" });
}
}

View File

@ -1,298 +0,0 @@
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import {PrismaClient} from '@prisma/client';
const prisma = new PrismaClient();
// Token 配置
const ACCESS_TOKEN_SECRET = process.env.JWT_SECRET || 'your-access-token-secret-change-this-in-production';
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || 'your-refresh-token-secret-change-this-in-production';
// Token 过期时间配置
const ACCESS_TOKEN_EXPIRES_IN = process.env.ACCESS_TOKEN_EXPIRES_IN || '15m'; // 15分钟
const REFRESH_TOKEN_EXPIRES_IN = process.env.REFRESH_TOKEN_EXPIRES_IN || '7d'; // 7天
// JWT 算法配置
const JWT_ALG = (process.env.JWT_ALG || 'HS256').toUpperCase();
// RS256 密钥对如果使用RSA算法
const ACCESS_TOKEN_PRIVATE_KEY = process.env.ACCESS_TOKEN_PRIVATE_KEY?.replace(/\\n/g, '\n');
const ACCESS_TOKEN_PUBLIC_KEY = process.env.ACCESS_TOKEN_PUBLIC_KEY?.replace(/\\n/g, '\n');
const REFRESH_TOKEN_PRIVATE_KEY = process.env.REFRESH_TOKEN_PRIVATE_KEY?.replace(/\\n/g, '\n');
const REFRESH_TOKEN_PUBLIC_KEY = process.env.REFRESH_TOKEN_PUBLIC_KEY?.replace(/\\n/g, '\n');
/**
* 获取签名和验证密钥
*/
function getKeys(tokenType = 'access') {
if (JWT_ALG === 'RS256') {
const privateKey = tokenType === 'access' ? ACCESS_TOKEN_PRIVATE_KEY : REFRESH_TOKEN_PRIVATE_KEY;
const publicKey = tokenType === 'access' ? ACCESS_TOKEN_PUBLIC_KEY : REFRESH_TOKEN_PUBLIC_KEY;
if (!privateKey || !publicKey) {
throw new Error(`RS256 需要同时提供 ${tokenType.toUpperCase()}_TOKEN_PRIVATE_KEY 与 ${tokenType.toUpperCase()}_TOKEN_PUBLIC_KEY`);
}
return {signKey: privateKey, verifyKey: publicKey};
}
// 默认 HS256
const secret = tokenType === 'access' ? ACCESS_TOKEN_SECRET : REFRESH_TOKEN_SECRET;
return {signKey: secret, verifyKey: secret};
}
/**
* 生成访问令牌
*/
export function generateAccessToken(account) {
const {signKey} = getKeys('access');
const payload = {
type: 'access',
accountId: account.id,
provider: account.provider,
email: account.email,
name: account.name,
avatarUrl: account.avatarUrl,
tokenVersion: account.tokenVersion || 1,
};
return jwt.sign(payload, signKey, {
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
algorithm: JWT_ALG,
issuer: 'ClassworksKV',
audience: 'classworks-client',
});
}
/**
* 生成刷新令牌
*/
export function generateRefreshToken(account) {
const {signKey} = getKeys('refresh');
const payload = {
type: 'refresh',
accountId: account.id,
tokenVersion: account.tokenVersion || 1,
// 添加随机字符串增加安全性
jti: crypto.randomBytes(16).toString('hex'),
};
return jwt.sign(payload, signKey, {
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
algorithm: JWT_ALG,
issuer: 'ClassworksKV',
audience: 'classworks-client',
});
}
/**
* 验证访问令牌
*/
export function verifyAccessToken(token) {
const {verifyKey} = getKeys('access');
try {
const decoded = jwt.verify(token, verifyKey, {
algorithms: [JWT_ALG],
issuer: 'ClassworksKV',
audience: 'classworks-client',
});
if (decoded.type !== 'access') {
throw new Error('Invalid token type');
}
return decoded;
} catch (error) {
throw error;
}
}
/**
* 验证刷新令牌
*/
export function verifyRefreshToken(token) {
const {verifyKey} = getKeys('refresh');
try {
const decoded = jwt.verify(token, verifyKey, {
algorithms: [JWT_ALG],
issuer: 'ClassworksKV',
audience: 'classworks-client',
});
if (decoded.type !== 'refresh') {
throw new Error('Invalid token type');
}
return decoded;
} catch (error) {
throw error;
}
}
/**
* 生成令牌对访问令牌 + 刷新令牌
*/
export async function generateTokenPair(account) {
const accessToken = generateAccessToken(account);
const refreshToken = generateRefreshToken(account);
// 计算刷新令牌过期时间
const refreshTokenExpiry = new Date();
const expiresInMs = parseExpirationToMs(REFRESH_TOKEN_EXPIRES_IN);
refreshTokenExpiry.setTime(refreshTokenExpiry.getTime() + expiresInMs);
// 更新数据库中的刷新令牌
await prisma.account.update({
where: {id: account.id},
data: {
refreshToken,
refreshTokenExpiry,
updatedAt: new Date(),
},
});
return {
accessToken,
refreshToken,
accessTokenExpiresIn: ACCESS_TOKEN_EXPIRES_IN,
refreshTokenExpiresIn: REFRESH_TOKEN_EXPIRES_IN,
};
}
/**
* 刷新访问令牌
*/
export async function refreshAccessToken(refreshToken) {
try {
// 验证刷新令牌
const decoded = verifyRefreshToken(refreshToken);
// 从数据库获取账户信息
const account = await prisma.account.findUnique({
where: {id: decoded.accountId},
});
if (!account) {
throw new Error('Account not found');
}
// 验证刷新令牌是否匹配
if (account.refreshToken !== refreshToken) {
throw new Error('Invalid refresh token');
}
// 验证刷新令牌是否过期
if (account.refreshTokenExpiry && account.refreshTokenExpiry < new Date()) {
throw new Error('Refresh token expired');
}
// 验证令牌版本
if (account.tokenVersion !== decoded.tokenVersion) {
throw new Error('Token version mismatch');
}
// 生成新的访问令牌
const newAccessToken = generateAccessToken(account);
return {
accessToken: newAccessToken,
accessTokenExpiresIn: ACCESS_TOKEN_EXPIRES_IN,
account: {
id: account.id,
provider: account.provider,
email: account.email,
name: account.name,
avatarUrl: account.avatarUrl,
},
};
} catch (error) {
throw error;
}
}
/**
* 撤销所有令牌登出所有设备
*/
export async function revokeAllTokens(accountId) {
await prisma.account.update({
where: {id: accountId},
data: {
tokenVersion: {increment: 1},
refreshToken: null,
refreshTokenExpiry: null,
updatedAt: new Date(),
},
});
}
/**
* 撤销当前刷新令牌登出当前设备
*/
export async function revokeRefreshToken(accountId) {
await prisma.account.update({
where: {id: accountId},
data: {
refreshToken: null,
refreshTokenExpiry: null,
updatedAt: new Date(),
},
});
}
/**
* 解析过期时间字符串为毫秒
*/
function parseExpirationToMs(expiresIn) {
if (typeof expiresIn === 'number') {
return expiresIn * 1000;
}
const match = expiresIn.match(/^(\d+)([smhd])$/);
if (!match) {
throw new Error('Invalid expiration format');
}
const value = parseInt(match[1]);
const unit = match[2];
switch (unit) {
case 's':
return value * 1000;
case 'm':
return value * 60 * 1000;
case 'h':
return value * 60 * 60 * 1000;
case 'd':
return value * 24 * 60 * 60 * 1000;
default:
throw new Error('Invalid time unit');
}
}
/**
* 验证账户并检查令牌版本
*/
export async function validateAccountToken(decoded) {
const account = await prisma.account.findUnique({
where: {id: decoded.accountId},
});
if (!account) {
throw new Error('Account not found');
}
// 验证令牌版本
if (account.tokenVersion !== decoded.tokenVersion) {
throw new Error('Token version mismatch');
}
return account;
}
// 向后兼容的导出
export const signToken = generateAccessToken;
export const verifyToken = verifyAccessToken;
export const generateAccountToken = generateAccessToken;

View File

@ -8,10 +8,8 @@
</head>
<body>
<h1>Classworks 服务端</h1>
<p>服务运行中</p>
<h1>Classworks 服务端</h1>
<p>服务运行中</p>
</body>
<script>
window.open('https://kv.houlang.cloud')
</script>
</html>