mirror of
https://github.com/ZeroCatDev/ClassworksKV.git
synced 2025-12-07 21:13:10 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e3b3df1ae | ||
|
|
21d6ddf164 | ||
|
|
e65f84aa22 | ||
|
|
ab8904b549 | ||
|
|
da633ca5b6 | ||
|
|
1f68aea39f | ||
|
|
b782945674 | ||
|
|
1e1b99a070 | ||
|
|
63716e0429 | ||
|
|
b582521fee | ||
|
|
f985b6a11a | ||
|
|
f0de2cd59b | ||
|
|
d52ed81a29 | ||
|
|
e73ff53f58 | ||
|
|
79ec5b94a4 | ||
|
|
ddf001b1c1 | ||
|
|
7a010faa54 | ||
|
|
d6330c81fe | ||
|
|
c545612c9c | ||
|
|
4ec10acfcf | ||
|
|
398f79d5c9 | ||
|
|
4ae023afb0 | ||
|
|
4ff64ad514 | ||
|
|
a1deb5e6e3 | ||
|
|
78843418de | ||
|
|
43a49b6516 | ||
|
|
1d7078874b | ||
|
|
114069a999 | ||
|
|
87a408d904 | ||
|
|
296473633c | ||
|
|
2ab90ffebc | ||
|
|
9f051885c2 |
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
12
.idea/FixClassworksKV.iml
generated
Normal file
12
.idea/FixClassworksKV.iml
generated
Normal file
@ -0,0 +1,12 @@
|
||||
<?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
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<?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
Normal file
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
129
MIGRATION_CHECKLIST.md
Normal file
129
MIGRATION_CHECKLIST.md
Normal file
@ -0,0 +1,129 @@
|
||||
# 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完成
|
||||
- [ ] 单元测试通过
|
||||
- [ ] 集成测试通过
|
||||
- [ ] 性能测试通过
|
||||
|
||||
### 部署时
|
||||
- [ ] 数据库迁移执行
|
||||
- [ ] 环境变量配置
|
||||
- [ ] 服务重启验证
|
||||
- [ ] 健康检查通过
|
||||
|
||||
### 部署后
|
||||
- [ ] 新用户登录测试
|
||||
- [ ] 现有用户功能正常
|
||||
- [ ] 监控指标正常
|
||||
- [ ] 错误日志检查
|
||||
|
||||
## 🔄 回滚计划
|
||||
|
||||
### 紧急回滚
|
||||
- [ ] 回滚代码到上一版本
|
||||
- [ ] 恢复原环境变量
|
||||
- [ ] 数据库回滚方案(如需要)
|
||||
|
||||
### 数据迁移回滚
|
||||
- [ ] 备份新增字段数据
|
||||
- [ ] 移除新增字段的迁移脚本
|
||||
- [ ] 验证旧版功能正常
|
||||
|
||||
---
|
||||
|
||||
**检查完成人员**: ___________
|
||||
**检查完成时间**: ___________
|
||||
**环境**: [ ] 开发 [ ] 测试 [ ] 生产
|
||||
489
REFRESH_TOKEN_API.md
Normal file
489
REFRESH_TOKEN_API.md
Normal file
@ -0,0 +1,489 @@
|
||||
# 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逻辑
|
||||
112
REFRESH_TOKEN_QUICKSTART.md
Normal file
112
REFRESH_TOKEN_QUICKSTART.md
Normal file
@ -0,0 +1,112 @@
|
||||
# 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 Token(15分钟)
|
||||
- ✅ 长期Refresh Token(7天)
|
||||
- ✅ Token版本控制
|
||||
- ✅ 设备级登出
|
||||
- ✅ 全局登出
|
||||
- ✅ 自动刷新机制
|
||||
- ✅ 向后兼容
|
||||
|
||||
## 🔄 迁移步骤
|
||||
|
||||
1. **更新环境变量**
|
||||
2. **运行数据库迁移**
|
||||
3. **更新前端OAuth回调处理**
|
||||
4. **实现Token刷新逻辑**
|
||||
5. **测试登出功能**
|
||||
|
||||
详细文档请参考:`REFRESH_TOKEN_API.md`
|
||||
174
REFRESH_TOKEN_SUMMARY.md
Normal file
174
REFRESH_TOKEN_SUMMARY.md
Normal file
@ -0,0 +1,174 @@
|
||||
# 账户登录密钥系统重构完成报告
|
||||
|
||||
## 📋 项目概述
|
||||
|
||||
已成功重构ClassworksKV的账户登录密钥系统,从单一JWT令牌升级为标准的Refresh Token系统,大幅提升了安全性和用户体验。
|
||||
|
||||
## ✅ 完成的工作
|
||||
|
||||
### 1. 数据库架构更新
|
||||
- 在`Account`模型中添加了`refreshToken`、`refreshTokenExpiry`和`tokenVersion`字段
|
||||
- 支持令牌版本控制,可快速失效所有设备的令牌
|
||||
- 向后兼容现有数据
|
||||
|
||||
### 2. 核心Token管理系统
|
||||
- **创建 `utils/tokenManager.js`**: 全新的令牌管理核心
|
||||
- 生成Access Token(15分钟有效期)
|
||||
- 生成Refresh Token(7天有效期)
|
||||
- 支持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
|
||||
**兼容性**: 向后兼容,支持渐进式迁移
|
||||
126
app.js
126
app.js
@ -1,19 +1,13 @@
|
||||
import "./utils/instrumentation.js";
|
||||
// import createError from "http-errors";
|
||||
import express from "express";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import {dirname, join} 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";
|
||||
@ -21,16 +15,21 @@ 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秒),减少预检请求
|
||||
})
|
||||
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");
|
||||
|
||||
@ -38,75 +37,94 @@ 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")));
|
||||
|
||||
// 添加请求超时处理中间件
|
||||
app.use((req, res, next) => {
|
||||
// 设置默认请求超时时间为30秒
|
||||
const timeout = 30000;
|
||||
// 设置默认请求超时时间为30秒
|
||||
const timeout = 30000;
|
||||
|
||||
// 设置超时回调
|
||||
const timeoutCallback = () => {
|
||||
const timeoutError = errors.createError(408, "请求处理超时");
|
||||
next(timeoutError);
|
||||
};
|
||||
// 设置超时回调
|
||||
const timeoutCallback = () => {
|
||||
const timeoutError = errors.createError(408, "请求处理超时");
|
||||
next(timeoutError);
|
||||
};
|
||||
|
||||
// 设置超时
|
||||
req.setTimeout(timeout, timeoutCallback);
|
||||
// 设置超时
|
||||
req.setTimeout(timeout, timeoutCallback);
|
||||
|
||||
// 监听响应完成事件
|
||||
res.on("finish", () => {
|
||||
// 如果响应已经完成,清除超时处理
|
||||
req.setTimeout(0, timeoutCallback);
|
||||
});
|
||||
// 监听响应完成事件
|
||||
res.on("finish", () => {
|
||||
// 如果响应已经完成,清除超时处理
|
||||
req.setTimeout(0, timeoutCallback);
|
||||
});
|
||||
|
||||
next();
|
||||
next();
|
||||
});
|
||||
app.get("/", (req, res) => {
|
||||
res.render("index.ejs");
|
||||
res.render("index.ejs");
|
||||
});
|
||||
app.get("/check", apiLimiter, (req, res) => {
|
||||
res.json({
|
||||
status: "success",
|
||||
message: "API is running",
|
||||
time: new Date().getTime(),
|
||||
});
|
||||
app.get("/check", (req, res) => {
|
||||
res.json({
|
||||
status: "success",
|
||||
message: "Classworks KV 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", apiLimiter, appsRouter);
|
||||
app.use("/apps", appsRouter);
|
||||
|
||||
// Mount the Auto Auth router with API rate limiting
|
||||
app.use("/auto-auth", apiLimiter, autoAuthRouter);
|
||||
app.use("/auto-auth", autoAuthRouter);
|
||||
|
||||
// Mount the Device router with API rate limiting
|
||||
app.use("/devices", apiLimiter, deviceRouter);
|
||||
app.use("/devices", deviceRouter);
|
||||
|
||||
// Mount the KV store router with token-based rate limiting (更宽松的限速)
|
||||
app.use("/kv", tokenBasedRateLimiter, kvRouter);
|
||||
// Mount the KV store router
|
||||
app.use("/kv", kvRouter);
|
||||
|
||||
// Mount the Device Authorization router with API rate limiting
|
||||
app.use("/auth", apiLimiter, deviceAuthRouter);
|
||||
app.use("/auth", deviceAuthRouter);
|
||||
|
||||
// Mount the Accounts router with API rate limiting
|
||||
app.use("/accounts", apiLimiter, accountsRouter);
|
||||
app.use("/accounts", accountsRouter);
|
||||
|
||||
// 兜底404路由 - 处理所有未匹配的路由
|
||||
app.use((req, res, next) => {
|
||||
const notFoundError = errors.createError(404, `找不到路径: ${req.path}`);
|
||||
next(notFoundError);
|
||||
const notFoundError = errors.createError(404, `找不到路径: ${req.path}`);
|
||||
next(notFoundError);
|
||||
});
|
||||
|
||||
// 全局错误处理中间件
|
||||
@ -114,19 +132,19 @@ app.use(errorHandler);
|
||||
|
||||
// 全局未捕获的异常处理
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("未捕获的异常:", error);
|
||||
// 记录错误但不退出进程
|
||||
console.error("未捕获的异常:", error);
|
||||
// 记录错误但不退出进程
|
||||
});
|
||||
|
||||
// 全局未处理的Promise拒绝处理
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("未处理的Promise拒绝:", reason);
|
||||
// 记录错误但不退出进程
|
||||
console.error("未处理的Promise拒绝:", reason);
|
||||
// 记录错误但不退出进程
|
||||
});
|
||||
|
||||
// 处理 SIGTERM 信号
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("收到 SIGTERM 信号,准备关闭服务...");
|
||||
console.log("收到 SIGTERM 信号,准备关闭服务...");
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
70
bin/www
70
bin/www
@ -5,8 +5,9 @@
|
||||
*/
|
||||
|
||||
import app from '../app.js';
|
||||
import { createServer } from 'http';
|
||||
import { initSocket } from '../utils/socket.js';
|
||||
import {createServer} from 'http';
|
||||
import {initSocket} from '../utils/socket.js';
|
||||
import {initializeMetrics} from '../utils/metrics.js';
|
||||
|
||||
/**
|
||||
* Get port from environment and store in Express.
|
||||
@ -24,6 +25,9 @@ var server = createServer(app);
|
||||
// 初始化 Socket.IO 并绑定到 HTTP Server
|
||||
initSocket(server);
|
||||
|
||||
// 初始化 Prometheus 指标
|
||||
initializeMetrics();
|
||||
|
||||
/**
|
||||
* Listen on provided port, on all network interfaces.
|
||||
*/
|
||||
@ -37,19 +41,19 @@ server.on('listening', onListening);
|
||||
*/
|
||||
|
||||
function normalizePort(val) {
|
||||
var port = parseInt(val, 10);
|
||||
var port = parseInt(val, 10);
|
||||
|
||||
if (isNaN(port)) {
|
||||
// named pipe
|
||||
return val;
|
||||
}
|
||||
if (isNaN(port)) {
|
||||
// named pipe
|
||||
return val;
|
||||
}
|
||||
|
||||
if (port >= 0) {
|
||||
// port number
|
||||
return port;
|
||||
}
|
||||
if (port >= 0) {
|
||||
// port number
|
||||
return port;
|
||||
}
|
||||
|
||||
return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -57,27 +61,27 @@ function normalizePort(val) {
|
||||
*/
|
||||
|
||||
function onError(error) {
|
||||
if (error.syscall !== 'listen') {
|
||||
throw error;
|
||||
}
|
||||
if (error.syscall !== 'listen') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
var bind = typeof port === 'string'
|
||||
? 'Pipe ' + port
|
||||
: 'Port ' + port;
|
||||
var bind = typeof port === 'string'
|
||||
? 'Pipe ' + port
|
||||
: 'Port ' + port;
|
||||
|
||||
// handle specific listen errors with friendly messages
|
||||
switch (error.code) {
|
||||
case 'EACCES':
|
||||
console.error(bind + ' requires elevated privileges');
|
||||
process.exit(1);
|
||||
break;
|
||||
case 'EADDRINUSE':
|
||||
console.error(bind + ' is already in use');
|
||||
process.exit(1);
|
||||
break;
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
// handle specific listen errors with friendly messages
|
||||
switch (error.code) {
|
||||
case 'EACCES':
|
||||
console.error(bind + ' requires elevated privileges');
|
||||
process.exit(1);
|
||||
break;
|
||||
case 'EADDRINUSE':
|
||||
console.error(bind + ' is already in use');
|
||||
process.exit(1);
|
||||
break;
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -85,6 +89,6 @@ function onError(error) {
|
||||
*/
|
||||
|
||||
function onListening() {
|
||||
var addr = server.address();
|
||||
console.log(`Server running at http://0.0.0.0:${addr.port}`);
|
||||
var addr = server.address();
|
||||
console.log(`Server running at http://0.0.0.0:${addr.port}`);
|
||||
}
|
||||
|
||||
102
classworks.js
102
classworks.js
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { execSync } from "child_process";
|
||||
import {execSync} from "child_process";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
@ -7,78 +7,78 @@ dotenv.config();
|
||||
|
||||
// 🔄 执行数据库迁移函数
|
||||
function runDatabaseMigration() {
|
||||
try {
|
||||
console.log("🔄 执行数据库迁移...");
|
||||
execSync("npx prisma migrate deploy", { stdio: "inherit" });
|
||||
console.log("✅ 数据库迁移完成");
|
||||
} catch (error) {
|
||||
console.error("❌ 数据库迁移失败:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
console.log("🔄 执行数据库迁移...");
|
||||
execSync("npx prisma migrate deploy", {stdio: "inherit"});
|
||||
console.log("✅ 数据库迁移完成");
|
||||
} catch (error) {
|
||||
console.error("❌ 数据库迁移失败:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 🧱 数据库初始化函数
|
||||
function setupDatabase() {
|
||||
try {
|
||||
// 执行数据库迁移
|
||||
runDatabaseMigration();
|
||||
} catch (error) {
|
||||
console.error("❌ 数据库初始化失败:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
// 执行数据库迁移
|
||||
runDatabaseMigration();
|
||||
} catch (error) {
|
||||
console.error("❌ 数据库初始化失败:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔨 本地构建函数
|
||||
function buildLocal() {
|
||||
try {
|
||||
// 确保数据库迁移已执行
|
||||
runDatabaseMigration();
|
||||
execSync("npm install", { stdio: "inherit" }); // 安装依赖
|
||||
execSync("npx prisma generate", { stdio: "inherit" }); // 生成 Prisma 客户端
|
||||
console.log("✅ 构建完成");
|
||||
} catch (error) {
|
||||
console.error("❌ 构建失败:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
// 确保数据库迁移已执行
|
||||
runDatabaseMigration();
|
||||
execSync("npm install", {stdio: "inherit"}); // 安装依赖
|
||||
execSync("npx prisma generate", {stdio: "inherit"}); // 生成 Prisma 客户端
|
||||
console.log("✅ 构建完成");
|
||||
} catch (error) {
|
||||
console.error("❌ 构建失败:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 🚀 启动服务函数
|
||||
function startServer() {
|
||||
try {
|
||||
execSync("npm run start", { stdio: "inherit" }); // 启动项目
|
||||
} catch (error) {
|
||||
console.error("❌ 服务启动失败:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
execSync("npm run start", {stdio: "inherit"}); // 启动项目
|
||||
} catch (error) {
|
||||
console.error("❌ 服务启动失败:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ▶️ 执行 Prisma CLI 命令函数
|
||||
function runPrismaCommand(args) {
|
||||
try {
|
||||
const command = `npx prisma ${args.join(" ")}`;
|
||||
execSync(command, { stdio: "inherit" });
|
||||
} catch (error) {
|
||||
console.error("❌ Prisma 命令执行失败:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
const command = `npx prisma ${args.join(" ")}`;
|
||||
execSync(command, {stdio: "inherit"});
|
||||
} catch (error) {
|
||||
console.error("❌ Prisma 命令执行失败:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 🧠 主函数,根据命令行参数判断执行哪种流程
|
||||
async function main() {
|
||||
const args = process.argv.slice(2); // 获取命令行参数
|
||||
if (args[0] === "prisma") {
|
||||
// 如果输入的是 prisma 命令,则执行 prisma 子命令
|
||||
runPrismaCommand(args.slice(1));
|
||||
} else {
|
||||
// 否则按默认流程:初始化 → 构建 → 启动服务
|
||||
setupDatabase();
|
||||
buildLocal();
|
||||
startServer();
|
||||
}
|
||||
const args = process.argv.slice(2); // 获取命令行参数
|
||||
if (args[0] === "prisma") {
|
||||
// 如果输入的是 prisma 命令,则执行 prisma 子命令
|
||||
runPrismaCommand(args.slice(1));
|
||||
} else {
|
||||
// 否则按默认流程:初始化 → 构建 → 启动服务
|
||||
setupDatabase();
|
||||
buildLocal();
|
||||
startServer();
|
||||
}
|
||||
}
|
||||
|
||||
// 🚨 捕捉主函数异常
|
||||
main().catch((error) => {
|
||||
console.error("❌ 脚本执行失败:", error);
|
||||
process.exit(1);
|
||||
console.error("❌ 脚本执行失败:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@ -13,127 +13,127 @@
|
||||
|
||||
import http from 'http';
|
||||
import url from 'url';
|
||||
import { randomBytes } from 'crypto';
|
||||
import {randomBytes} from 'crypto';
|
||||
|
||||
// 配置
|
||||
const CONFIG = {
|
||||
// API服务器地址
|
||||
baseUrl: process.env.API_BASE_URL || 'http://localhost:3030',
|
||||
// 站点密钥
|
||||
siteKey: process.env.SITE_KEY || '',
|
||||
// 应用ID
|
||||
appId: process.env.APP_ID || '1',
|
||||
// 授权页面地址(Classworks前端)
|
||||
authPageUrl: process.env.FRONTEND_URL,
|
||||
// 本地回调服务器端口
|
||||
callbackPort: process.env.CALLBACK_PORT || '8080',
|
||||
// 回调路径
|
||||
callbackPath: '/callback',
|
||||
// 超时时间(秒)
|
||||
timeout: 300,
|
||||
// API服务器地址
|
||||
baseUrl: process.env.API_BASE_URL || 'http://localhost:3030',
|
||||
// 站点密钥
|
||||
siteKey: process.env.SITE_KEY || '',
|
||||
// 应用ID
|
||||
appId: process.env.APP_ID || '1',
|
||||
// 授权页面地址(Classworks前端)
|
||||
authPageUrl: process.env.FRONTEND_URL,
|
||||
// 本地回调服务器端口
|
||||
callbackPort: process.env.CALLBACK_PORT || '8080',
|
||||
// 回调路径
|
||||
callbackPath: '/callback',
|
||||
// 超时时间(秒)
|
||||
timeout: 300,
|
||||
};
|
||||
|
||||
// 颜色输出
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
};
|
||||
|
||||
function log(message, color = '') {
|
||||
console.log(`${color}${message}${colors.reset}`);
|
||||
console.log(`${color}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
function logSuccess(message) {
|
||||
log(`✓ ${message}`, colors.green);
|
||||
log(`✓ ${message}`, colors.green);
|
||||
}
|
||||
|
||||
function logError(message) {
|
||||
log(`✗ ${message}`, colors.red);
|
||||
log(`✗ ${message}`, colors.red);
|
||||
}
|
||||
|
||||
function logInfo(message) {
|
||||
log(`ℹ ${message}`, colors.cyan);
|
||||
log(`ℹ ${message}`, colors.cyan);
|
||||
}
|
||||
|
||||
function logWarning(message) {
|
||||
log(`⚠ ${message}`, colors.yellow);
|
||||
log(`⚠ ${message}`, colors.yellow);
|
||||
}
|
||||
|
||||
// HTTP请求封装
|
||||
async function request(path, options = {}) {
|
||||
const requestUrl = `${CONFIG.baseUrl}${path}`;
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
const requestUrl = `${CONFIG.baseUrl}${path}`;
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (CONFIG.siteKey) {
|
||||
headers['X-Site-Key'] = CONFIG.siteKey;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(requestUrl, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `HTTP ${response.status}`);
|
||||
if (CONFIG.siteKey) {
|
||||
headers['X-Site-Key'] = CONFIG.siteKey;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.message.includes('fetch')) {
|
||||
throw new Error(`无法连接到服务器: ${CONFIG.baseUrl}`);
|
||||
try {
|
||||
const response = await fetch(requestUrl, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.message.includes('fetch')) {
|
||||
throw new Error(`无法连接到服务器: ${CONFIG.baseUrl}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成随机状态字符串
|
||||
function generateState() {
|
||||
return randomBytes(16).toString('hex');
|
||||
return randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
// 获取设备UUID
|
||||
async function getDeviceUuid() {
|
||||
try {
|
||||
const deviceInfo = await request('/device/info');
|
||||
return deviceInfo.uuid;
|
||||
} catch (error) {
|
||||
// 如果设备不存在,生成新的UUID
|
||||
const uuid = randomBytes(16).toString('hex');
|
||||
logInfo(`生成新的设备UUID: ${uuid}`);
|
||||
return uuid;
|
||||
}
|
||||
try {
|
||||
const deviceInfo = await request('/device/info');
|
||||
return deviceInfo.uuid;
|
||||
} catch (error) {
|
||||
// 如果设备不存在,生成新的UUID
|
||||
const uuid = randomBytes(16).toString('hex');
|
||||
logInfo(`生成新的设备UUID: ${uuid}`);
|
||||
return uuid;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建回调服务器
|
||||
function createCallbackServer(state) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let server;
|
||||
let resolved = false;
|
||||
return new Promise((resolve, reject) => {
|
||||
let server;
|
||||
let resolved = false;
|
||||
|
||||
const handleRequest = (req, res) => {
|
||||
if (resolved) return;
|
||||
const handleRequest = (req, res) => {
|
||||
if (resolved) return;
|
||||
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
|
||||
if (parsedUrl.pathname === CONFIG.callbackPath) {
|
||||
const { token, error, state: returnedState } = parsedUrl.query;
|
||||
if (parsedUrl.pathname === CONFIG.callbackPath) {
|
||||
const {token, error, state: returnedState} = parsedUrl.query;
|
||||
|
||||
// 验证状态参数
|
||||
if (returnedState !== state) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
// 验证状态参数
|
||||
if (returnedState !== state) {
|
||||
res.writeHead(400, {'Content-Type': 'text/html; charset=utf-8'});
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@ -151,15 +151,15 @@ function createCallbackServer(state) {
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
resolved = true;
|
||||
server.close();
|
||||
reject(new Error('状态参数不匹配'));
|
||||
return;
|
||||
}
|
||||
resolved = true;
|
||||
server.close();
|
||||
reject(new Error('状态参数不匹配'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
if (error) {
|
||||
res.writeHead(400, {'Content-Type': 'text/html; charset=utf-8'});
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@ -177,15 +177,15 @@ function createCallbackServer(state) {
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
resolved = true;
|
||||
server.close();
|
||||
reject(new Error(error));
|
||||
return;
|
||||
}
|
||||
resolved = true;
|
||||
server.close();
|
||||
reject(new Error(error));
|
||||
return;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
if (token) {
|
||||
res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@ -205,15 +205,15 @@ function createCallbackServer(state) {
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
resolved = true;
|
||||
server.close();
|
||||
resolve(token);
|
||||
return;
|
||||
}
|
||||
resolved = true;
|
||||
server.close();
|
||||
resolve(token);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果没有token和error参数
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
// 如果没有token和error参数
|
||||
res.writeHead(400, {'Content-Type': 'text/html; charset=utf-8'});
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@ -231,191 +231,191 @@ function createCallbackServer(state) {
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
resolved = true;
|
||||
server.close();
|
||||
reject(new Error('缺少必要的参数'));
|
||||
} else {
|
||||
// 404 for other paths
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('Not Found');
|
||||
}
|
||||
};
|
||||
resolved = true;
|
||||
server.close();
|
||||
reject(new Error('缺少必要的参数'));
|
||||
} else {
|
||||
// 404 for other paths
|
||||
res.writeHead(404, {'Content-Type': 'text/plain'});
|
||||
res.end('Not Found');
|
||||
}
|
||||
};
|
||||
|
||||
server = http.createServer(handleRequest);
|
||||
server = http.createServer(handleRequest);
|
||||
|
||||
server.listen(CONFIG.callbackPort, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
logSuccess(`回调服务器已启动: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`);
|
||||
}
|
||||
server.listen(CONFIG.callbackPort, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
logSuccess(`回调服务器已启动: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 设置超时
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
server.close();
|
||||
reject(new Error('授权超时'));
|
||||
}
|
||||
}, CONFIG.timeout * 1000);
|
||||
|
||||
server.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 设置超时
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
server.close();
|
||||
reject(new Error('授权超时'));
|
||||
}
|
||||
}, CONFIG.timeout * 1000);
|
||||
|
||||
server.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 打开浏览器
|
||||
async function openBrowser(url) {
|
||||
const { spawn } = await import('child_process');
|
||||
const {spawn} = await import('child_process');
|
||||
|
||||
let command;
|
||||
let args;
|
||||
let command;
|
||||
let args;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
command = 'cmd';
|
||||
args = ['/c', 'start', url];
|
||||
} else if (process.platform === 'darwin') {
|
||||
command = 'open';
|
||||
args = [url];
|
||||
} else {
|
||||
command = 'xdg-open';
|
||||
args = [url];
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
command = 'cmd';
|
||||
args = ['/c', 'start', url];
|
||||
} else if (process.platform === 'darwin') {
|
||||
command = 'open';
|
||||
args = [url];
|
||||
} else {
|
||||
command = 'xdg-open';
|
||||
args = [url];
|
||||
}
|
||||
|
||||
try {
|
||||
spawn(command, args, { detached: true, stdio: 'ignore' });
|
||||
logSuccess('已尝试打开浏览器');
|
||||
} catch (error) {
|
||||
logWarning('无法自动打开浏览器,请手动打开授权链接');
|
||||
}
|
||||
try {
|
||||
spawn(command, args, {detached: true, stdio: 'ignore'});
|
||||
logSuccess('已尝试打开浏览器');
|
||||
} catch (error) {
|
||||
logWarning('无法自动打开浏览器,请手动打开授权链接');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示授权信息
|
||||
function displayAuthInfo(authUrl, deviceUuid, state) {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
log(` 请访问以下地址完成授权:`, colors.bright);
|
||||
console.log('');
|
||||
log(` ${authUrl}`, colors.cyan + colors.bright);
|
||||
console.log('');
|
||||
log(` 设备UUID: ${deviceUuid}`, colors.green);
|
||||
log(` 状态参数: ${state}`, colors.dim);
|
||||
console.log('='.repeat(60));
|
||||
logInfo(`回调地址: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`);
|
||||
logInfo(`API服务器: ${CONFIG.baseUrl}`);
|
||||
logInfo(`超时时间: ${CONFIG.timeout} 秒`);
|
||||
console.log('');
|
||||
console.log('\n' + '='.repeat(60));
|
||||
log(` 请访问以下地址完成授权:`, colors.bright);
|
||||
console.log('');
|
||||
log(` ${authUrl}`, colors.cyan + colors.bright);
|
||||
console.log('');
|
||||
log(` 设备UUID: ${deviceUuid}`, colors.green);
|
||||
log(` 状态参数: ${state}`, colors.dim);
|
||||
console.log('='.repeat(60));
|
||||
logInfo(`回调地址: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`);
|
||||
logInfo(`API服务器: ${CONFIG.baseUrl}`);
|
||||
logInfo(`超时时间: ${CONFIG.timeout} 秒`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// 保存令牌到文件
|
||||
async function saveToken(token) {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const os = await import('os');
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const os = await import('os');
|
||||
|
||||
const tokenDir = path.join(os.homedir(), '.classworks');
|
||||
const tokenFile = path.join(tokenDir, 'token-callback.txt');
|
||||
const tokenDir = path.join(os.homedir(), '.classworks');
|
||||
const tokenFile = path.join(tokenDir, 'token-callback.txt');
|
||||
|
||||
try {
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(tokenDir)) {
|
||||
fs.mkdirSync(tokenDir, { recursive: true });
|
||||
try {
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(tokenDir)) {
|
||||
fs.mkdirSync(tokenDir, {recursive: true});
|
||||
}
|
||||
|
||||
// 写入令牌
|
||||
fs.writeFileSync(tokenFile, token, 'utf8');
|
||||
logSuccess(`令牌已保存到: ${tokenFile}`);
|
||||
} catch (error) {
|
||||
logWarning(`无法保存令牌到文件: ${error.message}`);
|
||||
logInfo('您可以手动保存令牌');
|
||||
}
|
||||
|
||||
// 写入令牌
|
||||
fs.writeFileSync(tokenFile, token, 'utf8');
|
||||
logSuccess(`令牌已保存到: ${tokenFile}`);
|
||||
} catch (error) {
|
||||
logWarning(`无法保存令牌到文件: ${error.message}`);
|
||||
logInfo('您可以手动保存令牌');
|
||||
}
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
console.log('\n' + colors.cyan + colors.bright + '回调授权流程 - 令牌获取工具' + colors.reset + '\n');
|
||||
console.log('\n' + colors.cyan + colors.bright + '回调授权流程 - 令牌获取工具' + colors.reset + '\n');
|
||||
|
||||
try {
|
||||
// 检查配置
|
||||
if (!CONFIG.siteKey) {
|
||||
logWarning('未设置 SITE_KEY 环境变量,可能需要站点密钥才能访问');
|
||||
logInfo('设置方法: export SITE_KEY=your-site-key');
|
||||
console.log('');
|
||||
try {
|
||||
// 检查配置
|
||||
if (!CONFIG.siteKey) {
|
||||
logWarning('未设置 SITE_KEY 环境变量,可能需要站点密钥才能访问');
|
||||
logInfo('设置方法: export SITE_KEY=your-site-key');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// 1. 获取设备UUID
|
||||
logInfo('正在获取设备UUID...');
|
||||
const deviceUuid = await getDeviceUuid();
|
||||
logSuccess(`设备UUID: ${deviceUuid}`);
|
||||
|
||||
// 2. 生成状态参数
|
||||
const state = generateState();
|
||||
|
||||
// 3. 构建回调URL
|
||||
const callbackUrl = `http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`;
|
||||
|
||||
// 4. 构建授权URL
|
||||
const authUrl = new URL(CONFIG.authPageUrl);
|
||||
authUrl.searchParams.set('app_id', CONFIG.appId);
|
||||
authUrl.searchParams.set('mode', 'callback');
|
||||
authUrl.searchParams.set('callback_url', callbackUrl);
|
||||
authUrl.searchParams.set('state', state);
|
||||
|
||||
// 5. 显示授权信息
|
||||
displayAuthInfo(authUrl.toString(), deviceUuid, state);
|
||||
|
||||
// 6. 启动回调服务器
|
||||
logInfo('正在启动回调服务器...');
|
||||
const serverPromise = createCallbackServer(state);
|
||||
|
||||
// 7. 打开浏览器
|
||||
logInfo('正在尝试打开浏览器...');
|
||||
await openBrowser(authUrl.toString());
|
||||
|
||||
// 8. 等待授权完成
|
||||
logInfo('等待授权完成...\n');
|
||||
const token = await serverPromise;
|
||||
|
||||
// 9. 显示令牌
|
||||
console.log('\n' + '='.repeat(50));
|
||||
logSuccess('授权成功!令牌获取完成');
|
||||
console.log('='.repeat(50));
|
||||
console.log('\n' + colors.bright + '您的访问令牌:' + colors.reset);
|
||||
log(token, colors.green);
|
||||
console.log('');
|
||||
|
||||
// 10. 保存令牌
|
||||
await saveToken(token);
|
||||
|
||||
// 11. 使用示例
|
||||
console.log('\n' + colors.bright + '使用示例:' + colors.reset);
|
||||
console.log(` curl -H "Authorization: Bearer ${token}" ${CONFIG.baseUrl}/kv`);
|
||||
console.log('');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.log('');
|
||||
logError(`错误: ${error.message}`);
|
||||
|
||||
// 提供一些常见问题的解决方案
|
||||
if (error.message.includes('EADDRINUSE')) {
|
||||
logInfo(`端口 ${CONFIG.callbackPort} 已被占用,请尝试设置不同的端口:`);
|
||||
logInfo(`CALLBACK_PORT=8081 node cli/get-token-callback.js`);
|
||||
} else if (error.message.includes('无法连接到服务器')) {
|
||||
logInfo('请检查API服务器是否正在运行');
|
||||
logInfo(`当前API地址: ${CONFIG.baseUrl}`);
|
||||
} else if (error.message.includes('授权超时')) {
|
||||
logInfo(`授权超时(${CONFIG.timeout}秒),请重新尝试`);
|
||||
logInfo('您可以设置更长的超时时间:TIMEOUT=600 node cli/get-token-callback.js');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 1. 获取设备UUID
|
||||
logInfo('正在获取设备UUID...');
|
||||
const deviceUuid = await getDeviceUuid();
|
||||
logSuccess(`设备UUID: ${deviceUuid}`);
|
||||
|
||||
// 2. 生成状态参数
|
||||
const state = generateState();
|
||||
|
||||
// 3. 构建回调URL
|
||||
const callbackUrl = `http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`;
|
||||
|
||||
// 4. 构建授权URL
|
||||
const authUrl = new URL(CONFIG.authPageUrl);
|
||||
authUrl.searchParams.set('app_id', CONFIG.appId);
|
||||
authUrl.searchParams.set('mode', 'callback');
|
||||
authUrl.searchParams.set('callback_url', callbackUrl);
|
||||
authUrl.searchParams.set('state', state);
|
||||
|
||||
// 5. 显示授权信息
|
||||
displayAuthInfo(authUrl.toString(), deviceUuid, state);
|
||||
|
||||
// 6. 启动回调服务器
|
||||
logInfo('正在启动回调服务器...');
|
||||
const serverPromise = createCallbackServer(state);
|
||||
|
||||
// 7. 打开浏览器
|
||||
logInfo('正在尝试打开浏览器...');
|
||||
await openBrowser(authUrl.toString());
|
||||
|
||||
// 8. 等待授权完成
|
||||
logInfo('等待授权完成...\n');
|
||||
const token = await serverPromise;
|
||||
|
||||
// 9. 显示令牌
|
||||
console.log('\n' + '='.repeat(50));
|
||||
logSuccess('授权成功!令牌获取完成');
|
||||
console.log('='.repeat(50));
|
||||
console.log('\n' + colors.bright + '您的访问令牌:' + colors.reset);
|
||||
log(token, colors.green);
|
||||
console.log('');
|
||||
|
||||
// 10. 保存令牌
|
||||
await saveToken(token);
|
||||
|
||||
// 11. 使用示例
|
||||
console.log('\n' + colors.bright + '使用示例:' + colors.reset);
|
||||
console.log(` curl -H "Authorization: Bearer ${token}" ${CONFIG.baseUrl}/kv`);
|
||||
console.log('');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.log('');
|
||||
logError(`错误: ${error.message}`);
|
||||
|
||||
// 提供一些常见问题的解决方案
|
||||
if (error.message.includes('EADDRINUSE')) {
|
||||
logInfo(`端口 ${CONFIG.callbackPort} 已被占用,请尝试设置不同的端口:`);
|
||||
logInfo(`CALLBACK_PORT=8081 node cli/get-token-callback.js`);
|
||||
} else if (error.message.includes('无法连接到服务器')) {
|
||||
logInfo('请检查API服务器是否正在运行');
|
||||
logInfo(`当前API地址: ${CONFIG.baseUrl}`);
|
||||
} else if (error.message.includes('授权超时')) {
|
||||
logInfo(`授权超时(${CONFIG.timeout}秒),请重新尝试`);
|
||||
logInfo('您可以设置更长的超时时间:TIMEOUT=600 node cli/get-token-callback.js');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行
|
||||
|
||||
312
cli/get-token.js
312
cli/get-token.js
@ -10,223 +10,221 @@
|
||||
* 或配置为可执行:chmod +x cli/get-token.js && ./cli/get-token.js
|
||||
*/
|
||||
|
||||
import readline from 'readline';
|
||||
|
||||
// 配置
|
||||
const CONFIG = {
|
||||
// API服务器地址
|
||||
baseUrl: process.env.API_BASE_URL || 'http://localhost:3030',
|
||||
// 站点密钥
|
||||
siteKey: process.env.SITE_KEY || '',
|
||||
// 应用ID
|
||||
appId: process.env.APP_ID || '1',
|
||||
// 授权页面地址(Classworks前端)
|
||||
authPageUrl: process.env.FRONTEND_URL,
|
||||
// 轮询间隔(秒)
|
||||
pollInterval: 3,
|
||||
// 最大轮询次数
|
||||
maxPolls: 100,
|
||||
// API服务器地址
|
||||
baseUrl: process.env.API_BASE_URL || 'http://localhost:3030',
|
||||
// 站点密钥
|
||||
siteKey: process.env.SITE_KEY || '',
|
||||
// 应用ID
|
||||
appId: process.env.APP_ID || '1',
|
||||
// 授权页面地址(Classworks前端)
|
||||
authPageUrl: process.env.FRONTEND_URL,
|
||||
// 轮询间隔(秒)
|
||||
pollInterval: 3,
|
||||
// 最大轮询次数
|
||||
maxPolls: 100,
|
||||
};
|
||||
|
||||
// 颜色输出
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
};
|
||||
|
||||
function log(message, color = '') {
|
||||
console.log(`${color}${message}${colors.reset}`);
|
||||
console.log(`${color}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
function logSuccess(message) {
|
||||
log(`✓ ${message}`, colors.green);
|
||||
log(`✓ ${message}`, colors.green);
|
||||
}
|
||||
|
||||
function logError(message) {
|
||||
log(`✗ ${message}`, colors.red);
|
||||
log(`✗ ${message}`, colors.red);
|
||||
}
|
||||
|
||||
function logInfo(message) {
|
||||
log(`ℹ ${message}`, colors.cyan);
|
||||
log(`ℹ ${message}`, colors.cyan);
|
||||
}
|
||||
|
||||
function logWarning(message) {
|
||||
log(`⚠ ${message}`, colors.yellow);
|
||||
log(`⚠ ${message}`, colors.yellow);
|
||||
}
|
||||
|
||||
// HTTP请求封装
|
||||
async function request(path, options = {}) {
|
||||
const url = `${CONFIG.baseUrl}${path}`;
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
const url = `${CONFIG.baseUrl}${path}`;
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (CONFIG.siteKey) {
|
||||
headers['X-Site-Key'] = CONFIG.siteKey;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `HTTP ${response.status}`);
|
||||
if (CONFIG.siteKey) {
|
||||
headers['X-Site-Key'] = CONFIG.siteKey;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.message.includes('fetch')) {
|
||||
throw new Error(`无法连接到服务器: ${CONFIG.baseUrl}`);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.message.includes('fetch')) {
|
||||
throw new Error(`无法连接到服务器: ${CONFIG.baseUrl}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成设备代码
|
||||
async function generateDeviceCode() {
|
||||
logInfo('正在生成设备授权码...');
|
||||
const data = await request('/auth/device/code', {
|
||||
method: 'POST',
|
||||
});
|
||||
return data;
|
||||
logInfo('正在生成设备授权码...');
|
||||
const data = await request('/auth/device/code', {
|
||||
method: 'POST',
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
// 轮询获取令牌
|
||||
async function pollForToken(deviceCode) {
|
||||
let polls = 0;
|
||||
let polls = 0;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = async () => {
|
||||
polls++;
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = async () => {
|
||||
polls++;
|
||||
|
||||
if (polls > CONFIG.maxPolls) {
|
||||
reject(new Error('轮询超时,请重试'));
|
||||
return;
|
||||
}
|
||||
if (polls > CONFIG.maxPolls) {
|
||||
reject(new Error('轮询超时,请重试'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await request(`/auth/device/token?device_code=${deviceCode}`);
|
||||
try {
|
||||
const data = await request(`/auth/device/token?device_code=${deviceCode}`);
|
||||
|
||||
if (data.status === 'success') {
|
||||
resolve(data.token);
|
||||
} else if (data.status === 'expired') {
|
||||
reject(new Error('设备代码已过期'));
|
||||
} else if (data.status === 'pending') {
|
||||
// 继续轮询
|
||||
log(`等待授权... (${polls}/${CONFIG.maxPolls})`, colors.dim);
|
||||
setTimeout(poll, CONFIG.pollInterval * 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
if (data.status === 'success') {
|
||||
resolve(data.token);
|
||||
} else if (data.status === 'expired') {
|
||||
reject(new Error('设备代码已过期'));
|
||||
} else if (data.status === 'pending') {
|
||||
// 继续轮询
|
||||
log(`等待授权... (${polls}/${CONFIG.maxPolls})`, colors.dim);
|
||||
setTimeout(poll, CONFIG.pollInterval * 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 开始轮询
|
||||
poll();
|
||||
});
|
||||
// 开始轮询
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
// 显示设备代码和授权链接
|
||||
function displayDeviceCode(deviceCode, expiresIn) {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
log(` 请访问以下地址完成授权:`, colors.bright);
|
||||
console.log('');
|
||||
console.log('\n' + '='.repeat(60));
|
||||
log(` 请访问以下地址完成授权:`, colors.bright);
|
||||
console.log('');
|
||||
|
||||
// 构建授权URL
|
||||
const authUrl = `${CONFIG.authPageUrl}?app_id=${CONFIG.appId}&mode=devicecode&devicecode=${deviceCode}`;
|
||||
log(` ${authUrl}`, colors.cyan + colors.bright);
|
||||
console.log('');
|
||||
log(` 设备授权码: ${deviceCode}`, colors.green + colors.bright);
|
||||
console.log('='.repeat(60));
|
||||
logInfo(`授权码有效期: ${Math.floor(expiresIn / 60)} 分钟`);
|
||||
logInfo(`API服务器: ${CONFIG.baseUrl}`);
|
||||
console.log('');
|
||||
// 构建授权URL
|
||||
const authUrl = `${CONFIG.authPageUrl}?app_id=${CONFIG.appId}&mode=devicecode&devicecode=${deviceCode}`;
|
||||
log(` ${authUrl}`, colors.cyan + colors.bright);
|
||||
console.log('');
|
||||
log(` 设备授权码: ${deviceCode}`, colors.green + colors.bright);
|
||||
console.log('='.repeat(60));
|
||||
logInfo(`授权码有效期: ${Math.floor(expiresIn / 60)} 分钟`);
|
||||
logInfo(`API服务器: ${CONFIG.baseUrl}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// 保存令牌到文件
|
||||
async function saveToken(token) {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const os = await import('os');
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const os = await import('os');
|
||||
|
||||
const tokenDir = path.join(os.homedir(), '.classworks');
|
||||
const tokenFile = path.join(tokenDir, 'token.txt');
|
||||
const tokenDir = path.join(os.homedir(), '.classworks');
|
||||
const tokenFile = path.join(tokenDir, 'token.txt');
|
||||
|
||||
try {
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(tokenDir)) {
|
||||
fs.mkdirSync(tokenDir, { recursive: true });
|
||||
try {
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(tokenDir)) {
|
||||
fs.mkdirSync(tokenDir, {recursive: true});
|
||||
}
|
||||
|
||||
// 写入令牌
|
||||
fs.writeFileSync(tokenFile, token, 'utf8');
|
||||
logSuccess(`令牌已保存到: ${tokenFile}`);
|
||||
} catch (error) {
|
||||
logWarning(`无法保存令牌到文件: ${error.message}`);
|
||||
logInfo('您可以手动保存令牌');
|
||||
}
|
||||
|
||||
// 写入令牌
|
||||
fs.writeFileSync(tokenFile, token, 'utf8');
|
||||
logSuccess(`令牌已保存到: ${tokenFile}`);
|
||||
} catch (error) {
|
||||
logWarning(`无法保存令牌到文件: ${error.message}`);
|
||||
logInfo('您可以手动保存令牌');
|
||||
}
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
console.log('\n' + colors.cyan + colors.bright + '设备授权流程 - 令牌获取工具' + colors.reset + '\n');
|
||||
console.log('\n' + colors.cyan + colors.bright + '设备授权流程 - 令牌获取工具' + colors.reset + '\n');
|
||||
|
||||
try {
|
||||
// 检查配置
|
||||
if (!CONFIG.siteKey) {
|
||||
logWarning('未设置 SITE_KEY 环境变量,可能需要站点密钥才能访问');
|
||||
logInfo('设置方法: export SITE_KEY=your-site-key');
|
||||
console.log('');
|
||||
try {
|
||||
// 检查配置
|
||||
if (!CONFIG.siteKey) {
|
||||
logWarning('未设置 SITE_KEY 环境变量,可能需要站点密钥才能访问');
|
||||
logInfo('设置方法: export SITE_KEY=your-site-key');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// 1. 生成设备代码
|
||||
const {device_code, expires_in} = await generateDeviceCode();
|
||||
logSuccess('设备授权码生成成功!');
|
||||
|
||||
// 2. 显示设备代码和授权链接
|
||||
displayDeviceCode(device_code, expires_in);
|
||||
|
||||
// 3. 提示用户授权
|
||||
logInfo('请在浏览器中打开上述地址,或在授权页面手动输入设备代码');
|
||||
logInfo('等待授权中...\n');
|
||||
|
||||
// 4. 轮询获取令牌
|
||||
const token = await pollForToken(device_code);
|
||||
|
||||
// 5. 显示令牌
|
||||
console.log('\n' + '='.repeat(50));
|
||||
logSuccess('授权成功!令牌获取完成');
|
||||
console.log('='.repeat(50));
|
||||
console.log('\n' + colors.bright + '您的访问令牌:' + colors.reset);
|
||||
log(token, colors.green);
|
||||
console.log('');
|
||||
|
||||
// 6. 保存令牌
|
||||
await saveToken(token);
|
||||
|
||||
// 7. 使用示例
|
||||
console.log('\n' + colors.bright + '使用示例:' + colors.reset);
|
||||
console.log(` curl -H "Authorization: Bearer ${token}" ${CONFIG.baseUrl}/kv`);
|
||||
console.log('');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.log('');
|
||||
logError(`错误: ${error.message}`);
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 1. 生成设备代码
|
||||
const { device_code, expires_in } = await generateDeviceCode();
|
||||
logSuccess('设备授权码生成成功!');
|
||||
|
||||
// 2. 显示设备代码和授权链接
|
||||
displayDeviceCode(device_code, expires_in);
|
||||
|
||||
// 3. 提示用户授权
|
||||
logInfo('请在浏览器中打开上述地址,或在授权页面手动输入设备代码');
|
||||
logInfo('等待授权中...\n');
|
||||
|
||||
// 4. 轮询获取令牌
|
||||
const token = await pollForToken(device_code);
|
||||
|
||||
// 5. 显示令牌
|
||||
console.log('\n' + '='.repeat(50));
|
||||
logSuccess('授权成功!令牌获取完成');
|
||||
console.log('='.repeat(50));
|
||||
console.log('\n' + colors.bright + '您的访问令牌:' + colors.reset);
|
||||
log(token, colors.green);
|
||||
console.log('');
|
||||
|
||||
// 6. 保存令牌
|
||||
await saveToken(token);
|
||||
|
||||
// 7. 使用示例
|
||||
console.log('\n' + colors.bright + '使用示例:' + colors.reset);
|
||||
console.log(` curl -H "Authorization: Bearer ${token}" ${CONFIG.baseUrl}/kv`);
|
||||
console.log('');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.log('');
|
||||
logError(`错误: ${error.message}`);
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行
|
||||
|
||||
160
config/oauth.js
160
config/oauth.js
@ -1,82 +1,100 @@
|
||||
// OAuth 提供者配置
|
||||
export const oauthProviders = {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||
authorizationURL: "https://github.com/login/oauth/authorize",
|
||||
tokenURL: "https://github.com/login/oauth/access_token",
|
||||
userInfoURL: "https://api.github.com/user",
|
||||
scope: "read:user user:email",
|
||||
// 展示相关
|
||||
name: "GitHub",
|
||||
displayName: "GitHub",
|
||||
icon: "github",
|
||||
color: "#24292e",
|
||||
description: "使用 GitHub 账号登录",
|
||||
website: "https://github.com",
|
||||
},
|
||||
zerocat: {
|
||||
clientId: process.env.ZEROCAT_CLIENT_ID,
|
||||
clientSecret: process.env.ZEROCAT_CLIENT_SECRET,
|
||||
authorizationURL: "https://zerocat-api.houlangs.com/oauth/authorize",
|
||||
tokenURL: "https://zerocat-api.houlangs.com/oauth/token",
|
||||
userInfoURL: "https://zerocat-api.houlangs.com/oauth/userinfo",
|
||||
scope: "user:basic user:email",
|
||||
// 展示相关
|
||||
name: "ZeroCat",
|
||||
displayName: "ZeroCat",
|
||||
icon: "zerocat",
|
||||
color: "#415f91",
|
||||
description: "使用 ZeroCat 账号登录",
|
||||
website: "https://zerocat.dev",
|
||||
},
|
||||
stcn: {
|
||||
// STCN(Casdoor)- 标准 OIDC Provider
|
||||
clientId: process.env.STCN_CLIENT_ID,
|
||||
clientSecret: process.env.STCN_CLIENT_SECRET,
|
||||
// Casdoor 标准端点
|
||||
authorizationURL: "https://auth.smart-teach.cn/login/oauth/authorize",
|
||||
tokenURL: "https://auth.smart-teach.cn/api/login/oauth/access_token",
|
||||
userInfoURL: "https://auth.smart-teach.cn/api/userinfo",
|
||||
scope: "openid profile email offline_access",
|
||||
// 展示相关
|
||||
name: "stcn",
|
||||
displayName: "智教联盟账户",
|
||||
icon: "casdoor",
|
||||
color: "#1068af",
|
||||
description: "使用智教联盟账户登录",
|
||||
website: "https://auth.smart-teach.cn",
|
||||
tokenRequestFormat: "json", // Casdoor 推荐 JSON 提交
|
||||
},
|
||||
hly: {
|
||||
// 厚浪云(Logto) - OIDC Provider
|
||||
clientId: process.env.HLY_CLIENT_ID,
|
||||
clientSecret: process.env.HLY_CLIENT_SECRET,
|
||||
authorizationURL: "https://oauth.houlang.cloud/oidc/auth",
|
||||
tokenURL: "https://oauth.houlang.cloud/oidc/token",
|
||||
userInfoURL: "https://oauth.houlang.cloud/oidc/me",
|
||||
scope: "openid profile email offline_access",
|
||||
// 展示相关
|
||||
name: "厚浪云",
|
||||
displayName: "厚浪云",
|
||||
icon: "logto",
|
||||
color: "#2d53f8",
|
||||
textColor: "#ffffff",
|
||||
order: 40,
|
||||
description: "使用厚浪云账号登录",
|
||||
website: "https://houlang.cloud",
|
||||
pkce: true, // 启用PKCE支持
|
||||
},
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||
authorizationURL: "https://github.com/login/oauth/authorize",
|
||||
tokenURL: "https://github.com/login/oauth/access_token",
|
||||
userInfoURL: "https://api.github.com/user",
|
||||
scope: "read:user user:email",
|
||||
// 展示相关
|
||||
name: "GitHub",
|
||||
displayName: "GitHub",
|
||||
icon: "github",
|
||||
color: "#24292e",
|
||||
description: "使用 GitHub 账号登录",
|
||||
website: "https://github.com",
|
||||
},
|
||||
zerocat: {
|
||||
clientId: process.env.ZEROCAT_CLIENT_ID,
|
||||
clientSecret: process.env.ZEROCAT_CLIENT_SECRET,
|
||||
authorizationURL: "https://zerocat-api.houlangs.com/oauth/authorize",
|
||||
tokenURL: "https://zerocat-api.houlangs.com/oauth/token",
|
||||
userInfoURL: "https://zerocat-api.houlangs.com/oauth/userinfo",
|
||||
scope: "user:basic user:email",
|
||||
// 展示相关
|
||||
name: "ZeroCat",
|
||||
displayName: "ZeroCat",
|
||||
icon: "zerocat",
|
||||
color: "#415f91",
|
||||
description: "使用 ZeroCat 账号登录",
|
||||
website: "https://zerocat.dev",
|
||||
},
|
||||
stcn: {
|
||||
// STCN(Casdoor)- 标准 OIDC Provider
|
||||
clientId: process.env.STCN_CLIENT_ID,
|
||||
clientSecret: process.env.STCN_CLIENT_SECRET,
|
||||
// Casdoor 标准端点
|
||||
authorizationURL: "https://auth.smart-teach.cn/login/oauth/authorize",
|
||||
tokenURL: "https://auth.smart-teach.cn/api/login/oauth/access_token",
|
||||
userInfoURL: "https://auth.smart-teach.cn/api/userinfo",
|
||||
scope: "openid profile email offline_access",
|
||||
// 展示相关
|
||||
name: "stcn",
|
||||
displayName: "智教联盟账户",
|
||||
icon: "casdoor",
|
||||
color: "#1068af",
|
||||
description: "使用智教联盟账户登录",
|
||||
website: "https://auth.smart-teach.cn",
|
||||
tokenRequestFormat: "json", // Casdoor 推荐 JSON 提交
|
||||
},
|
||||
hly: {
|
||||
// 厚浪云(Logto) - OIDC Provider
|
||||
clientId: process.env.HLY_CLIENT_ID,
|
||||
clientSecret: process.env.HLY_CLIENT_SECRET,
|
||||
authorizationURL: "https://oauth.houlang.cloud/oidc/auth",
|
||||
tokenURL: "https://oauth.houlang.cloud/oidc/token",
|
||||
userInfoURL: "https://oauth.houlang.cloud/oidc/me",
|
||||
scope: "openid profile email offline_access",
|
||||
// 展示相关
|
||||
name: "厚浪云",
|
||||
displayName: "厚浪云",
|
||||
icon: "logto",
|
||||
color: "#2d53f8",
|
||||
textColor: "#ffffff",
|
||||
order: 40,
|
||||
description: "使用厚浪云账号登录",
|
||||
website: "https://houlang.cloud",
|
||||
pkce: true, // 启用PKCE支持
|
||||
},
|
||||
dlass: {
|
||||
// Dlass(Casdoor)- 标准 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
|
||||
export function getCallbackURL(provider) {
|
||||
const baseUrl = process.env.BASE_URL;
|
||||
return `${baseUrl}/accounts/oauth/${provider}/callback`;
|
||||
const baseUrl = process.env.BASE_URL;
|
||||
return `${baseUrl}/accounts/oauth/${provider}/callback`;
|
||||
}
|
||||
|
||||
// 生成随机state参数
|
||||
export function generateState() {
|
||||
return Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
return Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
}
|
||||
@ -7,17 +7,39 @@
|
||||
* 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 {verifyDevicePassword} from "../utils/crypto.js";
|
||||
import {analyzeDevice} from "../utils/deviceDetector.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
|
||||
*
|
||||
* 使用方式:
|
||||
@ -25,33 +47,46 @@ const prisma = new PrismaClient();
|
||||
* router.get('/path/:deviceUuid', deviceMiddleware, handler)
|
||||
*/
|
||||
export const deviceMiddleware = errors.catchAsync(async (req, res, next) => {
|
||||
const deviceUuid = req.params.deviceUuid || req.body.deviceUuid;
|
||||
const deviceUuid = req.params.deviceUuid || req.body.deviceUuid;
|
||||
|
||||
if (!deviceUuid) {
|
||||
return next(errors.createError(400, "缺少设备UUID"));
|
||||
}
|
||||
if (!deviceUuid) {
|
||||
return next(errors.createError(400, "缺少设备UUID"));
|
||||
}
|
||||
|
||||
// 查找或创建设备
|
||||
let device = await prisma.device.findUnique({
|
||||
where: { uuid: deviceUuid },
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
// 设备不存在,自动创建
|
||||
device = await prisma.device.create({
|
||||
data: {
|
||||
uuid: deviceUuid,
|
||||
name: null,
|
||||
password: null,
|
||||
passwordHint: null,
|
||||
accountId: null,
|
||||
},
|
||||
// 查找或创建设备
|
||||
let device = await prisma.device.findUnique({
|
||||
where: {uuid: deviceUuid},
|
||||
});
|
||||
}
|
||||
|
||||
// 将设备信息存储到res.locals
|
||||
res.locals.device = device;
|
||||
next();
|
||||
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,
|
||||
password: null,
|
||||
passwordHint: null,
|
||||
accountId: null,
|
||||
},
|
||||
});
|
||||
|
||||
// 为新创建的设备添加默认的自动登录配置
|
||||
await createDefaultAutoAuth(device.id);
|
||||
|
||||
// 将设备分析结果添加到响应中
|
||||
res.locals.deviceAnalysis = deviceAnalysis;
|
||||
}
|
||||
|
||||
// 将设备信息存储到res.locals
|
||||
res.locals.device = device;
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
@ -65,24 +100,24 @@ 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"));
|
||||
}
|
||||
if (!deviceUuid) {
|
||||
return next(errors.createError(400, "缺少设备UUID"));
|
||||
}
|
||||
|
||||
// 查找设备
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid: deviceUuid },
|
||||
});
|
||||
// 查找设备
|
||||
const device = await prisma.device.findUnique({
|
||||
where: {uuid: deviceUuid},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
// 将设备信息存储到res.locals
|
||||
res.locals.device = device;
|
||||
next();
|
||||
// 将设备信息存储到res.locals
|
||||
res.locals.device = device;
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
@ -98,29 +133,29 @@ export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) =>
|
||||
* router.post('/path', deviceMiddleware, passwordMiddleware, handler)
|
||||
*/
|
||||
export const passwordMiddleware = errors.catchAsync(async (req, res, next) => {
|
||||
const device = res.locals.device;
|
||||
const { password } = req.body;
|
||||
const device = res.locals.device;
|
||||
const {password} = req.body;
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(500, "设备信息未加载,请先使用deviceMiddleware"));
|
||||
}
|
||||
|
||||
// 如果设备绑定了账户,且请求中有账户信息且匹配,则跳过密码验证
|
||||
if (device.accountId && req.account && req.account.id === device.accountId) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// 如果设备有密码,验证密码
|
||||
if (device.password) {
|
||||
if (!password) {
|
||||
return next(errors.createError(401, "设备需要密码"));
|
||||
if (!device) {
|
||||
return next(errors.createError(500, "设备信息未加载,请先使用deviceMiddleware"));
|
||||
}
|
||||
|
||||
const isValid = await verifyDevicePassword(password, device.password);
|
||||
if (!isValid) {
|
||||
return next(errors.createError(401, "密码错误"));
|
||||
// 如果设备绑定了账户,且请求中有账户信息且匹配,则跳过密码验证
|
||||
if (device.accountId && req.account && req.account.id === device.accountId) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
// 如果设备有密码,验证密码
|
||||
if (device.password) {
|
||||
if (!password) {
|
||||
return next(errors.createError(401, "设备需要密码"));
|
||||
}
|
||||
|
||||
const isValid = await verifyDevicePassword(password, device.password);
|
||||
if (!isValid) {
|
||||
return next(errors.createError(401, "密码错误"));
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
@ -1,49 +1,51 @@
|
||||
import { isDevelopment } from "../utils/config.js";
|
||||
import {isDevelopment} from "../utils/config.js";
|
||||
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
// 判断响应是否已经发送
|
||||
if (res.headersSent) {
|
||||
return next(err);
|
||||
}
|
||||
console.error(err);
|
||||
|
||||
try {
|
||||
if (isDevelopment) {
|
||||
// 输出错误信息到控制台
|
||||
console.error("Error occurred:");
|
||||
console.error(err);
|
||||
// 判断响应是否已经发送
|
||||
if (res.headersSent) {
|
||||
return next(err);
|
||||
}
|
||||
// 提取错误信息
|
||||
const statusCode = err.statusCode || err.status || 500;
|
||||
const message = err.message || "服务器错误";
|
||||
const details = err.details || null;
|
||||
console.error(err);
|
||||
|
||||
// 返回统一格式的错误响应
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
message: message,
|
||||
details: details,
|
||||
error:
|
||||
process.env.NODE_ENV === "production"
|
||||
? undefined
|
||||
: {
|
||||
stack: err.stack,
|
||||
originalError: err.originalError
|
||||
? err.originalError.message
|
||||
: null,
|
||||
},
|
||||
});
|
||||
} catch (handlerError) {
|
||||
// 处理器本身出错的兜底方案
|
||||
console.error("Error in error handler:", handlerError);
|
||||
try {
|
||||
if (isDevelopment) {
|
||||
// 输出错误信息到控制台
|
||||
console.error("Error occurred:");
|
||||
console.error(err);
|
||||
}
|
||||
// 提取错误信息
|
||||
const statusCode = err.statusCode || err.status || 500;
|
||||
const message = err.message || "服务器错误";
|
||||
const details = err.details || null;
|
||||
const code = err.code || undefined;
|
||||
|
||||
// 确保能返回响应
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "服务器错误",
|
||||
details: "服务器处理错误时出现问题",
|
||||
});
|
||||
}
|
||||
// 返回统一格式的错误响应
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
message: message,
|
||||
code: code,
|
||||
details: details,
|
||||
error:
|
||||
process.env.NODE_ENV === "production"
|
||||
? undefined
|
||||
: {
|
||||
stack: err.stack,
|
||||
originalError: err.originalError
|
||||
? err.originalError.message
|
||||
: null,
|
||||
},
|
||||
});
|
||||
} catch (handlerError) {
|
||||
// 处理器本身出错的兜底方案
|
||||
console.error("Error in error handler:", handlerError);
|
||||
|
||||
// 确保能返回响应
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "服务器错误",
|
||||
details: "服务器处理错误时出现问题",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default errorHandler;
|
||||
|
||||
@ -1,54 +1,107 @@
|
||||
/**
|
||||
* 纯账户JWT认证中间件
|
||||
*
|
||||
* 只验证账户JWT是否正确,不需要设备上下文
|
||||
* 支持新的refresh token系统,验证access token
|
||||
* 如果access token即将过期,会在响应头中提供新的token
|
||||
* 适用于只需要账户验证的接口
|
||||
*/
|
||||
|
||||
import { verifyToken } from "../utils/jwt.js";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import {generateAccessToken, validateAccountToken, verifyAccessToken} from "../utils/tokenManager.js";
|
||||
import {verifyToken} from "../utils/jwt.js";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import errors from "../utils/errors.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* 纯JWT认证中间件
|
||||
* 只验证Bearer token并将账户信息存储到res.locals
|
||||
* 新的JWT认证中间件(支持refresh token系统)
|
||||
*/
|
||||
export const jwtAuth = async (req, res, next) => {
|
||||
try {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return next(errors.createError(401, "需要提供有效的JWT token"));
|
||||
}
|
||||
|
||||
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 {
|
||||
const decoded = verifyToken(token);
|
||||
|
||||
// 从数据库获取账户信息
|
||||
const account = await prisma.account.findUnique({
|
||||
where: {id: decoded.accountId},
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return next(errors.createError(401, "账户不存在"));
|
||||
}
|
||||
|
||||
// 将账户信息存储到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') {
|
||||
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"));
|
||||
}
|
||||
|
||||
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 ")) {
|
||||
return next(errors.createError(401, "需要提供有效的JWT token"));
|
||||
// 没有提供token,跳过认证
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
// 验证JWT token
|
||||
const decoded = verifyToken(token);
|
||||
|
||||
// 从数据库获取账户信息
|
||||
const account = await prisma.account.findUnique({
|
||||
where: { id: decoded.accountId },
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return next(errors.createError(401, "账户不存在"));
|
||||
}
|
||||
|
||||
// 将账户信息存储到res.locals
|
||||
res.locals.account = account;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return next(errors.createError(401, "无效的JWT token"));
|
||||
}
|
||||
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return next(errors.createError(401, "JWT token已过期"));
|
||||
}
|
||||
|
||||
return next(errors.createError(500, "认证过程出错"));
|
||||
}
|
||||
// 有token则进行验证
|
||||
return jwtAuth(req, res, next);
|
||||
};
|
||||
@ -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();
|
||||
@ -15,35 +15,35 @@ const prisma = new PrismaClient();
|
||||
* 从请求中提取token(支持多种方式),验证后将设备和应用信息注入到res.locals
|
||||
*/
|
||||
export const kvTokenAuth = async (req, res, next) => {
|
||||
try {
|
||||
// 从多种途径获取token
|
||||
const token = extractToken(req);
|
||||
try {
|
||||
// 从多种途径获取token
|
||||
const token = extractToken(req);
|
||||
|
||||
if (!token) {
|
||||
return next(errors.createError(401, "需要提供有效的token"));
|
||||
if (!token) {
|
||||
return next(errors.createError(401, "需要提供有效的token"));
|
||||
}
|
||||
|
||||
// 查找token对应的应用安装信息
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: {token},
|
||||
include: {
|
||||
device: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!appInstall) {
|
||||
return next(errors.createError(401, "无效的token"));
|
||||
}
|
||||
|
||||
// 将信息存储到res.locals供后续使用
|
||||
res.locals.device = appInstall.device;
|
||||
res.locals.appInstall = appInstall;
|
||||
res.locals.deviceId = appInstall.device.id;
|
||||
res.locals.token = token;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
// 查找token对应的应用安装信息
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: { token },
|
||||
include: {
|
||||
device: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!appInstall) {
|
||||
return next(errors.createError(401, "无效的token"));
|
||||
}
|
||||
|
||||
// 将信息存储到res.locals供后续使用
|
||||
res.locals.device = appInstall.device;
|
||||
res.locals.appInstall = appInstall;
|
||||
res.locals.deviceId = appInstall.device.id;
|
||||
res.locals.token = token;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@ -54,18 +54,18 @@ export const kvTokenAuth = async (req, res, next) => {
|
||||
* 3. Body: token 或 apptoken
|
||||
*/
|
||||
function extractToken(req) {
|
||||
// 优先从 Authorization header 提取 Bearer token(支持大小写)
|
||||
const authHeader = req.headers && (req.headers.authorization || req.headers.Authorization);
|
||||
if (authHeader) {
|
||||
const m = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
if (m) return m[1];
|
||||
}
|
||||
// 优先从 Authorization header 提取 Bearer token(支持大小写)
|
||||
const authHeader = req.headers && (req.headers.authorization || req.headers.Authorization);
|
||||
if (authHeader) {
|
||||
const m = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
if (m) return m[1];
|
||||
}
|
||||
|
||||
return (
|
||||
req.headers["x-app-token"] ||
|
||||
req.query.token ||
|
||||
req.query.apptoken ||
|
||||
(req.body && req.body.token) ||
|
||||
(req.body && req.body.apptoken)
|
||||
);
|
||||
return (
|
||||
req.headers["x-app-token"] ||
|
||||
req.query.token ||
|
||||
req.query.apptoken ||
|
||||
(req.body && req.body.token) ||
|
||||
(req.body && req.body.apptoken)
|
||||
);
|
||||
}
|
||||
@ -2,198 +2,118 @@ import rateLimit from "express-rate-limit";
|
||||
|
||||
// 获取客户端真实IP的函数
|
||||
export const getClientIp = (req) => {
|
||||
return (
|
||||
req.headers["x-forwarded-for"] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
req.connection.socket?.remoteAddress ||
|
||||
"0.0.0.0"
|
||||
);
|
||||
return (
|
||||
req.headers["x-forwarded-for"] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
req.connection.socket?.remoteAddress ||
|
||||
"0.0.0.0"
|
||||
);
|
||||
};
|
||||
|
||||
// 从请求中提取Token的函数
|
||||
const extractToken = (req) => {
|
||||
return (
|
||||
req.headers["x-app-token"] ||
|
||||
req.query.apptoken ||
|
||||
req.body?.apptoken ||
|
||||
null
|
||||
);
|
||||
return (
|
||||
req.headers["x-app-token"] ||
|
||||
req.query.apptoken ||
|
||||
req.body?.apptoken ||
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
// 获取限速键:优先使用token,没有token则使用IP
|
||||
export const getRateLimitKey = (req) => {
|
||||
const token = extractToken(req);
|
||||
if (token) {
|
||||
return `token:${token}`;
|
||||
}
|
||||
return `ip:${getClientIp(req)}`;
|
||||
const token = extractToken(req);
|
||||
if (token) {
|
||||
return `token:${token}`;
|
||||
}
|
||||
return `ip:${getClientIp(req)}`;
|
||||
};
|
||||
|
||||
// 配置全局限速中间件
|
||||
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, // 失败的请求也计入限制
|
||||
});
|
||||
// 纯基于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提取
|
||||
|
||||
// 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,
|
||||
});
|
||||
if (!token) {
|
||||
// 如果没有token,返回一个特殊键用于统一限制
|
||||
return "no-token";
|
||||
}
|
||||
return `token:${token}`;
|
||||
};
|
||||
|
||||
// 写操作限速器(更严格)
|
||||
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,
|
||||
});
|
||||
// 创建一个中间件来将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 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({
|
||||
windowMs: 30 * 60 * 1000, // 30分钟
|
||||
limit: 5, // 每个IP在windowMs时间内最多允许5次认证尝试
|
||||
standardHeaders: "draft-7",
|
||||
legacyHeaders: false,
|
||||
message: "认证请求过于频繁,请30分钟后再试",
|
||||
keyGenerator: getClientIp,
|
||||
skipSuccessfulRequests: true, // 成功的认证不计入限制
|
||||
skipFailedRequests: false, // 失败的认证计入限制
|
||||
windowMs: 30 * 60 * 1000, // 30分钟
|
||||
limit: 5, // 每个IP在windowMs时间内最多允许5次认证尝试
|
||||
standardHeaders: "draft-7",
|
||||
legacyHeaders: false,
|
||||
message: "认证请求过于频繁,请30分钟后再试",
|
||||
keyGenerator: getClientIp,
|
||||
skipSuccessfulRequests: true, // 成功的认证不计入限制
|
||||
skipFailedRequests: false, // 失败的认证计入限制
|
||||
});
|
||||
|
||||
// 批量操作限速器(比写操作更严格)
|
||||
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 专用限速器(更宽松的限制,纯基于Token) ===
|
||||
|
||||
// Token 读操作限速器
|
||||
export const tokenReadLimiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1分钟
|
||||
limit: 1024, // 每个token在1分钟内最多1024次读操作
|
||||
standardHeaders: "draft-7",
|
||||
legacyHeaders: false,
|
||||
message: "读操作请求过于频繁,请稍后再试",
|
||||
keyGenerator: getRateLimitKey,
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: false,
|
||||
windowMs: 1 * 60 * 1000, // 1分钟
|
||||
limit: 1024, // 每个token在1分钟内最多1024次读操作
|
||||
standardHeaders: "draft-7",
|
||||
legacyHeaders: false,
|
||||
message: "读操作请求过于频繁,请稍后再试",
|
||||
keyGenerator: getTokenOnlyKey,
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: false,
|
||||
});
|
||||
|
||||
// Token 写操作限速器
|
||||
export const tokenWriteLimiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1分钟
|
||||
limit: 512, // 每个token在1分钟内最多512次写操作
|
||||
standardHeaders: "draft-7",
|
||||
legacyHeaders: false,
|
||||
message: "写操作请求过于频繁,请稍后再试",
|
||||
keyGenerator: getRateLimitKey,
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: false,
|
||||
windowMs: 1 * 60 * 1000, // 1分钟
|
||||
limit: 512, // 每个token在1分钟内最多512次写操作
|
||||
standardHeaders: "draft-7",
|
||||
legacyHeaders: false,
|
||||
message: "写操作请求过于频繁,请稍后再试",
|
||||
keyGenerator: getTokenOnlyKey,
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: false,
|
||||
});
|
||||
|
||||
// Token 删除操作限速器
|
||||
export const tokenDeleteLimiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1分钟
|
||||
limit: 256, // 每个token在1分钟内最多256次删除操作
|
||||
standardHeaders: "draft-7",
|
||||
legacyHeaders: false,
|
||||
message: "删除操作请求过于频繁,请稍后再试",
|
||||
keyGenerator: getRateLimitKey,
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: false,
|
||||
windowMs: 1 * 60 * 1000, // 1分钟
|
||||
limit: 256, // 每个token在1分钟内最多256次删除操作
|
||||
standardHeaders: "draft-7",
|
||||
legacyHeaders: false,
|
||||
message: "删除操作请求过于频繁,请稍后再试",
|
||||
keyGenerator: getTokenOnlyKey,
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: false,
|
||||
});
|
||||
|
||||
// Token 批量操作限速器
|
||||
export const tokenBatchLimiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1分钟
|
||||
limit: 128, // 每个token在1分钟内最多128次批量操作
|
||||
standardHeaders: "draft-7",
|
||||
legacyHeaders: false,
|
||||
message: "批量操作请求过于频繁,请稍后再试",
|
||||
keyGenerator: getRateLimitKey,
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: false,
|
||||
windowMs: 1 * 60 * 1000, // 1分钟
|
||||
limit: 128, // 每个token在1分钟内最多128次批量操作
|
||||
standardHeaders: "draft-7",
|
||||
legacyHeaders: false,
|
||||
message: "批量操作请求过于频繁,请稍后再试",
|
||||
keyGenerator: getTokenOnlyKey,
|
||||
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);
|
||||
};
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -17,115 +17,131 @@ const prisma = new PrismaClient();
|
||||
* UUID+密码/JWT混合认证中间件
|
||||
*/
|
||||
export const uuidAuth = async (req, res, next) => {
|
||||
try {
|
||||
// 1. 获取UUID(必需)
|
||||
const uuid = extractUuid(req);
|
||||
if (!uuid) {
|
||||
return next(errors.createError(400, "需要提供设备UUID"));
|
||||
}
|
||||
try {
|
||||
// 1. 获取UUID(必需)
|
||||
const uuid = extractUuid(req);
|
||||
if (!uuid) {
|
||||
return next(errors.createError(400, "需要提供设备UUID"));
|
||||
}
|
||||
|
||||
// 2. 查找设备并存储到locals
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
// 存储设备信息到locals
|
||||
res.locals.device = device;
|
||||
res.locals.deviceId = device.id;
|
||||
|
||||
// 3. 验证密码或JWT(二选一)
|
||||
const password = extractPassword(req);
|
||||
const jwt = extractJWT(req);
|
||||
|
||||
if (jwt) {
|
||||
// 验证账户JWT
|
||||
try {
|
||||
const accountPayload = await verifyAccountJWT(jwt);
|
||||
const account = await prisma.account.findUnique({
|
||||
where: { id: accountPayload.accountId },
|
||||
include: {
|
||||
devices: {
|
||||
where: { uuid },
|
||||
select: { id: true }
|
||||
}
|
||||
}
|
||||
// 2. 查找设备并存储到locals
|
||||
const device = await prisma.device.findUnique({
|
||||
where: {uuid},
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return next(errors.createError(401, "账户不存在"));
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
// 检查设备是否绑定到此账户
|
||||
if (account.devices.length === 0) {
|
||||
return next(errors.createError(403, "设备未绑定到此账户"));
|
||||
// 存储设备信息到locals
|
||||
res.locals.device = device;
|
||||
res.locals.deviceId = device.id;
|
||||
|
||||
// 3. 验证密码或JWT(二选一)
|
||||
const password = extractPassword(req);
|
||||
const jwt = extractJWT(req);
|
||||
|
||||
if (jwt) {
|
||||
// 验证账户JWT
|
||||
try {
|
||||
const accountPayload = await verifyAccountJWT(jwt);
|
||||
const account = await prisma.account.findUnique({
|
||||
where: {id: accountPayload.accountId},
|
||||
include: {
|
||||
devices: {
|
||||
where: {uuid},
|
||||
select: {id: true}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return next(errors.createError(401, "账户不存在"));
|
||||
}
|
||||
|
||||
// 检查设备是否绑定到此账户
|
||||
if (account.devices.length === 0) {
|
||||
return next(errors.createError(403, "设备未绑定到此账户"));
|
||||
}
|
||||
|
||||
res.locals.account = account;
|
||||
res.locals.isAccountOwner = true; // 标记为账户拥有者
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(errors.createError(401, "无效的JWT token"));
|
||||
}
|
||||
} else if (password) {
|
||||
// 验证设备密码
|
||||
if (!device.password) {
|
||||
return next(); // 如果设备未设置密码,允许无密码访问
|
||||
}
|
||||
|
||||
const isValid = await verifyDevicePassword(password, device.password);
|
||||
if (!isValid) {
|
||||
return next(errors.createError(401, "密码错误"));
|
||||
}
|
||||
|
||||
return next();
|
||||
} else {
|
||||
// 如果设备未设置密码,允许无密码访问
|
||||
if (!device.password) {
|
||||
return next();
|
||||
}
|
||||
return next(errors.createError(401, "需要提供密码或JWT token"));
|
||||
}
|
||||
|
||||
res.locals.account = account;
|
||||
res.locals.isAccountOwner = true; // 标记为账户拥有者
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(errors.createError(401, "无效的JWT token"));
|
||||
}
|
||||
} else if (password) {
|
||||
// 验证设备密码
|
||||
if (!device.password) {
|
||||
return next(); // 如果设备未设置密码,允许无密码访问
|
||||
}
|
||||
|
||||
const isValid = await verifyDevicePassword(password, device.password);
|
||||
if (!isValid) {
|
||||
return next(errors.createError(401, "密码错误"));
|
||||
}
|
||||
|
||||
return next();
|
||||
} else {
|
||||
// 如果设备未设置密码,允许无密码访问
|
||||
if (!device.password) {
|
||||
return next();
|
||||
}
|
||||
return next(errors.createError(401, "需要提供密码或JWT token"));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
} catch (error) {
|
||||
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
|
||||
*/
|
||||
function extractUuid(req) {
|
||||
return (
|
||||
req.headers["x-device-uuid"] ||
|
||||
req.query.uuid ||
|
||||
req.params.uuid ||
|
||||
req.params.deviceUuid ||
|
||||
(req.body && req.body.uuid) ||
|
||||
(req.body && req.body.deviceUuid)
|
||||
);
|
||||
return (
|
||||
req.headers["x-device-uuid"] ||
|
||||
req.query.uuid ||
|
||||
req.params.uuid ||
|
||||
req.params.deviceUuid ||
|
||||
(req.body && req.body.uuid) ||
|
||||
(req.body && req.body.deviceUuid)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中提取密码
|
||||
*/
|
||||
function extractPassword(req) {
|
||||
return (
|
||||
req.headers["x-device-password"] ||
|
||||
req.query.password ||
|
||||
req.query.currentPassword
|
||||
);
|
||||
return (
|
||||
req.headers["x-device-password"] ||
|
||||
req.query.password ||
|
||||
req.query.currentPassword
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中提取JWT
|
||||
*/
|
||||
function extractJWT(req) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
return null;
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
9032
package-lock.json
generated
Normal file
9032
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ClassworksKV",
|
||||
"version": "1.1.2",
|
||||
"version": "1.3.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node ./bin/www",
|
||||
@ -30,6 +30,8 @@
|
||||
"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
33
pnpm-lock.yaml
generated
@ -71,6 +71,12 @@ 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
|
||||
@ -1433,6 +1439,9 @@ 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==}
|
||||
|
||||
@ -2091,6 +2100,10 @@ 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==}
|
||||
|
||||
@ -2212,6 +2225,10 @@ 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'}
|
||||
@ -2391,6 +2408,9 @@ 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==}
|
||||
|
||||
@ -4035,6 +4055,8 @@ snapshots:
|
||||
|
||||
bignumber.js@9.3.0: {}
|
||||
|
||||
bintrees@1.0.2: {}
|
||||
|
||||
birpc@2.6.1: {}
|
||||
|
||||
body-parser@2.2.0:
|
||||
@ -4691,6 +4713,8 @@ snapshots:
|
||||
|
||||
node-addon-api@8.3.1: {}
|
||||
|
||||
node-device-detector@2.2.4: {}
|
||||
|
||||
node-fetch-native@1.6.7: {}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
@ -4792,6 +4816,11 @@ 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
|
||||
@ -5067,6 +5096,10 @@ 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:
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `Account` ADD COLUMN `refreshToken` TEXT NULL,
|
||||
ADD COLUMN `refreshTokenExpiry` DATETIME(3) NULL,
|
||||
ADD COLUMN `tokenVersion` INTEGER NOT NULL DEFAULT 1;
|
||||
@ -29,7 +29,10 @@ model Account {
|
||||
name String? // 用户名称
|
||||
avatarUrl String? // 用户头像URL
|
||||
providerData Json? // OAuth提供者返回的完整信息
|
||||
accessToken String? @db.Text // 账户访问令牌
|
||||
accessToken String? @db.Text // 账户访问令牌
|
||||
refreshToken String? @db.Text // 刷新令牌
|
||||
refreshTokenExpiry DateTime? // 刷新令牌过期时间
|
||||
tokenVersion Int @default(1) // 令牌版本,用于令牌失效
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@ -1,166 +1,172 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录失败</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
<meta charset="UTF-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<title>登录失败</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: shake 0.5s ease;
|
||||
}
|
||||
.error-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: shake 0.5s ease;
|
||||
}
|
||||
|
||||
.error-icon svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
stroke: white;
|
||||
stroke-width: 3;
|
||||
}
|
||||
.error-icon svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
stroke: white;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(5px); }
|
||||
}
|
||||
@keyframes shake {
|
||||
0%, 100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10%, 30%, 50%, 70%, 90% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
20%, 40%, 60%, 80% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #1f2937;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
h1 {
|
||||
color: #1f2937;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #fee2e2;
|
||||
border-radius: 8px;
|
||||
color: #991b1b;
|
||||
}
|
||||
.error-message {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #fee2e2;
|
||||
border-radius: 8px;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.error-code {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.retry-btn {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: #4338ca;
|
||||
}
|
||||
.retry-btn:hover {
|
||||
background: #4338ca;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.close-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
.help-text {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="container">
|
||||
<div class="error-icon">
|
||||
<svg fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<svg fill="none" viewBox="0 0 24 24">
|
||||
<path d="M6 18L18 6M6 6l12 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1>登录失败</h1>
|
||||
|
||||
<div class="error-message">
|
||||
<div id="errorMsg">认证过程中出现错误</div>
|
||||
<div class="error-code" id="errorCode"></div>
|
||||
<div id="errorMsg">认证过程中出现错误</div>
|
||||
<div class="error-code" id="errorCode"></div>
|
||||
</div>
|
||||
|
||||
<a href="javascript:history.back()" class="retry-btn">返回重试</a>
|
||||
<a class="retry-btn" href="javascript:history.back()">返回重试</a>
|
||||
<button class="close-btn" onclick="window.close()">关闭窗口</button>
|
||||
|
||||
<div class="help-text">
|
||||
如果问题持续存在,请检查:<br>
|
||||
• OAuth应用配置是否正确<br>
|
||||
• 回调URL是否已添加到OAuth应用中<br>
|
||||
• 环境变量是否配置正确
|
||||
如果问题持续存在,请检查:<br>
|
||||
• OAuth应用配置是否正确<br>
|
||||
• 回调URL是否已添加到OAuth应用中<br>
|
||||
• 环境变量是否配置正确
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script>
|
||||
// 从URL获取错误信息
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const error = params.get('error');
|
||||
|
||||
if (error) {
|
||||
const errorMessages = {
|
||||
'invalid_state': 'State验证失败,可能存在CSRF攻击',
|
||||
'access_denied': '用户拒绝了授权请求',
|
||||
'temporarily_unavailable': '服务暂时不可用,请稍后重试'
|
||||
};
|
||||
const errorMessages = {
|
||||
'invalid_state': 'State验证失败,可能存在CSRF攻击',
|
||||
'access_denied': '用户拒绝了授权请求',
|
||||
'temporarily_unavailable': '服务暂时不可用,请稍后重试'
|
||||
};
|
||||
|
||||
const errorMsg = errorMessages[error] || '未知错误';
|
||||
document.getElementById('errorMsg').textContent = errorMsg;
|
||||
document.getElementById('errorCode').textContent = `错误代码: ${error}`;
|
||||
const errorMsg = errorMessages[error] || '未知错误';
|
||||
document.getElementById('errorMsg').textContent = errorMsg;
|
||||
document.getElementById('errorCode').textContent = `错误代码: ${error}`;
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,160 +1,160 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录成功</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
<meta charset="UTF-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<title>登录成功</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: scaleIn 0.5s ease;
|
||||
}
|
||||
.success-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: scaleIn 0.5s ease;
|
||||
}
|
||||
|
||||
.success-icon svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
stroke: white;
|
||||
stroke-width: 3;
|
||||
}
|
||||
.success-icon svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
stroke: white;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #1f2937;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
h1 {
|
||||
color: #1f2937;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.provider {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.provider {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.token-container {
|
||||
background: #f3f4f6;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.token-container {
|
||||
background: #f3f4f6;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.token-label {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.token-label {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.token {
|
||||
color: #1f2937;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
user-select: all;
|
||||
}
|
||||
.token {
|
||||
color: #1f2937;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.copy-btn {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: #4338ca;
|
||||
}
|
||||
.copy-btn:hover {
|
||||
background: #4338ca;
|
||||
}
|
||||
|
||||
.copy-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.copy-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.copy-btn.copied {
|
||||
background: #10b981;
|
||||
}
|
||||
.copy-btn.copied {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.auto-close {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.auto-close {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
color: #4f46e5;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
.countdown {
|
||||
color: #4f46e5;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="container">
|
||||
<div class="success-icon">
|
||||
<svg fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<svg fill="none" viewBox="0 0 24 24">
|
||||
<path d="M5 13l4 4L19 7" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1>登录成功</h1>
|
||||
<p class="provider" id="provider">OAuth Provider</p>
|
||||
|
||||
<div class="token-container">
|
||||
<div class="token-label">访问令牌</div>
|
||||
<div class="token" id="token">加载中...</div>
|
||||
<div class="token-label">访问令牌</div>
|
||||
<div class="token" id="token">加载中...</div>
|
||||
</div>
|
||||
|
||||
<button class="copy-btn" id="copyBtn" onclick="copyToken()">复制令牌</button>
|
||||
|
||||
<div class="auto-close">
|
||||
窗口将在 <span class="countdown" id="countdown">10</span> 秒后自动关闭
|
||||
窗口将在 <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');
|
||||
@ -162,63 +162,63 @@
|
||||
|
||||
// 显示信息
|
||||
if (token) {
|
||||
document.getElementById('token').textContent = token;
|
||||
document.getElementById('token').textContent = token;
|
||||
|
||||
// 保存到localStorage(前端应用可以读取)
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('auth_provider', provider);
|
||||
// 保存到localStorage(前端应用可以读取)
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('auth_provider', provider);
|
||||
|
||||
// 触发storage事件,通知其他窗口
|
||||
window.dispatchEvent(new StorageEvent('storage', {
|
||||
key: 'auth_token',
|
||||
newValue: token,
|
||||
url: window.location.href
|
||||
}));
|
||||
// 触发storage事件,通知其他窗口
|
||||
window.dispatchEvent(new StorageEvent('storage', {
|
||||
key: 'auth_token',
|
||||
newValue: token,
|
||||
url: window.location.href
|
||||
}));
|
||||
} else {
|
||||
document.getElementById('token').textContent = '未获取到令牌';
|
||||
document.getElementById('token').textContent = '未获取到令牌';
|
||||
}
|
||||
|
||||
if (provider) {
|
||||
const providerNames = {
|
||||
'github': 'GitHub',
|
||||
'zerocat': 'ZeroCat'
|
||||
};
|
||||
document.getElementById('provider').textContent = `通过 ${providerNames[provider] || provider} 登录`;
|
||||
const providerNames = {
|
||||
'github': 'GitHub',
|
||||
'zerocat': 'ZeroCat'
|
||||
};
|
||||
document.getElementById('provider').textContent = `通过 ${providerNames[provider] || provider} 登录`;
|
||||
}
|
||||
|
||||
// 复制令牌
|
||||
function copyToken() {
|
||||
if (!token) return;
|
||||
if (!token) return;
|
||||
|
||||
navigator.clipboard.writeText(token).then(() => {
|
||||
const btn = document.getElementById('copyBtn');
|
||||
btn.textContent = '已复制';
|
||||
btn.classList.add('copied');
|
||||
navigator.clipboard.writeText(token).then(() => {
|
||||
const btn = document.getElementById('copyBtn');
|
||||
btn.textContent = '已复制';
|
||||
btn.classList.add('copied');
|
||||
|
||||
setTimeout(() => {
|
||||
btn.textContent = '复制令牌';
|
||||
btn.classList.remove('copied');
|
||||
}, 2000);
|
||||
}).catch(() => {
|
||||
// 降级方案
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = token;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
setTimeout(() => {
|
||||
btn.textContent = '复制令牌';
|
||||
btn.classList.remove('copied');
|
||||
}, 2000);
|
||||
}).catch(() => {
|
||||
// 降级方案
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = token;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
const btn = document.getElementById('copyBtn');
|
||||
btn.textContent = '已复制';
|
||||
btn.classList.add('copied');
|
||||
const btn = document.getElementById('copyBtn');
|
||||
btn.textContent = '已复制';
|
||||
btn.classList.add('copied');
|
||||
|
||||
setTimeout(() => {
|
||||
btn.textContent = '复制令牌';
|
||||
btn.classList.remove('copied');
|
||||
}, 2000);
|
||||
});
|
||||
setTimeout(() => {
|
||||
btn.textContent = '复制令牌';
|
||||
btn.classList.remove('copied');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// 倒计时关闭
|
||||
@ -226,29 +226,29 @@
|
||||
const countdownEl = document.getElementById('countdown');
|
||||
|
||||
const timer = setInterval(() => {
|
||||
countdown--;
|
||||
countdownEl.textContent = countdown;
|
||||
countdown--;
|
||||
countdownEl.textContent = countdown;
|
||||
|
||||
if (countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
if (countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
|
||||
// 尝试关闭窗口
|
||||
window.close();
|
||||
// 尝试关闭窗口
|
||||
window.close();
|
||||
|
||||
// 如果无法关闭(比如不是通过脚本打开的),显示提示
|
||||
setTimeout(() => {
|
||||
if (!window.closed) {
|
||||
countdownEl.parentElement.innerHTML = '您可以关闭此窗口了';
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
// 如果无法关闭(比如不是通过脚本打开的),显示提示
|
||||
setTimeout(() => {
|
||||
if (!window.closed) {
|
||||
countdownEl.parentElement.innerHTML = '您可以关闭此窗口了';
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// 如果用户有任何交互,停止自动关闭
|
||||
document.addEventListener('click', () => {
|
||||
clearInterval(timer);
|
||||
document.querySelector('.auto-close').style.display = 'none';
|
||||
clearInterval(timer);
|
||||
document.querySelector('.auto-close').style.display = 'none';
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,8 +1,8 @@
|
||||
body {
|
||||
padding: 50px;
|
||||
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
|
||||
padding: 50px;
|
||||
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #00B7FF;
|
||||
color: #00B7FF;
|
||||
}
|
||||
|
||||
1184
routes/accounts.js
1184
routes/accounts.js
File diff suppressed because it is too large
Load Diff
553
routes/apps.js
553
routes/apps.js
@ -1,12 +1,11 @@
|
||||
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 {Router} from "express";
|
||||
import {uuidAuth} from "../middleware/uuidAuth.js";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import crypto from "crypto";
|
||||
import errors from "../utils/errors.js";
|
||||
import { verifyDevicePassword } from "../utils/crypto.js";
|
||||
import {verifyDevicePassword} from "../utils/crypto.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@ -15,35 +14,35 @@ const prisma = new PrismaClient();
|
||||
* 获取设备安装的应用列表 (公开接口,无需认证)
|
||||
*/
|
||||
router.get(
|
||||
"/devices/:uuid/apps",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid } = req.params;
|
||||
"/devices/:uuid/apps",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {uuid} = req.params;
|
||||
|
||||
// 查找设备
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
});
|
||||
// 查找设备
|
||||
const device = await prisma.device.findUnique({
|
||||
where: {uuid},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
const installations = await prisma.appInstall.findMany({
|
||||
where: { deviceId: device.id },
|
||||
});
|
||||
const installations = await prisma.appInstall.findMany({
|
||||
where: {deviceId: device.id},
|
||||
});
|
||||
|
||||
const apps = installations.map(install => ({
|
||||
appId: install.appId,
|
||||
token: install.token,
|
||||
note: install.note,
|
||||
installedAt: install.createdAt,
|
||||
}));
|
||||
const apps = installations.map(install => ({
|
||||
appId: install.appId,
|
||||
token: install.token,
|
||||
note: install.note,
|
||||
installedAt: install.createdAt,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
apps,
|
||||
});
|
||||
})
|
||||
return res.json({
|
||||
success: true,
|
||||
apps,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -52,35 +51,35 @@ router.get(
|
||||
* appId 现在是 SHA256 hash
|
||||
*/
|
||||
router.post(
|
||||
"/devices/:uuid/install/:appId",
|
||||
uuidAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const device = res.locals.device;
|
||||
const { appId } = req.params;
|
||||
const { note } = req.body;
|
||||
"/devices/:uuid/install/:appId",
|
||||
uuidAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const device = res.locals.device;
|
||||
const {appId} = req.params;
|
||||
const {note} = req.body;
|
||||
|
||||
// 生成token
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
// 生成token
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
// 创建安装记录
|
||||
const installation = await prisma.appInstall.create({
|
||||
data: {
|
||||
deviceId: device.id,
|
||||
appId: appId,
|
||||
token,
|
||||
note: note || null,
|
||||
},
|
||||
});
|
||||
// 创建安装记录
|
||||
const installation = await prisma.appInstall.create({
|
||||
data: {
|
||||
deviceId: device.id,
|
||||
appId: appId,
|
||||
token,
|
||||
note: note || null,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
id: installation.id,
|
||||
appId: installation.appId,
|
||||
token: installation.token,
|
||||
note: installation.note,
|
||||
name: installation.note, // 备注同时作为名称返回
|
||||
installedAt: installation.createdAt,
|
||||
});
|
||||
})
|
||||
return res.status(201).json({
|
||||
id: installation.id,
|
||||
appId: installation.appId,
|
||||
token: installation.token,
|
||||
note: installation.note,
|
||||
name: installation.note, // 备注同时作为名称返回
|
||||
installedAt: installation.createdAt,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -88,31 +87,31 @@ router.post(
|
||||
* 卸载设备应用 (需要UUID认证)
|
||||
*/
|
||||
router.delete(
|
||||
"/devices/:uuid/uninstall/:installId",
|
||||
uuidAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const device = res.locals.device;
|
||||
const { installId } = req.params;
|
||||
"/devices/:uuid/uninstall/:installId",
|
||||
uuidAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const device = res.locals.device;
|
||||
const {installId} = req.params;
|
||||
|
||||
const installation = await prisma.appInstall.findUnique({
|
||||
where: { id: installId },
|
||||
});
|
||||
const installation = await prisma.appInstall.findUnique({
|
||||
where: {id: installId},
|
||||
});
|
||||
|
||||
if (!installation) {
|
||||
return next(errors.createError(404, "应用未安装"));
|
||||
}
|
||||
if (!installation) {
|
||||
return next(errors.createError(404, "应用未安装"));
|
||||
}
|
||||
|
||||
// 确保安装记录属于当前设备
|
||||
if (installation.deviceId !== device.id) {
|
||||
return next(errors.createError(403, "无权操作此安装记录"));
|
||||
}
|
||||
// 确保安装记录属于当前设备
|
||||
if (installation.deviceId !== device.id) {
|
||||
return next(errors.createError(403, "无权操作此安装记录"));
|
||||
}
|
||||
|
||||
await prisma.appInstall.delete({
|
||||
where: { id: installation.id },
|
||||
});
|
||||
await prisma.appInstall.delete({
|
||||
where: {id: installation.id},
|
||||
});
|
||||
|
||||
return res.status(204).end();
|
||||
})
|
||||
return res.status(204).end();
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -120,44 +119,44 @@ router.delete(
|
||||
* 获取设备的token列表 (需要设备UUID)
|
||||
*/
|
||||
router.get(
|
||||
"/tokens",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid } = req.query;
|
||||
"/tokens",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {uuid} = req.query;
|
||||
|
||||
if (!uuid) {
|
||||
return next(errors.createError(400, "需要提供设备UUID"));
|
||||
}
|
||||
if (!uuid) {
|
||||
return next(errors.createError(400, "需要提供设备UUID"));
|
||||
}
|
||||
|
||||
// 查找设备
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
});
|
||||
// 查找设备
|
||||
const device = await prisma.device.findUnique({
|
||||
where: {uuid},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
// 获取该设备的所有应用安装记录(即token)
|
||||
const installations = await prisma.appInstall.findMany({
|
||||
where: { deviceId: device.id },
|
||||
orderBy: { installedAt: 'desc' },
|
||||
});
|
||||
// 获取该设备的所有应用安装记录(即token)
|
||||
const installations = await prisma.appInstall.findMany({
|
||||
where: {deviceId: device.id},
|
||||
orderBy: {installedAt: 'desc'},
|
||||
});
|
||||
|
||||
const tokens = installations.map(install => ({
|
||||
id: install.id,
|
||||
token: install.token,
|
||||
appId: install.appId,
|
||||
installedAt: install.installedAt,
|
||||
note: install.note,
|
||||
name: install.note, // 备注同时作为名称返回
|
||||
}));
|
||||
const tokens = installations.map(install => ({
|
||||
id: install.id,
|
||||
token: install.token,
|
||||
appId: install.appId,
|
||||
installedAt: install.installedAt,
|
||||
note: install.note,
|
||||
name: install.note, // 备注同时作为名称返回
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
tokens,
|
||||
deviceUuid: uuid,
|
||||
});
|
||||
})
|
||||
return res.json({
|
||||
success: true,
|
||||
tokens,
|
||||
deviceUuid: uuid,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -166,97 +165,97 @@ router.get(
|
||||
* Body: { namespace: string, password: string, appId: string }
|
||||
*/
|
||||
router.post(
|
||||
"/auth/token",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { namespace, password, appId } = req.body;
|
||||
"/auth/token",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {namespace, password, appId} = req.body;
|
||||
|
||||
if (!namespace) {
|
||||
return next(errors.createError(400, "需要提供 namespace"));
|
||||
}
|
||||
|
||||
if (!appId) {
|
||||
return next(errors.createError(400, "需要提供 appId"));
|
||||
}
|
||||
|
||||
// 通过 namespace 查找设备
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { namespace },
|
||||
include: {
|
||||
autoAuths: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在或 namespace 不正确"));
|
||||
}
|
||||
|
||||
// 查找匹配的自动授权配置
|
||||
let matchedAutoAuth = null;
|
||||
|
||||
// 如果提供了密码,查找匹配密码的自动授权
|
||||
if (password) {
|
||||
// 首先尝试直接匹配明文密码
|
||||
matchedAutoAuth = device.autoAuths.find(auth => auth.password === password);
|
||||
|
||||
// 如果没有匹配到,尝试验证哈希密码(向后兼容)
|
||||
if (!matchedAutoAuth) {
|
||||
for (const autoAuth of device.autoAuths) {
|
||||
if (autoAuth.password && autoAuth.password.startsWith('$2')) { // bcrypt 哈希以 $2 开头
|
||||
try {
|
||||
if (await verifyDevicePassword(password, autoAuth.password)) {
|
||||
matchedAutoAuth = autoAuth;
|
||||
|
||||
// 自动迁移:将哈希密码更新为明文密码
|
||||
await prisma.autoAuth.update({
|
||||
where: { id: autoAuth.id },
|
||||
data: { password: password }, // 保存明文密码
|
||||
});
|
||||
|
||||
console.log(`AutoAuth ${autoAuth.id} 密码已自动迁移为明文`);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
// 如果验证失败,继续尝试下一个
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!namespace) {
|
||||
return next(errors.createError(400, "需要提供 namespace"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedAutoAuth) {
|
||||
return next(errors.createError(401, "密码不正确"));
|
||||
}
|
||||
} else {
|
||||
// 如果没有提供密码,查找密码为空的自动授权
|
||||
matchedAutoAuth = device.autoAuths.find(auth => !auth.password);
|
||||
if (!appId) {
|
||||
return next(errors.createError(400, "需要提供 appId"));
|
||||
}
|
||||
|
||||
if (!matchedAutoAuth) {
|
||||
return next(errors.createError(401, "需要提供密码"));
|
||||
}
|
||||
}
|
||||
// 通过 namespace 查找设备
|
||||
const device = await prisma.device.findUnique({
|
||||
where: {namespace},
|
||||
include: {
|
||||
autoAuths: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 根据自动授权配置创建 AppInstall
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在或 namespace 不正确"));
|
||||
}
|
||||
|
||||
const installation = await prisma.appInstall.create({
|
||||
data: {
|
||||
deviceId: device.id,
|
||||
appId: appId,
|
||||
token,
|
||||
note: null,
|
||||
isReadOnly: matchedAutoAuth.isReadOnly,
|
||||
deviceType: matchedAutoAuth.deviceType,
|
||||
},
|
||||
});
|
||||
// 查找匹配的自动授权配置
|
||||
let matchedAutoAuth = null;
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
token: installation.token,
|
||||
deviceType: installation.deviceType,
|
||||
isReadOnly: installation.isReadOnly,
|
||||
installedAt: installation.installedAt,
|
||||
});
|
||||
})
|
||||
// 如果提供了密码,查找匹配密码的自动授权
|
||||
if (password) {
|
||||
// 首先尝试直接匹配明文密码
|
||||
matchedAutoAuth = device.autoAuths.find(auth => auth.password === password);
|
||||
|
||||
// 如果没有匹配到,尝试验证哈希密码(向后兼容)
|
||||
if (!matchedAutoAuth) {
|
||||
for (const autoAuth of device.autoAuths) {
|
||||
if (autoAuth.password && autoAuth.password.startsWith('$2')) { // bcrypt 哈希以 $2 开头
|
||||
try {
|
||||
if (await verifyDevicePassword(password, autoAuth.password)) {
|
||||
matchedAutoAuth = autoAuth;
|
||||
|
||||
// 自动迁移:将哈希密码更新为明文密码
|
||||
await prisma.autoAuth.update({
|
||||
where: {id: autoAuth.id},
|
||||
data: {password: password}, // 保存明文密码
|
||||
});
|
||||
|
||||
console.log(`AutoAuth ${autoAuth.id} 密码已自动迁移为明文`);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
// 如果验证失败,继续尝试下一个
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedAutoAuth) {
|
||||
return next(errors.createError(401, "密码不正确"));
|
||||
}
|
||||
} else {
|
||||
// 如果没有提供密码,查找密码为空的自动授权
|
||||
matchedAutoAuth = device.autoAuths.find(auth => !auth.password);
|
||||
|
||||
if (!matchedAutoAuth) {
|
||||
return next(errors.createError(401, "需要提供密码"));
|
||||
}
|
||||
}
|
||||
|
||||
// 根据自动授权配置创建 AppInstall
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
const installation = await prisma.appInstall.create({
|
||||
data: {
|
||||
deviceId: device.id,
|
||||
appId: appId,
|
||||
token,
|
||||
note: null,
|
||||
isReadOnly: matchedAutoAuth.isReadOnly,
|
||||
deviceType: matchedAutoAuth.deviceType,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
token: installation.token,
|
||||
deviceType: installation.deviceType,
|
||||
isReadOnly: installation.isReadOnly,
|
||||
installedAt: installation.installedAt,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -265,78 +264,78 @@ router.post(
|
||||
* Body: { name: string }
|
||||
*/
|
||||
router.post(
|
||||
"/tokens/:token/set-student-name",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { token } = req.params;
|
||||
const { name } = req.body;
|
||||
"/tokens/:token/set-student-name",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {token} = req.params;
|
||||
const {name} = req.body;
|
||||
|
||||
if (!name) {
|
||||
return next(errors.createError(400, "需要提供学生名称"));
|
||||
}
|
||||
if (!name) {
|
||||
return next(errors.createError(400, "需要提供学生名称"));
|
||||
}
|
||||
|
||||
// 查找 token 对应的应用安装记录
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: { token },
|
||||
include: {
|
||||
device: true,
|
||||
},
|
||||
});
|
||||
// 查找 token 对应的应用安装记录
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: {token},
|
||||
include: {
|
||||
device: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!appInstall) {
|
||||
return next(errors.createError(404, "Token 不存在"));
|
||||
}
|
||||
if (!appInstall) {
|
||||
return next(errors.createError(404, "Token 不存在"));
|
||||
}
|
||||
|
||||
// 验证 token 类型是否为 student
|
||||
if (appInstall.deviceType !== 'student') {
|
||||
return next(errors.createError(403, "只有学生类型的 token 可以设置名称"));
|
||||
}
|
||||
// 验证 token 类型是否为 student
|
||||
if (!['student', 'parent'].includes(appInstall.deviceType)) {
|
||||
return next(errors.createError(403, "只有学生和家长类型的 token 可以设置名称"));
|
||||
}
|
||||
|
||||
// 读取设备的 classworks-list-main 键值
|
||||
const kvRecord = await prisma.kVStore.findUnique({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: appInstall.deviceId,
|
||||
key: 'classworks-list-main',
|
||||
},
|
||||
},
|
||||
});
|
||||
// 读取设备的 classworks-list-main 键值
|
||||
const kvRecord = await prisma.kVStore.findUnique({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: appInstall.deviceId,
|
||||
key: 'classworks-list-main',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!kvRecord) {
|
||||
return next(errors.createError(404, "设备未设置学生列表"));
|
||||
}
|
||||
if (!kvRecord) {
|
||||
return next(errors.createError(404, "设备未设置学生列表"));
|
||||
}
|
||||
|
||||
// 解析学生列表
|
||||
let studentList;
|
||||
try {
|
||||
studentList = kvRecord.value;
|
||||
if (!Array.isArray(studentList)) {
|
||||
return next(errors.createError(500, "学生列表格式错误"));
|
||||
}
|
||||
} catch (error) {
|
||||
return next(errors.createError(500, "无法解析学生列表"));
|
||||
}
|
||||
// 解析学生列表
|
||||
let studentList;
|
||||
try {
|
||||
studentList = kvRecord.value;
|
||||
if (!Array.isArray(studentList)) {
|
||||
return next(errors.createError(500, "学生列表格式错误"));
|
||||
}
|
||||
} catch (error) {
|
||||
return next(errors.createError(500, "无法解析学生列表"));
|
||||
}
|
||||
|
||||
// 验证名称是否在学生列表中
|
||||
const studentExists = studentList.some(student => student.name === name);
|
||||
// 验证名称是否在学生列表中
|
||||
const studentExists = studentList.some(student => student.name === name);
|
||||
|
||||
if (!studentExists) {
|
||||
return next(errors.createError(400, "该名称不在学生列表中"));
|
||||
}
|
||||
if (!studentExists) {
|
||||
return next(errors.createError(400, "该名称不在学生列表中"));
|
||||
}
|
||||
|
||||
// 更新 AppInstall 的 note 字段
|
||||
const updatedInstall = await prisma.appInstall.update({
|
||||
where: { id: appInstall.id },
|
||||
data: { note: name },
|
||||
});
|
||||
// 更新 AppInstall 的 note 字段
|
||||
const updatedInstall = await prisma.appInstall.update({
|
||||
where: {id: appInstall.id},
|
||||
data: {note: appInstall.deviceType === 'parent' ? `${name} 家长` : name},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
token: updatedInstall.token,
|
||||
name: updatedInstall.note,
|
||||
deviceType: updatedInstall.deviceType,
|
||||
updatedAt: updatedInstall.updatedAt,
|
||||
});
|
||||
})
|
||||
return res.json({
|
||||
success: true,
|
||||
token: updatedInstall.token,
|
||||
name: updatedInstall.note,
|
||||
deviceType: updatedInstall.deviceType,
|
||||
updatedAt: updatedInstall.updatedAt,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -345,33 +344,33 @@ router.post(
|
||||
* Body: { note: string }
|
||||
*/
|
||||
router.put(
|
||||
"/tokens/:token/note",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { token } = req.params;
|
||||
const { note } = req.body;
|
||||
"/tokens/:token/note",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {token} = req.params;
|
||||
const {note} = req.body;
|
||||
|
||||
// 查找 token 对应的应用安装记录
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
// 查找 token 对应的应用安装记录
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: {token},
|
||||
});
|
||||
|
||||
if (!appInstall) {
|
||||
return next(errors.createError(404, "Token 不存在"));
|
||||
}
|
||||
if (!appInstall) {
|
||||
return next(errors.createError(404, "Token 不存在"));
|
||||
}
|
||||
|
||||
// 更新 AppInstall 的 note 字段
|
||||
const updatedInstall = await prisma.appInstall.update({
|
||||
where: { id: appInstall.id },
|
||||
data: { note: note || null },
|
||||
});
|
||||
// 更新 AppInstall 的 note 字段
|
||||
const updatedInstall = await prisma.appInstall.update({
|
||||
where: {id: appInstall.id},
|
||||
data: {note: note || null},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
token: updatedInstall.token,
|
||||
note: updatedInstall.note,
|
||||
updatedAt: updatedInstall.updatedAt,
|
||||
});
|
||||
})
|
||||
return res.json({
|
||||
success: true,
|
||||
token: updatedInstall.token,
|
||||
note: updatedInstall.note,
|
||||
updatedAt: updatedInstall.updatedAt,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@ -1,9 +1,10 @@
|
||||
import { Router } from "express";
|
||||
const router = Router();
|
||||
import { jwtAuth } from "../middleware/jwt-auth.js";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import {Router} from "express";
|
||||
import {jwtAuth} from "../middleware/jwt-auth.js";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import errors from "../utils/errors.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
@ -11,52 +12,52 @@ const prisma = new PrismaClient();
|
||||
* 获取设备的所有自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户)
|
||||
*/
|
||||
router.get(
|
||||
"/devices/:uuid/auth-configs",
|
||||
jwtAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid } = req.params;
|
||||
const account = res.locals.account;
|
||||
"/devices/:uuid/auth-configs",
|
||||
jwtAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {uuid} = req.params;
|
||||
const account = res.locals.account;
|
||||
|
||||
// 查找设备并验证是否属于当前账户
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
});
|
||||
// 查找设备并验证是否属于当前账户
|
||||
const device = await prisma.device.findUnique({
|
||||
where: {uuid},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
// 验证设备是否绑定到当前账户
|
||||
if (!device.accountId || device.accountId !== account.id) {
|
||||
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||
}
|
||||
// 验证设备是否绑定到当前账户
|
||||
if (!device.accountId || device.accountId !== account.id) {
|
||||
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||
}
|
||||
|
||||
const autoAuths = await prisma.autoAuth.findMany({
|
||||
where: { deviceId: device.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const autoAuths = await prisma.autoAuth.findMany({
|
||||
where: {deviceId: device.id},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
});
|
||||
|
||||
// 返回配置,智能处理密码显示
|
||||
const configs = autoAuths.map(auth => {
|
||||
// 检查是否是 bcrypt 哈希密码
|
||||
const isHashedPassword = auth.password && auth.password.startsWith('$2');
|
||||
// 返回配置,智能处理密码显示
|
||||
const configs = autoAuths.map(auth => {
|
||||
// 检查是否是 bcrypt 哈希密码
|
||||
const isHashedPassword = auth.password && auth.password.startsWith('$2');
|
||||
|
||||
return {
|
||||
id: auth.id,
|
||||
password: isHashedPassword ? null : auth.password, // 哈希密码不返回
|
||||
isLegacyHash: isHashedPassword, // 标记是否为旧的哈希密码
|
||||
deviceType: auth.deviceType,
|
||||
isReadOnly: auth.isReadOnly,
|
||||
createdAt: auth.createdAt,
|
||||
updatedAt: auth.updatedAt,
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: auth.id,
|
||||
password: isHashedPassword ? null : auth.password, // 哈希密码不返回
|
||||
isLegacyHash: isHashedPassword, // 标记是否为旧的哈希密码
|
||||
deviceType: auth.deviceType,
|
||||
isReadOnly: auth.isReadOnly,
|
||||
createdAt: auth.createdAt,
|
||||
updatedAt: auth.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
configs,
|
||||
});
|
||||
})
|
||||
return res.json({
|
||||
success: true,
|
||||
configs,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -65,163 +66,164 @@ router.get(
|
||||
* Body: { password?: string, deviceType?: string, isReadOnly?: boolean }
|
||||
*/
|
||||
router.post(
|
||||
"/devices/:uuid/auth-configs",
|
||||
jwtAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid } = req.params;
|
||||
const account = res.locals.account;
|
||||
const { password, deviceType, isReadOnly } = req.body;
|
||||
"/devices/:uuid/auth-configs",
|
||||
jwtAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {uuid} = req.params;
|
||||
const account = res.locals.account;
|
||||
const {password, deviceType, isReadOnly} = req.body;
|
||||
|
||||
// 查找设备并验证是否属于当前账户
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
});
|
||||
// 查找设备并验证是否属于当前账户
|
||||
const device = await prisma.device.findUnique({
|
||||
where: {uuid},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
// 验证设备是否绑定到当前账户
|
||||
if (!device.accountId || device.accountId !== account.id) {
|
||||
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||
}
|
||||
// 验证设备是否绑定到当前账户
|
||||
if (!device.accountId || device.accountId !== account.id) {
|
||||
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||
}
|
||||
|
||||
// 验证 deviceType 如果提供的话
|
||||
const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent'];
|
||||
if (deviceType && !validDeviceTypes.includes(deviceType)) {
|
||||
return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`));
|
||||
}
|
||||
// 验证 deviceType 如果提供的话
|
||||
const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent'];
|
||||
if (deviceType && !validDeviceTypes.includes(deviceType)) {
|
||||
return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`));
|
||||
}
|
||||
|
||||
// 规范化密码:空字符串视为 null
|
||||
const plainPassword = (password !== undefined && password !== '') ? password : null;
|
||||
// 规范化密码:空字符串视为 null
|
||||
const plainPassword = (password !== undefined && password !== '') ? password : null;
|
||||
|
||||
// 查询该设备的所有自动授权配置,本地检查是否存在相同密码
|
||||
const allAuths = await prisma.autoAuth.findMany({
|
||||
where: { deviceId: device.id },
|
||||
});
|
||||
// 查询该设备的所有自动授权配置,本地检查是否存在相同密码
|
||||
const allAuths = await prisma.autoAuth.findMany({
|
||||
where: {deviceId: device.id},
|
||||
});
|
||||
|
||||
const existingAuth = allAuths.find(auth => auth.password === plainPassword);
|
||||
const existingAuth = allAuths.find(auth => auth.password === plainPassword);
|
||||
|
||||
if (existingAuth) {
|
||||
return next(errors.createError(400, "该密码的自动授权配置已存在"));
|
||||
}
|
||||
if (existingAuth) {
|
||||
return next(errors.createError(400, "该密码的自动授权配置已存在"));
|
||||
}
|
||||
|
||||
// 创建新的自动授权配置(密码明文存储)
|
||||
const autoAuth = await prisma.autoAuth.create({
|
||||
data: {
|
||||
deviceId: device.id,
|
||||
password: plainPassword,
|
||||
deviceType: deviceType || null,
|
||||
isReadOnly: isReadOnly || false,
|
||||
},
|
||||
});
|
||||
// 创建新的自动授权配置(密码明文存储)
|
||||
const autoAuth = await prisma.autoAuth.create({
|
||||
data: {
|
||||
deviceId: device.id,
|
||||
password: plainPassword,
|
||||
deviceType: deviceType || null,
|
||||
isReadOnly: isReadOnly || false,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
config: {
|
||||
id: autoAuth.id,
|
||||
password: autoAuth.password, // 返回明文密码
|
||||
deviceType: autoAuth.deviceType,
|
||||
isReadOnly: autoAuth.isReadOnly,
|
||||
createdAt: autoAuth.createdAt,
|
||||
},
|
||||
});
|
||||
})
|
||||
);/**
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
config: {
|
||||
id: autoAuth.id,
|
||||
password: autoAuth.password, // 返回明文密码
|
||||
deviceType: autoAuth.deviceType,
|
||||
isReadOnly: autoAuth.isReadOnly,
|
||||
createdAt: autoAuth.createdAt,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
/**
|
||||
* PUT /auto-auth/devices/:uuid/auth-configs/:configId
|
||||
* 更新自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户)
|
||||
* Body: { password?: string, deviceType?: string, isReadOnly?: boolean }
|
||||
*/
|
||||
router.put(
|
||||
"/devices/:uuid/auth-configs/:configId",
|
||||
jwtAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid, configId } = req.params;
|
||||
const account = res.locals.account;
|
||||
const { password, deviceType, isReadOnly } = req.body;
|
||||
"/devices/:uuid/auth-configs/:configId",
|
||||
jwtAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {uuid, configId} = req.params;
|
||||
const account = res.locals.account;
|
||||
const {password, deviceType, isReadOnly} = req.body;
|
||||
|
||||
// 查找设备并验证是否属于当前账户
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
});
|
||||
// 查找设备并验证是否属于当前账户
|
||||
const device = await prisma.device.findUnique({
|
||||
where: {uuid},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
// 验证设备是否绑定到当前账户
|
||||
if (!device.accountId || device.accountId !== account.id) {
|
||||
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||
}
|
||||
// 验证设备是否绑定到当前账户
|
||||
if (!device.accountId || device.accountId !== account.id) {
|
||||
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||
}
|
||||
|
||||
// 查找自动授权配置
|
||||
const autoAuth = await prisma.autoAuth.findUnique({
|
||||
where: { id: configId },
|
||||
});
|
||||
// 查找自动授权配置
|
||||
const autoAuth = await prisma.autoAuth.findUnique({
|
||||
where: {id: configId},
|
||||
});
|
||||
|
||||
if (!autoAuth) {
|
||||
return next(errors.createError(404, "自动授权配置不存在"));
|
||||
}
|
||||
if (!autoAuth) {
|
||||
return next(errors.createError(404, "自动授权配置不存在"));
|
||||
}
|
||||
|
||||
// 确保配置属于当前设备
|
||||
if (autoAuth.deviceId !== device.id) {
|
||||
return next(errors.createError(403, "无权操作此配置"));
|
||||
}
|
||||
// 确保配置属于当前设备
|
||||
if (autoAuth.deviceId !== device.id) {
|
||||
return next(errors.createError(403, "无权操作此配置"));
|
||||
}
|
||||
|
||||
// 验证 deviceType
|
||||
const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent'];
|
||||
if (deviceType && !validDeviceTypes.includes(deviceType)) {
|
||||
return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`));
|
||||
}
|
||||
// 验证 deviceType
|
||||
const validDeviceTypes = ['teacher', 'student', 'classroom', 'parent'];
|
||||
if (deviceType && !validDeviceTypes.includes(deviceType)) {
|
||||
return next(errors.createError(400, `设备类型必须是以下之一: ${validDeviceTypes.join(', ')}`));
|
||||
}
|
||||
|
||||
// 准备更新数据
|
||||
const updateData = {};
|
||||
// 准备更新数据
|
||||
const updateData = {};
|
||||
|
||||
if (password !== undefined) {
|
||||
// 规范化密码:空字符串视为 null
|
||||
const plainPassword = (password !== '') ? password : null;
|
||||
if (password !== undefined) {
|
||||
// 规范化密码:空字符串视为 null
|
||||
const plainPassword = (password !== '') ? password : null;
|
||||
|
||||
// 查询该设备的所有配置,本地检查新密码是否与其他配置冲突
|
||||
const allAuths = await prisma.autoAuth.findMany({
|
||||
where: { deviceId: device.id },
|
||||
});
|
||||
// 查询该设备的所有配置,本地检查新密码是否与其他配置冲突
|
||||
const allAuths = await prisma.autoAuth.findMany({
|
||||
where: {deviceId: device.id},
|
||||
});
|
||||
|
||||
const conflictAuth = allAuths.find(auth =>
|
||||
auth.id !== configId && auth.password === plainPassword
|
||||
);
|
||||
const conflictAuth = allAuths.find(auth =>
|
||||
auth.id !== configId && auth.password === plainPassword
|
||||
);
|
||||
|
||||
if (conflictAuth) {
|
||||
return next(errors.createError(400, "该密码已被其他配置使用"));
|
||||
}
|
||||
if (conflictAuth) {
|
||||
return next(errors.createError(400, "该密码已被其他配置使用"));
|
||||
}
|
||||
|
||||
updateData.password = plainPassword;
|
||||
}
|
||||
updateData.password = plainPassword;
|
||||
}
|
||||
|
||||
if (deviceType !== undefined) {
|
||||
updateData.deviceType = deviceType || null;
|
||||
}
|
||||
if (deviceType !== undefined) {
|
||||
updateData.deviceType = deviceType || null;
|
||||
}
|
||||
|
||||
if (isReadOnly !== undefined) {
|
||||
updateData.isReadOnly = isReadOnly;
|
||||
}
|
||||
if (isReadOnly !== undefined) {
|
||||
updateData.isReadOnly = isReadOnly;
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
const updatedAuth = await prisma.autoAuth.update({
|
||||
where: { id: configId },
|
||||
data: updateData,
|
||||
});
|
||||
// 更新配置
|
||||
const updatedAuth = await prisma.autoAuth.update({
|
||||
where: {id: configId},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
config: {
|
||||
id: updatedAuth.id,
|
||||
password: updatedAuth.password, // 返回明文密码
|
||||
deviceType: updatedAuth.deviceType,
|
||||
isReadOnly: updatedAuth.isReadOnly,
|
||||
updatedAt: updatedAuth.updatedAt,
|
||||
},
|
||||
});
|
||||
})
|
||||
return res.json({
|
||||
success: true,
|
||||
config: {
|
||||
id: updatedAuth.id,
|
||||
password: updatedAuth.password, // 返回明文密码
|
||||
deviceType: updatedAuth.deviceType,
|
||||
isReadOnly: updatedAuth.isReadOnly,
|
||||
updatedAt: updatedAuth.updatedAt,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -229,47 +231,47 @@ router.put(
|
||||
* 删除自动授权配置 (需要 JWT 认证,且设备必须绑定到该账户)
|
||||
*/
|
||||
router.delete(
|
||||
"/devices/:uuid/auth-configs/:configId",
|
||||
jwtAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid, configId } = req.params;
|
||||
const account = res.locals.account;
|
||||
"/devices/:uuid/auth-configs/:configId",
|
||||
jwtAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {uuid, configId} = req.params;
|
||||
const account = res.locals.account;
|
||||
|
||||
// 查找设备并验证是否属于当前账户
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
});
|
||||
// 查找设备并验证是否属于当前账户
|
||||
const device = await prisma.device.findUnique({
|
||||
where: {uuid},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
// 验证设备是否绑定到当前账户
|
||||
if (!device.accountId || device.accountId !== account.id) {
|
||||
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||
}
|
||||
// 验证设备是否绑定到当前账户
|
||||
if (!device.accountId || device.accountId !== account.id) {
|
||||
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||
}
|
||||
|
||||
// 查找自动授权配置
|
||||
const autoAuth = await prisma.autoAuth.findUnique({
|
||||
where: { id: configId },
|
||||
});
|
||||
// 查找自动授权配置
|
||||
const autoAuth = await prisma.autoAuth.findUnique({
|
||||
where: {id: configId},
|
||||
});
|
||||
|
||||
if (!autoAuth) {
|
||||
return next(errors.createError(404, "自动授权配置不存在"));
|
||||
}
|
||||
if (!autoAuth) {
|
||||
return next(errors.createError(404, "自动授权配置不存在"));
|
||||
}
|
||||
|
||||
// 确保配置属于当前设备
|
||||
if (autoAuth.deviceId !== device.id) {
|
||||
return next(errors.createError(403, "无权操作此配置"));
|
||||
}
|
||||
// 确保配置属于当前设备
|
||||
if (autoAuth.deviceId !== device.id) {
|
||||
return next(errors.createError(403, "无权操作此配置"));
|
||||
}
|
||||
|
||||
// 删除配置
|
||||
await prisma.autoAuth.delete({
|
||||
where: { id: configId },
|
||||
});
|
||||
// 删除配置
|
||||
await prisma.autoAuth.delete({
|
||||
where: {id: configId},
|
||||
});
|
||||
|
||||
return res.status(204).end();
|
||||
})
|
||||
return res.status(204).end();
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -278,66 +280,66 @@ router.delete(
|
||||
* Body: { namespace: string }
|
||||
*/
|
||||
router.put(
|
||||
"/devices/:uuid/namespace",
|
||||
jwtAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid } = req.params;
|
||||
const account = res.locals.account;
|
||||
const { namespace } = req.body;
|
||||
"/devices/:uuid/namespace",
|
||||
jwtAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {uuid} = req.params;
|
||||
const account = res.locals.account;
|
||||
const {namespace} = req.body;
|
||||
|
||||
if (!namespace) {
|
||||
return next(errors.createError(400, "需要提供 namespace"));
|
||||
}
|
||||
if (!namespace) {
|
||||
return next(errors.createError(400, "需要提供 namespace"));
|
||||
}
|
||||
|
||||
// 规范化 namespace:去除首尾空格
|
||||
const trimmedNamespace = namespace.trim();
|
||||
// 规范化 namespace:去除首尾空格
|
||||
const trimmedNamespace = namespace.trim();
|
||||
|
||||
if (!trimmedNamespace) {
|
||||
return next(errors.createError(400, "namespace 不能为空"));
|
||||
}
|
||||
if (!trimmedNamespace) {
|
||||
return next(errors.createError(400, "namespace 不能为空"));
|
||||
}
|
||||
|
||||
// 查找设备并验证是否属于当前账户
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
});
|
||||
// 查找设备并验证是否属于当前账户
|
||||
const device = await prisma.device.findUnique({
|
||||
where: {uuid},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
// 验证设备是否绑定到当前账户
|
||||
if (!device.accountId || device.accountId !== account.id) {
|
||||
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||
}
|
||||
// 验证设备是否绑定到当前账户
|
||||
if (!device.accountId || device.accountId !== account.id) {
|
||||
return next(errors.createError(403, "该设备未绑定到您的账户"));
|
||||
}
|
||||
|
||||
// 检查新的 namespace 是否已被其他设备使用
|
||||
if (device.namespace !== trimmedNamespace) {
|
||||
const existingDevice = await prisma.device.findUnique({
|
||||
where: { namespace: trimmedNamespace },
|
||||
});
|
||||
// 检查新的 namespace 是否已被其他设备使用
|
||||
if (device.namespace !== trimmedNamespace) {
|
||||
const existingDevice = await prisma.device.findUnique({
|
||||
where: {namespace: trimmedNamespace},
|
||||
});
|
||||
|
||||
if (existingDevice) {
|
||||
return next(errors.createError(409, "该 namespace 已被其他设备使用"));
|
||||
}
|
||||
}
|
||||
if (existingDevice) {
|
||||
return next(errors.createError(409, "该 namespace 已被其他设备使用"));
|
||||
}
|
||||
}
|
||||
|
||||
// 更新设备的 namespace
|
||||
const updatedDevice = await prisma.device.update({
|
||||
where: { id: device.id },
|
||||
data: { namespace: trimmedNamespace },
|
||||
});
|
||||
// 更新设备的 namespace
|
||||
const updatedDevice = await prisma.device.update({
|
||||
where: {id: device.id},
|
||||
data: {namespace: trimmedNamespace},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
device: {
|
||||
id: updatedDevice.id,
|
||||
uuid: updatedDevice.uuid,
|
||||
name: updatedDevice.name,
|
||||
namespace: updatedDevice.namespace,
|
||||
updatedAt: updatedDevice.updatedAt,
|
||||
},
|
||||
});
|
||||
})
|
||||
return res.json({
|
||||
success: true,
|
||||
device: {
|
||||
id: updatedDevice.id,
|
||||
uuid: updatedDevice.uuid,
|
||||
name: updatedDevice.name,
|
||||
namespace: updatedDevice.namespace,
|
||||
updatedAt: updatedDevice.updatedAt,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
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
|
||||
* 生成设备授权码
|
||||
@ -21,16 +20,16 @@ const prisma = new PrismaClient();
|
||||
* }
|
||||
*/
|
||||
router.post(
|
||||
"/device/code",
|
||||
errors.catchAsync(async (req, res) => {
|
||||
const deviceCode = deviceCodeStore.create();
|
||||
"/device/code",
|
||||
errors.catchAsync(async (req, res) => {
|
||||
const deviceCode = deviceCodeStore.create();
|
||||
|
||||
return res.json({
|
||||
device_code: deviceCode,
|
||||
expires_in: 900, // 15分钟
|
||||
message: "请在前端输入此代码进行授权",
|
||||
});
|
||||
})
|
||||
return res.json({
|
||||
device_code: deviceCode,
|
||||
expires_in: 900, // 15分钟
|
||||
message: "请在前端输入此代码进行授权",
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -53,39 +52,39 @@ router.post(
|
||||
* }
|
||||
*/
|
||||
router.post(
|
||||
"/device/bind",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { device_code, token } = req.body;
|
||||
"/device/bind",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {device_code, token} = req.body;
|
||||
|
||||
if (!device_code || !token) {
|
||||
return next(
|
||||
errors.createError(400, "请提供 device_code 和 token")
|
||||
);
|
||||
}
|
||||
if (!device_code || !token) {
|
||||
return next(
|
||||
errors.createError(400, "请提供 device_code 和 token")
|
||||
);
|
||||
}
|
||||
|
||||
// 验证token是否有效(检查数据库)
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
// 验证token是否有效(检查数据库)
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: {token},
|
||||
});
|
||||
|
||||
if (!appInstall) {
|
||||
return next(errors.createError(400, "无效的令牌"));
|
||||
}
|
||||
if (!appInstall) {
|
||||
return next(errors.createError(400, "无效的令牌"));
|
||||
}
|
||||
|
||||
// 绑定令牌到设备代码
|
||||
const success = deviceCodeStore.bindToken(device_code, token);
|
||||
// 绑定令牌到设备代码
|
||||
const success = deviceCodeStore.bindToken(device_code, token);
|
||||
|
||||
if (!success) {
|
||||
return next(
|
||||
errors.createError(400, "设备代码不存在或已过期")
|
||||
);
|
||||
}
|
||||
if (!success) {
|
||||
return next(
|
||||
errors.createError(400, "设备代码不存在或已过期")
|
||||
);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "令牌已成功绑定到设备代码",
|
||||
});
|
||||
})
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "令牌已成功绑定到设备代码",
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -117,43 +116,43 @@ router.post(
|
||||
* }
|
||||
*/
|
||||
router.get(
|
||||
"/device/token",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { device_code } = req.query;
|
||||
"/device/token",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {device_code} = req.query;
|
||||
|
||||
if (!device_code) {
|
||||
return next(errors.createError(400, "请提供 device_code"));
|
||||
}
|
||||
if (!device_code) {
|
||||
return next(errors.createError(400, "请提供 device_code"));
|
||||
}
|
||||
|
||||
// 尝试获取并移除令牌
|
||||
const token = deviceCodeStore.getAndRemove(device_code);
|
||||
// 尝试获取并移除令牌
|
||||
const token = deviceCodeStore.getAndRemove(device_code);
|
||||
|
||||
if (token) {
|
||||
// 令牌已绑定,返回并删除
|
||||
return res.json({
|
||||
status: "success",
|
||||
token,
|
||||
});
|
||||
}
|
||||
if (token) {
|
||||
// 令牌已绑定,返回并删除
|
||||
return res.json({
|
||||
status: "success",
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
// 检查设备代码是否存在
|
||||
const status = deviceCodeStore.getStatus(device_code);
|
||||
// 检查设备代码是否存在
|
||||
const status = deviceCodeStore.getStatus(device_code);
|
||||
|
||||
if (!status) {
|
||||
// 设备代码不存在或已过期
|
||||
return res.json({
|
||||
status: "expired",
|
||||
message: "设备代码不存在或已过期",
|
||||
});
|
||||
}
|
||||
if (!status) {
|
||||
// 设备代码不存在或已过期
|
||||
return res.json({
|
||||
status: "expired",
|
||||
message: "设备代码不存在或已过期",
|
||||
});
|
||||
}
|
||||
|
||||
// 设备代码存在但令牌未绑定
|
||||
return res.json({
|
||||
status: "pending",
|
||||
message: "等待用户授权",
|
||||
expires_in: Math.floor((status.expiresAt - Date.now()) / 1000),
|
||||
});
|
||||
})
|
||||
// 设备代码存在但令牌未绑定
|
||||
return res.json({
|
||||
status: "pending",
|
||||
message: "等待用户授权",
|
||||
expires_in: Math.floor((status.expiresAt - Date.now()) / 1000),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -172,32 +171,32 @@ router.get(
|
||||
* }
|
||||
*/
|
||||
router.get(
|
||||
"/device/status",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { device_code } = req.query;
|
||||
"/device/status",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {device_code} = req.query;
|
||||
|
||||
if (!device_code) {
|
||||
return next(errors.createError(400, "请提供 device_code"));
|
||||
}
|
||||
if (!device_code) {
|
||||
return next(errors.createError(400, "请提供 device_code"));
|
||||
}
|
||||
|
||||
const status = deviceCodeStore.getStatus(device_code);
|
||||
const status = deviceCodeStore.getStatus(device_code);
|
||||
|
||||
if (!status) {
|
||||
return res.json({
|
||||
device_code,
|
||||
exists: false,
|
||||
message: "设备代码不存在或已过期",
|
||||
});
|
||||
}
|
||||
if (!status) {
|
||||
return res.json({
|
||||
device_code,
|
||||
exists: false,
|
||||
message: "设备代码不存在或已过期",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
device_code,
|
||||
exists: true,
|
||||
has_token: status.hasToken,
|
||||
expires_in: Math.floor((status.expiresAt - Date.now()) / 1000),
|
||||
created_at: status.createdAt,
|
||||
});
|
||||
})
|
||||
return res.json({
|
||||
device_code,
|
||||
exists: true,
|
||||
has_token: status.hasToken,
|
||||
expires_in: Math.floor((status.expiresAt - Date.now()) / 1000),
|
||||
created_at: status.createdAt,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
493
routes/device.js
493
routes/device.js
@ -1,72 +1,104 @@
|
||||
import { Router } from "express";
|
||||
const router = Router();
|
||||
import { uuidAuth } from "../middleware/uuidAuth.js";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import crypto from "crypto";
|
||||
import {Router} from "express";
|
||||
import {extractDeviceInfo} from "../middleware/uuidAuth.js";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import errors from "../utils/errors.js";
|
||||
import { hashPassword, verifyDevicePassword } from "../utils/crypto.js";
|
||||
import { getOnlineDevices } from "../utils/socket.js";
|
||||
import {getOnlineDevices} from "../utils/socket.js";
|
||||
import {registeredDevicesTotal} from "../utils/metrics.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
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
|
||||
* 注册新设备
|
||||
*/
|
||||
router.post(
|
||||
"/",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid, deviceName, namespace } = req.body;
|
||||
"/",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {uuid, deviceName, namespace} = req.body;
|
||||
|
||||
if (!uuid) {
|
||||
return next(errors.createError(400, "设备UUID是必需的"));
|
||||
}
|
||||
if (!uuid) {
|
||||
return next(errors.createError(400, "设备UUID是必需的"));
|
||||
}
|
||||
|
||||
if (!deviceName) {
|
||||
return next(errors.createError(400, "设备名称是必需的"));
|
||||
}
|
||||
if (!deviceName) {
|
||||
return next(errors.createError(400, "设备名称是必需的"));
|
||||
}
|
||||
|
||||
// 检查UUID是否已存在
|
||||
const existingDevice = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
});
|
||||
try {
|
||||
// 检查UUID是否已存在
|
||||
const existingDevice = await prisma.device.findUnique({
|
||||
where: {uuid},
|
||||
});
|
||||
|
||||
if (existingDevice) {
|
||||
return next(errors.createError(409, "设备UUID已存在"));
|
||||
}
|
||||
if (existingDevice) {
|
||||
return next(errors.createError(409, "设备UUID已存在"));
|
||||
}
|
||||
|
||||
// 处理 namespace:如果没有提供,则使用 uuid
|
||||
const deviceNamespace = namespace && namespace.trim() ? namespace.trim() : uuid;
|
||||
// 处理 namespace:如果没有提供,则使用 uuid
|
||||
const deviceNamespace = namespace && namespace.trim() ? namespace.trim() : uuid;
|
||||
|
||||
// 检查 namespace 是否已被使用
|
||||
const existingNamespace = await prisma.device.findUnique({
|
||||
where: { namespace: deviceNamespace },
|
||||
});
|
||||
// 检查 namespace 是否已被使用
|
||||
const existingNamespace = await prisma.device.findUnique({
|
||||
where: {namespace: deviceNamespace},
|
||||
});
|
||||
|
||||
if (existingNamespace) {
|
||||
return next(errors.createError(409, "该 namespace 已被使用"));
|
||||
}
|
||||
if (existingNamespace) {
|
||||
return next(errors.createError(409, "该 namespace 已被使用"));
|
||||
}
|
||||
|
||||
// 创建设备
|
||||
const device = await prisma.device.create({
|
||||
data: {
|
||||
uuid,
|
||||
name: deviceName,
|
||||
namespace: deviceNamespace,
|
||||
},
|
||||
});
|
||||
// 创建设备
|
||||
const device = await prisma.device.create({
|
||||
data: {
|
||||
uuid,
|
||||
name: deviceName,
|
||||
namespace: deviceNamespace,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
device: {
|
||||
id: device.id,
|
||||
uuid: device.uuid,
|
||||
name: device.name,
|
||||
namespace: device.namespace,
|
||||
createdAt: device.createdAt,
|
||||
},
|
||||
});
|
||||
})
|
||||
// 为新设备创建默认的自动登录配置
|
||||
await createDefaultAutoAuth(device.id);
|
||||
|
||||
// 更新注册设备总数指标
|
||||
const totalDevices = await prisma.device.count();
|
||||
registeredDevicesTotal.set(totalDevices);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
device: {
|
||||
id: device.id,
|
||||
uuid: device.uuid,
|
||||
name: device.name,
|
||||
namespace: device.namespace,
|
||||
createdAt: device.createdAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -74,271 +106,80 @@ router.post(
|
||||
* 获取设备信息 (公开接口,无需认证)
|
||||
*/
|
||||
router.get(
|
||||
"/:uuid",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { uuid } = req.params;
|
||||
"/:uuid",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {uuid} = req.params;
|
||||
|
||||
// 查找设备,包含绑定的账户信息
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { uuid },
|
||||
include: {
|
||||
account: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// 查找设备,包含绑定的账户信息
|
||||
const device = await prisma.device.findUnique({
|
||||
where: {uuid},
|
||||
include: {
|
||||
account: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
return res.json({
|
||||
id: device.id,
|
||||
uuid: device.uuid,
|
||||
name: device.name,
|
||||
hasPassword: !!device.password,
|
||||
passwordHint: device.passwordHint,
|
||||
createdAt: device.createdAt,
|
||||
account: device.account ? {
|
||||
id: device.account.id,
|
||||
name: device.account.name,
|
||||
email: device.account.email,
|
||||
avatarUrl: device.account.avatarUrl,
|
||||
} : null,
|
||||
isBoundToAccount: !!device.account,
|
||||
namespace: device.namespace,
|
||||
});
|
||||
})
|
||||
);/**
|
||||
return res.json({
|
||||
id: device.id,
|
||||
uuid: device.uuid,
|
||||
name: device.name,
|
||||
hasPassword: !!device.password,
|
||||
passwordHint: device.passwordHint,
|
||||
createdAt: device.createdAt,
|
||||
account: device.account ? {
|
||||
id: device.account.id,
|
||||
name: device.account.name,
|
||||
email: device.account.email,
|
||||
avatarUrl: device.account.avatarUrl,
|
||||
} : null,
|
||||
isBoundToAccount: !!device.account,
|
||||
namespace: device.namespace,
|
||||
});
|
||||
})
|
||||
);
|
||||
/**
|
||||
* PUT /devices/:uuid/name
|
||||
* 设置设备名称 (需要UUID认证)
|
||||
*/
|
||||
router.put(
|
||||
"/:uuid/name",
|
||||
uuidAuth,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const { name } = req.body;
|
||||
const device = res.locals.device;
|
||||
"/:uuid/name",
|
||||
extractDeviceInfo,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const {name} = req.body;
|
||||
const device = res.locals.device;
|
||||
|
||||
if (!name) {
|
||||
return next(errors.createError(400, "设备名称是必需的"));
|
||||
}
|
||||
if (!name) {
|
||||
return next(errors.createError(400, "设备名称是必需的"));
|
||||
}
|
||||
|
||||
const updatedDevice = await prisma.device.update({
|
||||
where: { id: device.id },
|
||||
data: { name },
|
||||
});
|
||||
const updatedDevice = await prisma.device.update({
|
||||
where: {id: device.id},
|
||||
data: {name},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
device: {
|
||||
id: updatedDevice.id,
|
||||
uuid: updatedDevice.uuid,
|
||||
name: updatedDevice.name,
|
||||
hasPassword: !!updatedDevice.password,
|
||||
passwordHint: updatedDevice.passwordHint,
|
||||
},
|
||||
});
|
||||
})
|
||||
return res.json({
|
||||
success: true,
|
||||
device: {
|
||||
id: updatedDevice.id,
|
||||
uuid: updatedDevice.uuid,
|
||||
name: updatedDevice.name,
|
||||
hasPassword: !!updatedDevice.password,
|
||||
passwordHint: updatedDevice.passwordHint,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* 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
|
||||
@ -346,28 +187,30 @@ export default router;
|
||||
* 返回:[{ uuid, connections, name? }]
|
||||
*/
|
||||
router.get(
|
||||
"/online",
|
||||
errors.catchAsync(async (req, res) => {
|
||||
const list = getOnlineDevices();
|
||||
"/online",
|
||||
errors.catchAsync(async (req, res) => {
|
||||
const list = getOnlineDevices();
|
||||
|
||||
if (list.length === 0) {
|
||||
return res.json({ success: true, devices: [] });
|
||||
}
|
||||
if (list.length === 0) {
|
||||
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 },
|
||||
});
|
||||
const nameMap = new Map(rows.map((r) => [r.uuid, r.name]));
|
||||
// 补充设备名称
|
||||
const uuids = list.map((x) => x.uuid);
|
||||
const rows = await prisma.device.findMany({
|
||||
where: {uuid: {in: uuids}},
|
||||
select: {uuid: true, name: true},
|
||||
});
|
||||
const nameMap = new Map(rows.map((r) => [r.uuid, r.name]));
|
||||
|
||||
const devices = list.map((x) => ({
|
||||
uuid: x.uuid,
|
||||
connections: x.connections,
|
||||
name: nameMap.get(x.uuid) || null,
|
||||
}));
|
||||
const devices = list.map((x) => ({
|
||||
uuid: x.uuid,
|
||||
connections: x.connections,
|
||||
name: nameMap.get(x.uuid) || null,
|
||||
}));
|
||||
|
||||
res.json({ success: true, devices });
|
||||
})
|
||||
);
|
||||
res.json({success: true, devices});
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { Router } from "express";
|
||||
import {Router} from "express";
|
||||
|
||||
var router = Router();
|
||||
|
||||
/* GET home page. */
|
||||
router.get("/", function (req, res, next) {
|
||||
res.render("index");
|
||||
res.render("index");
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@ -1,59 +1,78 @@
|
||||
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",
|
||||
errors.catchAsync(async (req, res) => {
|
||||
const deviceId = res.locals.deviceId;
|
||||
"/_info",
|
||||
tokenReadLimiter,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const deviceId = res.locals.deviceId;
|
||||
|
||||
// 获取设备信息,包含关联的账号
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { id: deviceId },
|
||||
include: {
|
||||
account: true,
|
||||
},
|
||||
});
|
||||
// 获取设备信息,包含关联的账号
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { id: deviceId },
|
||||
include: {
|
||||
account: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
if (!device) {
|
||||
return next(errors.createError(404, "设备不存在"));
|
||||
}
|
||||
|
||||
// 构建响应对象
|
||||
const response = {
|
||||
device: {
|
||||
id: device.id,
|
||||
uuid: device.uuid,
|
||||
name: device.name,
|
||||
createdAt: device.createdAt,
|
||||
updatedAt: device.updatedAt,
|
||||
},
|
||||
};
|
||||
// 构建响应对象:当设备没有关联账号时返回 uuid;若已关联账号则不返回 uuid
|
||||
const response = {
|
||||
device: {
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
createdAt: device.createdAt,
|
||||
updatedAt: device.updatedAt,
|
||||
},
|
||||
};
|
||||
|
||||
// 如果关联了账号,添加账号信息
|
||||
if (device.account) {
|
||||
response.account = {
|
||||
id: device.account.id,
|
||||
name: device.account.name,
|
||||
avatarUrl: device.account.avatarUrl,
|
||||
};
|
||||
}
|
||||
// 仅当设备未绑定账号时,包含 uuid 字段
|
||||
if (!device.account) {
|
||||
response.device.uuid = device.uuid;
|
||||
}
|
||||
|
||||
return res.json(response);
|
||||
})
|
||||
// 标识是否已绑定账号
|
||||
response.hasAccount = !!device.account;
|
||||
|
||||
// 如果关联了账号,添加账号信息
|
||||
if (device.account) {
|
||||
response.account = {
|
||||
id: device.account.id,
|
||||
name: device.account.name,
|
||||
avatarUrl: device.account.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return res.json(response);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -61,47 +80,48 @@ router.get(
|
||||
* 获取当前 KV Token 的详细信息(类型、备注等)
|
||||
*/
|
||||
router.get(
|
||||
"/_token",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const token = res.locals.token;
|
||||
const deviceId = res.locals.deviceId;
|
||||
"/_token",
|
||||
tokenReadLimiter,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const token = res.locals.token;
|
||||
const deviceId = res.locals.deviceId;
|
||||
|
||||
// 查找当前 token 对应的应用安装记录
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: { token },
|
||||
include: {
|
||||
device: {
|
||||
select: {
|
||||
id: true,
|
||||
uuid: true,
|
||||
name: true,
|
||||
namespace: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// 查找当前 token 对应的应用安装记录
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: { token },
|
||||
include: {
|
||||
device: {
|
||||
select: {
|
||||
id: true,
|
||||
uuid: true,
|
||||
name: true,
|
||||
namespace: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!appInstall) {
|
||||
return next(errors.createError(404, "Token 信息不存在"));
|
||||
}
|
||||
if (!appInstall) {
|
||||
return next(errors.createError(404, "Token 信息不存在"));
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
token: appInstall.token,
|
||||
appId: appInstall.appId,
|
||||
deviceType: appInstall.deviceType,
|
||||
isReadOnly: appInstall.isReadOnly,
|
||||
note: appInstall.note,
|
||||
installedAt: appInstall.installedAt,
|
||||
updatedAt: appInstall.updatedAt,
|
||||
device: {
|
||||
id: appInstall.device.id,
|
||||
uuid: appInstall.device.uuid,
|
||||
name: appInstall.device.name,
|
||||
namespace: appInstall.device.namespace,
|
||||
},
|
||||
});
|
||||
})
|
||||
return res.json({
|
||||
success: true,
|
||||
token: appInstall.token,
|
||||
appId: appInstall.appId,
|
||||
deviceType: appInstall.deviceType,
|
||||
isReadOnly: appInstall.isReadOnly,
|
||||
note: appInstall.note,
|
||||
installedAt: appInstall.installedAt,
|
||||
updatedAt: appInstall.updatedAt,
|
||||
device: {
|
||||
id: appInstall.device.id,
|
||||
uuid: appInstall.device.uuid,
|
||||
name: appInstall.device.name,
|
||||
namespace: appInstall.device.namespace,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -109,49 +129,50 @@ router.get(
|
||||
* 获取当前token对应设备的键名列表(分页,不包括内容)
|
||||
*/
|
||||
router.get(
|
||||
"/_keys",
|
||||
errors.catchAsync(async (req, res) => {
|
||||
const deviceId = res.locals.deviceId;
|
||||
const { sortBy, sortDir, limit, skip } = req.query;
|
||||
"/_keys",
|
||||
tokenReadLimiter,
|
||||
errors.catchAsync(async (req, res) => {
|
||||
const deviceId = res.locals.deviceId;
|
||||
const { sortBy, sortDir, limit, skip } = req.query;
|
||||
|
||||
// 构建选项
|
||||
const options = {
|
||||
sortBy: sortBy || "key",
|
||||
sortDir: sortDir || "asc",
|
||||
limit: limit ? parseInt(limit) : 100,
|
||||
skip: skip ? parseInt(skip) : 0,
|
||||
};
|
||||
// 构建选项
|
||||
const options = {
|
||||
sortBy: sortBy || "key",
|
||||
sortDir: sortDir || "asc",
|
||||
limit: limit ? parseInt(limit) : 100,
|
||||
skip: skip ? parseInt(skip) : 0,
|
||||
};
|
||||
|
||||
const keys = await kvStore.listKeysOnly(deviceId, options);
|
||||
const totalRows = keys.length;
|
||||
const keys = await kvStore.listKeysOnly(deviceId, options);
|
||||
const totalRows = keys.length;
|
||||
|
||||
// 构建响应对象
|
||||
const response = {
|
||||
keys: keys,
|
||||
total_rows: totalRows,
|
||||
current_page: {
|
||||
limit: options.limit,
|
||||
skip: options.skip,
|
||||
count: keys.length,
|
||||
},
|
||||
};
|
||||
// 构建响应对象
|
||||
const response = {
|
||||
keys: keys,
|
||||
total_rows: totalRows,
|
||||
current_page: {
|
||||
limit: options.limit,
|
||||
skip: options.skip,
|
||||
count: keys.length,
|
||||
},
|
||||
};
|
||||
|
||||
// 如果还有更多数据,添加load_more字段
|
||||
const nextSkip = options.skip + options.limit;
|
||||
if (nextSkip < totalRows) {
|
||||
const baseUrl = `${req.baseUrl}/_keys`;
|
||||
const queryParams = new URLSearchParams({
|
||||
sortBy: options.sortBy,
|
||||
sortDir: options.sortDir,
|
||||
limit: options.limit,
|
||||
skip: nextSkip,
|
||||
}).toString();
|
||||
// 如果还有更多数据,添加load_more字段
|
||||
const nextSkip = options.skip + options.limit;
|
||||
if (nextSkip < totalRows) {
|
||||
const baseUrl = `${req.baseUrl}/_keys`;
|
||||
const queryParams = new URLSearchParams({
|
||||
sortBy: options.sortBy,
|
||||
sortDir: options.sortDir,
|
||||
limit: options.limit,
|
||||
skip: nextSkip,
|
||||
}).toString();
|
||||
|
||||
response.load_more = `${baseUrl}?${queryParams}`;
|
||||
}
|
||||
response.load_more = `${baseUrl}?${queryParams}`;
|
||||
}
|
||||
|
||||
return res.json(response);
|
||||
})
|
||||
return res.json(response);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -159,44 +180,45 @@ router.get(
|
||||
* 获取当前token对应设备的所有键名及元数据列表
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
errors.catchAsync(async (req, res) => {
|
||||
const deviceId = res.locals.deviceId;
|
||||
const { sortBy, sortDir, limit, skip } = req.query;
|
||||
"/",
|
||||
tokenReadLimiter,
|
||||
errors.catchAsync(async (req, res) => {
|
||||
const deviceId = res.locals.deviceId;
|
||||
const { sortBy, sortDir, limit, skip } = req.query;
|
||||
|
||||
// 构建选项
|
||||
const options = {
|
||||
sortBy: sortBy || "key",
|
||||
sortDir: sortDir || "asc",
|
||||
limit: limit ? parseInt(limit) : 100,
|
||||
skip: skip ? parseInt(skip) : 0,
|
||||
};
|
||||
// 构建选项
|
||||
const options = {
|
||||
sortBy: sortBy || "key",
|
||||
sortDir: sortDir || "asc",
|
||||
limit: limit ? parseInt(limit) : 100,
|
||||
skip: skip ? parseInt(skip) : 0,
|
||||
};
|
||||
|
||||
const keys = await kvStore.list(deviceId, options);
|
||||
const totalRows = await kvStore.count(deviceId);
|
||||
const keys = await kvStore.list(deviceId, options);
|
||||
const totalRows = await kvStore.count(deviceId);
|
||||
|
||||
// 构建响应对象
|
||||
const response = {
|
||||
items: keys,
|
||||
total_rows: totalRows,
|
||||
};
|
||||
// 构建响应对象
|
||||
const response = {
|
||||
items: keys,
|
||||
total_rows: totalRows,
|
||||
};
|
||||
|
||||
// 如果还有更多数据,添加load_more字段
|
||||
const nextSkip = options.skip + options.limit;
|
||||
if (nextSkip < totalRows) {
|
||||
const baseUrl = `${req.baseUrl}`;
|
||||
const queryParams = new URLSearchParams({
|
||||
sortBy: options.sortBy,
|
||||
sortDir: options.sortDir,
|
||||
limit: options.limit,
|
||||
skip: nextSkip,
|
||||
}).toString();
|
||||
// 如果还有更多数据,添加load_more字段
|
||||
const nextSkip = options.skip + options.limit;
|
||||
if (nextSkip < totalRows) {
|
||||
const baseUrl = `${req.baseUrl}`;
|
||||
const queryParams = new URLSearchParams({
|
||||
sortBy: options.sortBy,
|
||||
sortDir: options.sortDir,
|
||||
limit: options.limit,
|
||||
skip: nextSkip,
|
||||
}).toString();
|
||||
|
||||
response.load_more = `${baseUrl}?${queryParams}`;
|
||||
}
|
||||
response.load_more = `${baseUrl}?${queryParams}`;
|
||||
}
|
||||
|
||||
return res.json(response);
|
||||
})
|
||||
return res.json(response);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -204,21 +226,22 @@ router.get(
|
||||
* 通过键名获取键值
|
||||
*/
|
||||
router.get(
|
||||
"/:key",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const deviceId = res.locals.deviceId;
|
||||
const { key } = req.params;
|
||||
"/:key",
|
||||
tokenReadLimiter,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const deviceId = res.locals.deviceId;
|
||||
const { key } = req.params;
|
||||
|
||||
const value = await kvStore.get(deviceId, key);
|
||||
const value = await kvStore.get(deviceId, key);
|
||||
|
||||
if (value === null) {
|
||||
return next(
|
||||
errors.createError(404, `未找到键名为 '${key}' 的记录`)
|
||||
);
|
||||
}
|
||||
if (value === null) {
|
||||
return next(
|
||||
errors.createError(404, `未找到键名为 '${key}' 的记录`)
|
||||
);
|
||||
}
|
||||
|
||||
return res.json(value);
|
||||
})
|
||||
return res.json(value);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -226,19 +249,20 @@ router.get(
|
||||
* 获取键的元数据
|
||||
*/
|
||||
router.get(
|
||||
"/:key/metadata",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const deviceId = res.locals.deviceId;
|
||||
const { key } = req.params;
|
||||
"/:key/metadata",
|
||||
tokenReadLimiter,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
const deviceId = res.locals.deviceId;
|
||||
const { key } = req.params;
|
||||
|
||||
const metadata = await kvStore.getMetadata(deviceId, key);
|
||||
if (!metadata) {
|
||||
return next(
|
||||
errors.createError(404, `未找到键名为 '${key}' 的记录`)
|
||||
);
|
||||
}
|
||||
return res.json(metadata);
|
||||
})
|
||||
const metadata = await kvStore.getMetadata(deviceId, key);
|
||||
if (!metadata) {
|
||||
return next(
|
||||
errors.createError(404, `未找到键名为 '${key}' 的记录`)
|
||||
);
|
||||
}
|
||||
return res.json(metadata);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -246,72 +270,55 @@ router.get(
|
||||
* 批量导入键值对
|
||||
*/
|
||||
router.post(
|
||||
"/_batchimport",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
// 检查token是否为只读
|
||||
if (res.locals.appInstall?.isReadOnly) {
|
||||
return next(errors.createError(403, "当前token为只读模式,无法修改数据"));
|
||||
}
|
||||
|
||||
const deviceId = res.locals.deviceId;
|
||||
const data = req.body;
|
||||
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return next(
|
||||
errors.createError(
|
||||
400,
|
||||
'请提供有效的JSON数据,格式为 {"key":{}, "key2":{}}'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 获取客户端IP
|
||||
const creatorIp =
|
||||
req.headers["x-forwarded-for"] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
req.connection.socket?.remoteAddress ||
|
||||
"";
|
||||
|
||||
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,
|
||||
});
|
||||
"/_batchimport",
|
||||
tokenBatchLimiter,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
// 检查token是否为只读
|
||||
if (res.locals.appInstall?.isReadOnly) {
|
||||
return next(errors.createError(403, "当前token为只读模式,无法修改数据"));
|
||||
}
|
||||
} catch (error) {
|
||||
errorList.push({
|
||||
key,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
deviceId,
|
||||
total: Object.keys(data).length,
|
||||
successful: results.length,
|
||||
failed: errorList.length,
|
||||
results,
|
||||
errors: errorList.length > 0 ? errorList : undefined,
|
||||
});
|
||||
})
|
||||
const deviceId = res.locals.deviceId;
|
||||
const data = req.body;
|
||||
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return next(
|
||||
errors.createError(
|
||||
400,
|
||||
'请提供有效的JSON数据,格式为 {"key":{}, "key2":{}}'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 获取客户端IP
|
||||
const creatorIp =
|
||||
req.headers["x-forwarded-for"] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
req.connection.socket?.remoteAddress ||
|
||||
"";
|
||||
|
||||
// 使用优化的批量upsert方法
|
||||
const { results, errors: errorList } = await kvStore.batchUpsert(deviceId, data, creatorIp);
|
||||
|
||||
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 }),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -319,49 +326,60 @@ router.post(
|
||||
* 更新或创建键值
|
||||
*/
|
||||
router.post(
|
||||
"/:key",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
// 检查token是否为只读
|
||||
if (res.locals.appInstall?.isReadOnly) {
|
||||
return next(errors.createError(403, "当前token为只读模式,无法修改数据"));
|
||||
}
|
||||
"/:key",
|
||||
tokenWriteLimiter,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
// 检查token是否为只读
|
||||
if (res.locals.appInstall?.isReadOnly) {
|
||||
return next(errors.createError(403, "当前token为只读模式,无法修改数据"));
|
||||
}
|
||||
|
||||
const deviceId = res.locals.deviceId;
|
||||
const { key } = req.params;
|
||||
const value = req.body;
|
||||
const deviceId = res.locals.deviceId;
|
||||
const { key } = req.params;
|
||||
let value = req.body;
|
||||
|
||||
if (!value || Object.keys(value).length === 0) {
|
||||
return next(errors.createError(400, "请提供有效的JSON值"));
|
||||
}
|
||||
// 处理空值,转换为空对象
|
||||
if (value === null || value === undefined || value === '') {
|
||||
value = {};
|
||||
}
|
||||
|
||||
// 获取客户端IP
|
||||
const creatorIp =
|
||||
req.headers["x-forwarded-for"] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
req.connection.socket?.remoteAddress ||
|
||||
"";
|
||||
// 验证是否能被 JSON 序列化
|
||||
try {
|
||||
JSON.stringify(value);
|
||||
} catch (error) {
|
||||
return next(
|
||||
errors.createError(400, "无效的数据格式")
|
||||
);
|
||||
}
|
||||
|
||||
const result = await kvStore.upsert(deviceId, key, value, creatorIp);
|
||||
// 获取客户端IP
|
||||
const creatorIp =
|
||||
req.headers["x-forwarded-for"] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
req.connection.socket?.remoteAddress ||
|
||||
"";
|
||||
|
||||
// 广播单个键的变更
|
||||
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,
|
||||
});
|
||||
}
|
||||
const result = await kvStore.upsert(deviceId, key, value, creatorIp);
|
||||
|
||||
return res.status(200).json({
|
||||
deviceId: result.deviceId,
|
||||
key: result.key,
|
||||
created: result.createdAt.getTime() === result.updatedAt.getTime(),
|
||||
updatedAt: result.updatedAt,
|
||||
});
|
||||
})
|
||||
// 广播单个键的变更
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
deviceId: result.deviceId,
|
||||
key: result.key,
|
||||
created: result.createdAt.getTime() === result.updatedAt.getTime(),
|
||||
updatedAt: result.updatedAt,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@ -369,37 +387,38 @@ router.post(
|
||||
* 删除键值对
|
||||
*/
|
||||
router.delete(
|
||||
"/:key",
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
// 检查token是否为只读
|
||||
if (res.locals.appInstall?.isReadOnly) {
|
||||
return next(errors.createError(403, "当前token为只读模式,无法修改数据"));
|
||||
}
|
||||
"/:key",
|
||||
tokenDeleteLimiter,
|
||||
errors.catchAsync(async (req, res, next) => {
|
||||
// 检查token是否为只读
|
||||
if (res.locals.appInstall?.isReadOnly) {
|
||||
return next(errors.createError(403, "当前token为只读模式,无法修改数据"));
|
||||
}
|
||||
|
||||
const deviceId = res.locals.deviceId;
|
||||
const { key } = req.params;
|
||||
const deviceId = res.locals.deviceId;
|
||||
const { key } = req.params;
|
||||
|
||||
const result = await kvStore.delete(deviceId, key);
|
||||
const result = await kvStore.delete(deviceId, key);
|
||||
|
||||
if (!result) {
|
||||
return next(
|
||||
errors.createError(404, `未找到键名为 '${key}' 的记录`)
|
||||
);
|
||||
}
|
||||
if (!result) {
|
||||
return next(
|
||||
errors.createError(404, `未找到键名为 '${key}' 的记录`)
|
||||
);
|
||||
}
|
||||
|
||||
// 广播删除
|
||||
const uuid = res.locals.device?.uuid;
|
||||
if (uuid) {
|
||||
broadcastKeyChanged(uuid, {
|
||||
key,
|
||||
action: "delete",
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
}
|
||||
// 广播删除
|
||||
const uuid = res.locals.device?.uuid;
|
||||
if (uuid) {
|
||||
broadcastKeyChanged(uuid, {
|
||||
key,
|
||||
action: "delete",
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// 204状态码表示成功但无内容返回
|
||||
return res.status(204).end();
|
||||
})
|
||||
// 204状态码表示成功但无内容返回
|
||||
return res.status(204).end();
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
export default router;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const siteKey = process.env.SITE_KEY || "";
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import { Base64 } from "js-base64";
|
||||
import {Base64} from "js-base64";
|
||||
|
||||
const SALT_ROUNDS = 8;
|
||||
|
||||
@ -7,37 +7,37 @@ const SALT_ROUNDS = 8;
|
||||
* 从 base64 解码字符串
|
||||
*/
|
||||
export function decodeBase64(str) {
|
||||
if (!str) return null;
|
||||
try {
|
||||
return Base64.decode(str);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
if (!str) return null;
|
||||
try {
|
||||
return Base64.decode(str);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对字符串进行 UTF-8 编码处理
|
||||
*/
|
||||
function encodeUTF8(str) {
|
||||
try {
|
||||
return encodeURIComponent(str);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return encodeURIComponent(str);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证站点密钥
|
||||
*/
|
||||
export function verifySiteKey(providedKey, actualKey) {
|
||||
if (!actualKey) return true; // 如果没有设置站点密钥,则总是通过
|
||||
if (!providedKey) return false;
|
||||
const decodedKey = decodeBase64(providedKey);
|
||||
if (!decodedKey) return false;
|
||||
const encodedKey = encodeUTF8(decodedKey);
|
||||
if (!encodedKey) return false;
|
||||
console.debug(encodedKey);
|
||||
return encodedKey === actualKey;
|
||||
if (!actualKey) return true; // 如果没有设置站点密钥,则总是通过
|
||||
if (!providedKey) return false;
|
||||
const decodedKey = decodeBase64(providedKey);
|
||||
if (!decodedKey) return false;
|
||||
const encodedKey = encodeUTF8(decodedKey);
|
||||
if (!encodedKey) return false;
|
||||
console.debug(encodedKey);
|
||||
return encodedKey === actualKey;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -46,8 +46,8 @@ export function verifySiteKey(providedKey, actualKey) {
|
||||
* @returns {Promise<string>} 哈希后的密码
|
||||
*/
|
||||
export async function hashPassword(password) {
|
||||
if (!password) return null;
|
||||
return await bcrypt.hash(password, SALT_ROUNDS);
|
||||
if (!password) return null;
|
||||
return await bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -57,11 +57,11 @@ export async function hashPassword(password) {
|
||||
* @returns {Promise<boolean>} 密码是否匹配
|
||||
*/
|
||||
export async function verifyDevicePassword(providedPassword, hashedPassword) {
|
||||
if (!providedPassword || !hashedPassword) return false;
|
||||
try {
|
||||
return await bcrypt.compare(providedPassword, hashedPassword);
|
||||
} catch (error) {
|
||||
console.error('密码验证错误:', error);
|
||||
return false;
|
||||
}
|
||||
if (!providedPassword || !hashedPassword) return false;
|
||||
try {
|
||||
return await bcrypt.compare(providedPassword, hashedPassword);
|
||||
} catch (error) {
|
||||
console.error('密码验证错误:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,182 +6,182 @@
|
||||
*/
|
||||
|
||||
class DeviceCodeStore {
|
||||
constructor() {
|
||||
// 存储结构: { deviceCode: { token: string, expiresAt: number, createdAt: number } }
|
||||
this.store = new Map();
|
||||
constructor() {
|
||||
// 存储结构: { deviceCode: { token: string, expiresAt: number, createdAt: number } }
|
||||
this.store = new Map();
|
||||
|
||||
// 默认过期时间: 15分钟
|
||||
this.expirationTime = 15 * 60 * 1000;
|
||||
// 默认过期时间: 15分钟
|
||||
this.expirationTime = 15 * 60 * 1000;
|
||||
|
||||
// 定期清理过期数据 (每5分钟)
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanup();
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成设备代码 (格式: 1234-ABCD)
|
||||
*/
|
||||
generateDeviceCode() {
|
||||
const part1 = Math.floor(1000 + Math.random() * 9000).toString(); // 4位数字
|
||||
const part2 = Math.random().toString(36).substring(2, 6).toUpperCase(); // 4位字母
|
||||
return `${part1}-${part2}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的设备代码
|
||||
* @returns {string} 生成的设备代码
|
||||
*/
|
||||
create() {
|
||||
let deviceCode;
|
||||
|
||||
// 确保生成的代码不重复
|
||||
do {
|
||||
deviceCode = this.generateDeviceCode();
|
||||
} while (this.store.has(deviceCode));
|
||||
|
||||
const now = Date.now();
|
||||
this.store.set(deviceCode, {
|
||||
token: null,
|
||||
expiresAt: now + this.expirationTime,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return deviceCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定令牌到设备代码
|
||||
* @param {string} deviceCode - 设备代码
|
||||
* @param {string} token - 令牌
|
||||
* @returns {boolean} 是否成功绑定
|
||||
*/
|
||||
bindToken(deviceCode, token) {
|
||||
const entry = this.store.get(deviceCode);
|
||||
|
||||
if (!entry) {
|
||||
return false;
|
||||
// 定期清理过期数据 (每5分钟)
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanup();
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(deviceCode);
|
||||
return false;
|
||||
/**
|
||||
* 生成设备代码 (格式: 1234-ABCD)
|
||||
*/
|
||||
generateDeviceCode() {
|
||||
const part1 = Math.floor(1000 + Math.random() * 9000).toString(); // 4位数字
|
||||
const part2 = Math.random().toString(36).substring(2, 6).toUpperCase(); // 4位字母
|
||||
return `${part1}-${part2}`;
|
||||
}
|
||||
|
||||
// 绑定令牌
|
||||
entry.token = token;
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* 创建新的设备代码
|
||||
* @returns {string} 生成的设备代码
|
||||
*/
|
||||
create() {
|
||||
let deviceCode;
|
||||
|
||||
/**
|
||||
* 获取设备代码对应的令牌(获取后删除)
|
||||
* @param {string} deviceCode - 设备代码
|
||||
* @returns {string|null} 令牌,如果不存在或未绑定返回null
|
||||
*/
|
||||
getAndRemove(deviceCode) {
|
||||
const entry = this.store.get(deviceCode);
|
||||
// 确保生成的代码不重复
|
||||
do {
|
||||
deviceCode = this.generateDeviceCode();
|
||||
} while (this.store.has(deviceCode));
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
const now = Date.now();
|
||||
this.store.set(deviceCode, {
|
||||
token: null,
|
||||
expiresAt: now + this.expirationTime,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return deviceCode;
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(deviceCode);
|
||||
return null;
|
||||
/**
|
||||
* 绑定令牌到设备代码
|
||||
* @param {string} deviceCode - 设备代码
|
||||
* @param {string} token - 令牌
|
||||
* @returns {boolean} 是否成功绑定
|
||||
*/
|
||||
bindToken(deviceCode, token) {
|
||||
const entry = this.store.get(deviceCode);
|
||||
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(deviceCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 绑定令牌
|
||||
entry.token = token;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果令牌未绑定,返回null但不删除代码
|
||||
if (!entry.token) {
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* 获取设备代码对应的令牌(获取后删除)
|
||||
* @param {string} deviceCode - 设备代码
|
||||
* @returns {string|null} 令牌,如果不存在或未绑定返回null
|
||||
*/
|
||||
getAndRemove(deviceCode) {
|
||||
const entry = this.store.get(deviceCode);
|
||||
|
||||
// 获取令牌后删除条目
|
||||
const token = entry.token;
|
||||
this.store.delete(deviceCode);
|
||||
return token;
|
||||
}
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查设备代码是否存在且未过期
|
||||
* @param {string} deviceCode - 设备代码
|
||||
* @returns {boolean}
|
||||
*/
|
||||
exists(deviceCode) {
|
||||
const entry = this.store.get(deviceCode);
|
||||
// 检查是否过期
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(deviceCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
// 如果令牌未绑定,返回null但不删除代码
|
||||
if (!entry.token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(deviceCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备代码的状态信息(不删除)
|
||||
* @param {string} deviceCode - 设备代码
|
||||
* @returns {object|null} 状态信息
|
||||
*/
|
||||
getStatus(deviceCode) {
|
||||
const entry = this.store.get(deviceCode);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(deviceCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
hasToken: !!entry.token,
|
||||
expiresAt: entry.expiresAt,
|
||||
createdAt: entry.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的条目
|
||||
*/
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
let cleanedCount = 0;
|
||||
|
||||
for (const [deviceCode, entry] of this.store.entries()) {
|
||||
if (now > entry.expiresAt) {
|
||||
// 获取令牌后删除条目
|
||||
const token = entry.token;
|
||||
this.store.delete(deviceCode);
|
||||
cleanedCount++;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
console.log(`清理了 ${cleanedCount} 个过期的设备代码`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 检查设备代码是否存在且未过期
|
||||
* @param {string} deviceCode - 设备代码
|
||||
* @returns {boolean}
|
||||
*/
|
||||
exists(deviceCode) {
|
||||
const entry = this.store.get(deviceCode);
|
||||
|
||||
/**
|
||||
* 获取当前存储的条目数量
|
||||
*/
|
||||
size() {
|
||||
return this.store.size;
|
||||
}
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理定时器(用于优雅关闭)
|
||||
*/
|
||||
destroy() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(deviceCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备代码的状态信息(不删除)
|
||||
* @param {string} deviceCode - 设备代码
|
||||
* @returns {object|null} 状态信息
|
||||
*/
|
||||
getStatus(deviceCode) {
|
||||
const entry = this.store.get(deviceCode);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(deviceCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
hasToken: !!entry.token,
|
||||
expiresAt: entry.expiresAt,
|
||||
createdAt: entry.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的条目
|
||||
*/
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
let cleanedCount = 0;
|
||||
|
||||
for (const [deviceCode, entry] of this.store.entries()) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.store.delete(deviceCode);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
console.log(`清理了 ${cleanedCount} 个过期的设备代码`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前存储的条目数量
|
||||
*/
|
||||
size() {
|
||||
return this.store.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理定时器(用于优雅关闭)
|
||||
*/
|
||||
destroy() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
this.store.clear();
|
||||
}
|
||||
this.store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
@ -189,11 +189,11 @@ const deviceCodeStore = new DeviceCodeStore();
|
||||
|
||||
// 优雅关闭处理
|
||||
process.on('SIGTERM', () => {
|
||||
deviceCodeStore.destroy();
|
||||
deviceCodeStore.destroy();
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
deviceCodeStore.destroy();
|
||||
deviceCodeStore.destroy();
|
||||
});
|
||||
|
||||
export default deviceCodeStore;
|
||||
|
||||
108
utils/errors.js
108
utils/errors.js
@ -1,18 +1,20 @@
|
||||
/**
|
||||
* 创建标准错误对象
|
||||
* @param {number} statusCode - HTTP状态码
|
||||
* @param {string} [message] - 错误消息
|
||||
* @param {string} [message] - 错误消息(推荐使用稳定的机器可读文本,如 JWT_EXPIRED)
|
||||
* @param {object} [details] - 附加信息
|
||||
* @param {string} [code] - 业务错误码(如 AUTH_JWT_EXPIRED)
|
||||
* @returns {object} 标准错误对象
|
||||
*/
|
||||
const createError = (statusCode, message, details = null) => {
|
||||
// 直接返回错误对象,不抛出异常
|
||||
const error = {
|
||||
statusCode: statusCode,
|
||||
message: message || '服务器错误',
|
||||
details: details
|
||||
};
|
||||
return error;
|
||||
const createError = (statusCode, message, details = null, code = null) => {
|
||||
// 直接返回错误对象,不抛出异常
|
||||
const error = {
|
||||
statusCode: statusCode,
|
||||
message: message || '服务器错误',
|
||||
details: details,
|
||||
code: code || undefined,
|
||||
};
|
||||
return error;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -22,11 +24,11 @@ const createError = (statusCode, message, details = null) => {
|
||||
* @returns {object} 格式化的成功响应
|
||||
*/
|
||||
const createSuccessResponse = (data, message = null) => {
|
||||
return {
|
||||
success: true,
|
||||
message,
|
||||
data,
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
message,
|
||||
data,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@ -35,20 +37,20 @@ const createSuccessResponse = (data, message = null) => {
|
||||
* @param {Function} next - Express中间件next函数
|
||||
*/
|
||||
const passError = (error, next) => {
|
||||
// 不管是什么类型的错误,统一转换并传递
|
||||
if (error instanceof Error) {
|
||||
// 如果是标准Error,则转换为HTTP错误并保留原始信息
|
||||
const httpError = {
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '服务器错误',
|
||||
details: error.details || null,
|
||||
originalError: error
|
||||
};
|
||||
next(httpError);
|
||||
} else {
|
||||
// 已经是自定义错误对象结构,直接传递
|
||||
next(error);
|
||||
}
|
||||
// 不管是什么类型的错误,统一转换并传递
|
||||
if (error instanceof Error) {
|
||||
// 如果是标准Error,则转换为HTTP错误并保留原始信息
|
||||
const httpError = {
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '服务器错误',
|
||||
details: error.details || null,
|
||||
originalError: error
|
||||
};
|
||||
next(httpError);
|
||||
} else {
|
||||
// 已经是自定义错误对象结构,直接传递
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@ -57,11 +59,11 @@ const passError = (error, next) => {
|
||||
* @returns {Function} 包装后的函数
|
||||
*/
|
||||
const catchAsync = (fn) => {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(error => {
|
||||
passError(error, next);
|
||||
});
|
||||
};
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(error => {
|
||||
passError(error, next);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@ -71,31 +73,31 @@ const catchAsync = (fn) => {
|
||||
* @returns {[error, result]} 包含错误和结果的数组
|
||||
*/
|
||||
const trySafe = (fn, ...args) => {
|
||||
try {
|
||||
const result = fn(...args);
|
||||
return [null, result];
|
||||
} catch (error) {
|
||||
return [error, null];
|
||||
}
|
||||
try {
|
||||
const result = fn(...args);
|
||||
return [null, result];
|
||||
} catch (error) {
|
||||
return [error, null];
|
||||
}
|
||||
};
|
||||
|
||||
// 常用状态码
|
||||
const HTTP_STATUS = {
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
NO_CONTENT: 204,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
NO_CONTENT: 204,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
};
|
||||
|
||||
export default {
|
||||
createError,
|
||||
createSuccessResponse,
|
||||
passError,
|
||||
catchAsync,
|
||||
trySafe,
|
||||
HTTP_STATUS,
|
||||
createError,
|
||||
createSuccessResponse,
|
||||
passError,
|
||||
catchAsync,
|
||||
trySafe,
|
||||
HTTP_STATUS,
|
||||
};
|
||||
|
||||
@ -1,42 +1,43 @@
|
||||
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
|
||||
const traceExporter = new OTLPTraceExporter({
|
||||
url: "https://api.axiom.co/v1/traces",
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.AXIOM_TOKEN}`,
|
||||
"X-Axiom-Dataset": process.env.AXIOM_DATASET,
|
||||
},
|
||||
});
|
||||
// Initialize OTLP trace exporter with the endpoint URL and headers
|
||||
// Initialize OTLP trace exporter with the endpoint URL and headers
|
||||
const traceExporter = new OTLPTraceExporter({
|
||||
url: "https://api.axiom.co/v1/traces",
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.AXIOM_TOKEN}`,
|
||||
"X-Axiom-Dataset": process.env.AXIOM_DATASET,
|
||||
},
|
||||
});
|
||||
|
||||
const resourceAttributes = {
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: "node traces",
|
||||
};
|
||||
const resourceAttributes = {
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: "node traces",
|
||||
};
|
||||
|
||||
const resource = resourceFromAttributes(resourceAttributes);
|
||||
const resource = resourceFromAttributes(resourceAttributes);
|
||||
|
||||
// Configuring the OpenTelemetry Node SDK
|
||||
const sdk = new NodeSDK({
|
||||
// Adding a BatchSpanProcessor to batch and send traces
|
||||
spanProcessor: new BatchSpanProcessor(traceExporter),
|
||||
// Configuring the OpenTelemetry Node SDK
|
||||
const sdk = new NodeSDK({
|
||||
// Adding a BatchSpanProcessor to batch and send traces
|
||||
spanProcessor: new BatchSpanProcessor(traceExporter),
|
||||
|
||||
// Registering the resource to the SDK
|
||||
resource: resource,
|
||||
// Registering the resource to the SDK
|
||||
resource: resource,
|
||||
|
||||
// Adding auto-instrumentations to automatically collect trace data
|
||||
instrumentations: [getNodeAutoInstrumentations()],
|
||||
});
|
||||
// Adding auto-instrumentations to automatically collect trace data
|
||||
instrumentations: [getNodeAutoInstrumentations()],
|
||||
});
|
||||
|
||||
console.log("✅成功加载 Axiom 遥测");
|
||||
// Starting the OpenTelemetry SDK to begin collecting telemetry data
|
||||
sdk.start();
|
||||
console.log("✅成功加载 Axiom 遥测");
|
||||
// Starting the OpenTelemetry SDK to begin collecting telemetry data
|
||||
sdk.start();
|
||||
} else {
|
||||
console.log("❌未设置 Axiom 遥测");
|
||||
console.log("❌未设置 Axiom 遥测");
|
||||
}
|
||||
|
||||
71
utils/jwt.js
71
utils/jwt.js
@ -1,4 +1,12 @@
|
||||
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();
|
||||
@ -12,44 +20,57 @@ const JWT_PRIVATE_KEY = process.env.JWT_PRIVATE_KEY?.replace(/\\n/g, '\n');
|
||||
const JWT_PUBLIC_KEY = process.env.JWT_PUBLIC_KEY?.replace(/\\n/g, '\n');
|
||||
|
||||
function getSignVerifyKeys() {
|
||||
if (JWT_ALG === 'RS256') {
|
||||
if (!JWT_PRIVATE_KEY || !JWT_PUBLIC_KEY) {
|
||||
throw new Error('RS256 需要同时提供 JWT_PRIVATE_KEY 与 JWT_PUBLIC_KEY');
|
||||
if (JWT_ALG === 'RS256') {
|
||||
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 };
|
||||
// 默认 HS256
|
||||
return {signKey: JWT_SECRET, verifyKey: JWT_SECRET};
|
||||
}
|
||||
|
||||
/**
|
||||
* 签发JWT token
|
||||
* 签发JWT token(向后兼容)
|
||||
* @deprecated 建议使用 generateAccessToken
|
||||
*/
|
||||
export function signToken(payload) {
|
||||
const { signKey } = getSignVerifyKeys();
|
||||
return jwt.sign(payload, signKey, {
|
||||
expiresIn: JWT_EXPIRES_IN,
|
||||
algorithm: JWT_ALG,
|
||||
});
|
||||
const {signKey} = getSignVerifyKeys();
|
||||
return jwt.sign(payload, signKey, {
|
||||
expiresIn: JWT_EXPIRES_IN,
|
||||
algorithm: JWT_ALG,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证JWT token
|
||||
* 验证JWT token(向后兼容)
|
||||
* @deprecated 建议使用 verifyAccessToken
|
||||
*/
|
||||
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
|
||||
* 为账户生成JWT token(向后兼容)
|
||||
* @deprecated 建议使用 generateTokenPair 获取完整的令牌对
|
||||
*/
|
||||
export function generateAccountToken(account) {
|
||||
return signToken({
|
||||
accountId: account.id,
|
||||
provider: account.provider,
|
||||
email: account.email,
|
||||
name: account.name,
|
||||
avatarUrl: account.avatarUrl,
|
||||
});
|
||||
}
|
||||
return signToken({
|
||||
accountId: account.id,
|
||||
provider: account.provider,
|
||||
email: account.email,
|
||||
name: account.name,
|
||||
avatarUrl: account.avatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
// 重新导出新的token管理功能
|
||||
export {
|
||||
generateAccessToken,
|
||||
verifyAccessToken,
|
||||
generateTokenPair,
|
||||
refreshAccessToken,
|
||||
revokeAllTokens,
|
||||
revokeRefreshToken,
|
||||
};
|
||||
491
utils/kvStore.js
491
utils/kvStore.js
@ -1,210 +1,311 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import {keysTotal} from "./metrics.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
class KVStore {
|
||||
/**
|
||||
* 通过设备ID和键名获取值
|
||||
* @param {number} deviceId - 设备ID
|
||||
* @param {string} key - 键名
|
||||
* @returns {object|null} 键对应的值或null
|
||||
*/
|
||||
async get(deviceId, key) {
|
||||
const item = await prisma.kVStore.findUnique({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: deviceId,
|
||||
key: key,
|
||||
},
|
||||
},
|
||||
});
|
||||
return item ? item.value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取键的完整信息(包括元数据)
|
||||
* @param {number} deviceId - 设备ID
|
||||
* @param {string} key - 键名
|
||||
* @returns {object|null} 键的完整信息或null
|
||||
*/
|
||||
async getMetadata(deviceId, key) {
|
||||
const item = await prisma.kVStore.findUnique({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: deviceId,
|
||||
key: key,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
deviceId: true,
|
||||
creatorIp: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
// 转换为更友好的格式
|
||||
return {
|
||||
deviceId: item.deviceId,
|
||||
key: item.key,
|
||||
metadata: {
|
||||
creatorIp: item.creatorIp,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定设备下创建或更新键值
|
||||
* @param {number} deviceId - 设备ID
|
||||
* @param {string} key - 键名
|
||||
* @param {object} value - 键值
|
||||
* @param {string} creatorIp - 创建者IP,可选
|
||||
* @returns {object} 创建或更新的记录
|
||||
*/
|
||||
async upsert(deviceId, key, value, creatorIp = "") {
|
||||
const item = await prisma.kVStore.upsert({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: deviceId,
|
||||
key: key,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value,
|
||||
...(creatorIp && { creatorIp }),
|
||||
},
|
||||
create: {
|
||||
deviceId: deviceId,
|
||||
key: key,
|
||||
value,
|
||||
creatorIp,
|
||||
},
|
||||
});
|
||||
|
||||
// 返回带有设备ID和原始键的结果
|
||||
return {
|
||||
deviceId,
|
||||
key,
|
||||
value: item.value,
|
||||
creatorIp: item.creatorIp,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过设备ID和键名删除
|
||||
* @param {number} deviceId - 设备ID
|
||||
* @param {string} key - 键名
|
||||
* @returns {object|null} 删除的记录或null
|
||||
*/
|
||||
async delete(deviceId, key) {
|
||||
try {
|
||||
const item = await prisma.kVStore.delete({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: deviceId,
|
||||
key: key,
|
||||
},
|
||||
},
|
||||
});
|
||||
return item ? { ...item, deviceId, key } : null;
|
||||
} catch (error) {
|
||||
// 忽略记录不存在的错误
|
||||
if (error.code === "P2025") return null;
|
||||
throw error;
|
||||
/**
|
||||
* 通过设备ID和键名获取值
|
||||
* @param {number} deviceId - 设备ID
|
||||
* @param {string} key - 键名
|
||||
* @returns {object|null} 键对应的值或null
|
||||
*/
|
||||
async get(deviceId, key) {
|
||||
const item = await prisma.kVStore.findUnique({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: deviceId,
|
||||
key: key,
|
||||
},
|
||||
},
|
||||
});
|
||||
return item ? item.value : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出指定设备下的所有键名及其元数据
|
||||
* @param {number} deviceId - 设备ID
|
||||
* @param {object} options - 选项参数
|
||||
* @returns {Array} 键名和元数据数组
|
||||
*/
|
||||
async list(deviceId, options = {}) {
|
||||
const { sortBy = "key", sortDir = "asc", limit = 100, skip = 0 } = options;
|
||||
/**
|
||||
* 获取键的完整信息(包括元数据)
|
||||
* @param {number} deviceId - 设备ID
|
||||
* @param {string} key - 键名
|
||||
* @returns {object|null} 键的完整信息或null
|
||||
*/
|
||||
async getMetadata(deviceId, key) {
|
||||
const item = await prisma.kVStore.findUnique({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: deviceId,
|
||||
key: key,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
deviceId: true,
|
||||
creatorIp: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 构建排序条件
|
||||
const orderBy = {};
|
||||
orderBy[sortBy] = sortDir.toLowerCase();
|
||||
if (!item) return null;
|
||||
|
||||
// 查询设备的所有键
|
||||
const items = await prisma.kVStore.findMany({
|
||||
where: {
|
||||
deviceId: deviceId,
|
||||
},
|
||||
select: {
|
||||
deviceId: true,
|
||||
key: true,
|
||||
creatorIp: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
value: false,
|
||||
},
|
||||
orderBy,
|
||||
take: limit,
|
||||
skip: skip,
|
||||
});
|
||||
// 转换为更友好的格式
|
||||
return {
|
||||
deviceId: item.deviceId,
|
||||
key: item.key,
|
||||
metadata: {
|
||||
creatorIp: item.creatorIp,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 处理结果
|
||||
return items.map((item) => ({
|
||||
deviceId: item.deviceId,
|
||||
key: item.key,
|
||||
metadata: {
|
||||
creatorIp: item.creatorIp,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
},
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* 在指定设备下创建或更新键值
|
||||
* @param {number} deviceId - 设备ID
|
||||
* @param {string} key - 键名
|
||||
* @param {object} value - 键值
|
||||
* @param {string} creatorIp - 创建者IP,可选
|
||||
* @returns {object} 创建或更新的记录
|
||||
*/
|
||||
async upsert(deviceId, key, value, creatorIp = "") {
|
||||
const item = await prisma.kVStore.upsert({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: deviceId,
|
||||
key: key,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value,
|
||||
...(creatorIp && {creatorIp}),
|
||||
},
|
||||
create: {
|
||||
deviceId: deviceId,
|
||||
key: key,
|
||||
value,
|
||||
creatorIp,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取指定设备下的键名列表(不包括内容)
|
||||
* @param {number} deviceId - 设备ID
|
||||
* @param {object} options - 查询选项
|
||||
* @returns {Array} 键名列表
|
||||
*/
|
||||
async listKeysOnly(deviceId, options = {}) {
|
||||
const { sortBy = "key", sortDir = "asc", limit = 100, skip = 0 } = options;
|
||||
// 更新键总数指标
|
||||
const totalKeys = await prisma.kVStore.count();
|
||||
keysTotal.set(totalKeys);
|
||||
|
||||
// 构建排序条件
|
||||
const orderBy = {};
|
||||
orderBy[sortBy] = sortDir.toLowerCase();
|
||||
// 返回带有设备ID和原始键的结果
|
||||
return {
|
||||
deviceId,
|
||||
key,
|
||||
value: item.value,
|
||||
creatorIp: item.creatorIp,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// 查询设备的所有键,只选择键名
|
||||
const items = await prisma.kVStore.findMany({
|
||||
where: {
|
||||
deviceId: deviceId,
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
orderBy,
|
||||
take: limit,
|
||||
skip: skip,
|
||||
});
|
||||
/**
|
||||
* 批量创建或更新键值对(优化性能)
|
||||
* @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 = [];
|
||||
|
||||
// 只返回键名数组
|
||||
return items.map((item) => item.key);
|
||||
}
|
||||
// 使用事务处理所有操作
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 统计指定设备下的键值对数量
|
||||
* @param {number} deviceId - 设备ID
|
||||
* @returns {number} 键值对数量
|
||||
*/
|
||||
async count(deviceId) {
|
||||
const count = await prisma.kVStore.count({
|
||||
where: {
|
||||
deviceId: deviceId,
|
||||
},
|
||||
});
|
||||
return count;
|
||||
}
|
||||
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
|
||||
* @param {string} key - 键名
|
||||
* @returns {object|null} 删除的记录或null
|
||||
*/
|
||||
async delete(deviceId, key) {
|
||||
try {
|
||||
const item = await prisma.kVStore.delete({
|
||||
where: {
|
||||
deviceId_key: {
|
||||
deviceId: deviceId,
|
||||
key: key,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 更新键总数指标
|
||||
const totalKeys = await prisma.kVStore.count();
|
||||
keysTotal.set(totalKeys);
|
||||
|
||||
return item ? {...item, deviceId, key} : null;
|
||||
} catch (error) {
|
||||
// 忽略记录不存在的错误
|
||||
if (error.code === "P2025") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出指定设备下的所有键名及其元数据
|
||||
* @param {number} deviceId - 设备ID
|
||||
* @param {object} options - 选项参数
|
||||
* @returns {Array} 键名和元数据数组
|
||||
*/
|
||||
async list(deviceId, options = {}) {
|
||||
const {sortBy = "key", sortDir = "asc", limit = 100, skip = 0} = options;
|
||||
|
||||
// 构建排序条件
|
||||
const orderBy = {};
|
||||
orderBy[sortBy] = sortDir.toLowerCase();
|
||||
|
||||
// 查询设备的所有键
|
||||
const items = await prisma.kVStore.findMany({
|
||||
where: {
|
||||
deviceId: deviceId,
|
||||
},
|
||||
select: {
|
||||
deviceId: true,
|
||||
key: true,
|
||||
creatorIp: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
value: false,
|
||||
},
|
||||
orderBy,
|
||||
take: limit,
|
||||
skip: skip,
|
||||
});
|
||||
|
||||
// 处理结果
|
||||
return items.map((item) => ({
|
||||
deviceId: item.deviceId,
|
||||
key: item.key,
|
||||
metadata: {
|
||||
creatorIp: item.creatorIp,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定设备下的键名列表(不包括内容)
|
||||
* @param {number} deviceId - 设备ID
|
||||
* @param {object} options - 查询选项
|
||||
* @returns {Array} 键名列表
|
||||
*/
|
||||
async listKeysOnly(deviceId, options = {}) {
|
||||
const {sortBy = "key", sortDir = "asc", limit = 100, skip = 0} = options;
|
||||
|
||||
// 构建排序条件
|
||||
const orderBy = {};
|
||||
orderBy[sortBy] = sortDir.toLowerCase();
|
||||
|
||||
// 查询设备的所有键,只选择键名
|
||||
const items = await prisma.kVStore.findMany({
|
||||
where: {
|
||||
deviceId: deviceId,
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
orderBy,
|
||||
take: limit,
|
||||
skip: skip,
|
||||
});
|
||||
|
||||
// 只返回键名数组
|
||||
return items.map((item) => item.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计指定设备下的键值对数量
|
||||
* @param {number} deviceId - 设备ID
|
||||
* @returns {number} 键值对数量
|
||||
*/
|
||||
async count(deviceId) {
|
||||
const count = await prisma.kVStore.count({
|
||||
where: {
|
||||
deviceId: deviceId,
|
||||
},
|
||||
});
|
||||
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();
|
||||
|
||||
49
utils/metrics.js
Normal file
49
utils/metrics.js
Normal file
@ -0,0 +1,49 @@
|
||||
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};
|
||||
@ -1,4 +1,4 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import {PrismaClient} from "@prisma/client";
|
||||
import kvStore from "./kvStore.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
@ -12,8 +12,8 @@ let systemDeviceId = null;
|
||||
|
||||
// 封装默认 readme 对象
|
||||
const defaultReadme = {
|
||||
title: "Classworks 服务端",
|
||||
readme: "暂无 Readme 内容",
|
||||
title: "Classworks 服务端",
|
||||
readme: "暂无 Readme 内容",
|
||||
};
|
||||
|
||||
/**
|
||||
@ -21,25 +21,25 @@ const defaultReadme = {
|
||||
* @returns {Promise<number>} 系统设备ID
|
||||
*/
|
||||
async function getSystemDeviceId() {
|
||||
if (systemDeviceId) return systemDeviceId;
|
||||
if (systemDeviceId) return systemDeviceId;
|
||||
|
||||
let device = await prisma.device.findUnique({
|
||||
where: { uuid: SYSTEM_DEVICE_UUID },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
device = await prisma.device.create({
|
||||
data: {
|
||||
uuid: SYSTEM_DEVICE_UUID,
|
||||
name: "系统设备",
|
||||
},
|
||||
select: { id: true },
|
||||
let device = await prisma.device.findUnique({
|
||||
where: {uuid: SYSTEM_DEVICE_UUID},
|
||||
select: {id: true},
|
||||
});
|
||||
}
|
||||
|
||||
systemDeviceId = device.id;
|
||||
return systemDeviceId;
|
||||
if (!device) {
|
||||
device = await prisma.device.create({
|
||||
data: {
|
||||
uuid: SYSTEM_DEVICE_UUID,
|
||||
name: "系统设备",
|
||||
},
|
||||
select: {id: true},
|
||||
});
|
||||
}
|
||||
|
||||
systemDeviceId = device.id;
|
||||
return systemDeviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -47,26 +47,26 @@ async function getSystemDeviceId() {
|
||||
* 在应用启动时调用此函数
|
||||
*/
|
||||
export const initReadme = async () => {
|
||||
try {
|
||||
const deviceId = await getSystemDeviceId();
|
||||
const storedValue = await kvStore.get(deviceId, "info");
|
||||
try {
|
||||
const deviceId = await getSystemDeviceId();
|
||||
const storedValue = await kvStore.get(deviceId, "info");
|
||||
|
||||
// 合并默认值与存储值,确保结构完整
|
||||
readmeValue = {
|
||||
...defaultReadme,
|
||||
...(storedValue || {}),
|
||||
};
|
||||
// 合并默认值与存储值,确保结构完整
|
||||
readmeValue = {
|
||||
...defaultReadme,
|
||||
...(storedValue || {}),
|
||||
};
|
||||
|
||||
console.log("✅ 站点信息初始化成功");
|
||||
} catch (error) {
|
||||
console.error("❌ 站点信息初始化失败:", {
|
||||
message: error?.message,
|
||||
stack: error?.stack,
|
||||
});
|
||||
console.log("✅ 站点信息初始化成功");
|
||||
} catch (error) {
|
||||
console.error("❌ 站点信息初始化失败:", {
|
||||
message: error?.message,
|
||||
stack: error?.stack,
|
||||
});
|
||||
|
||||
// 确保在异常情况下也有默认值
|
||||
readmeValue = { ...defaultReadme };
|
||||
}
|
||||
// 确保在异常情况下也有默认值
|
||||
readmeValue = {...defaultReadme};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@ -74,7 +74,7 @@ export const initReadme = async () => {
|
||||
* @returns {Object} readme 值对象
|
||||
*/
|
||||
export const getReadmeValue = () => {
|
||||
return readmeValue || { ...defaultReadme };
|
||||
return readmeValue || {...defaultReadme};
|
||||
};
|
||||
|
||||
/**
|
||||
@ -83,19 +83,19 @@ export const getReadmeValue = () => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const updateReadmeValue = async (newValue) => {
|
||||
try {
|
||||
const deviceId = await getSystemDeviceId();
|
||||
await kvStore.upsert(deviceId, "info", newValue);
|
||||
readmeValue = {
|
||||
...defaultReadme,
|
||||
...newValue,
|
||||
};
|
||||
console.log("✅ 站点信息更新成功");
|
||||
} catch (error) {
|
||||
console.error("❌ 站点信息更新失败:", {
|
||||
message: error?.message,
|
||||
stack: error?.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const deviceId = await getSystemDeviceId();
|
||||
await kvStore.upsert(deviceId, "info", newValue);
|
||||
readmeValue = {
|
||||
...defaultReadme,
|
||||
...newValue,
|
||||
};
|
||||
console.log("✅ 站点信息更新成功");
|
||||
} catch (error) {
|
||||
console.error("❌ 站点信息更新失败:", {
|
||||
message: error?.message,
|
||||
stack: error?.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
655
utils/socket.js
655
utils/socket.js
@ -7,113 +7,305 @@
|
||||
* - 同一设备的不同 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 实例
|
||||
*/
|
||||
export function initSocket(server) {
|
||||
if (io) return io;
|
||||
if (io) return io;
|
||||
|
||||
const allowOrigin = process.env.FRONTEND_URL || "*";
|
||||
|
||||
io = new Server(server, {
|
||||
cors: {
|
||||
origin: allowOrigin,
|
||||
methods: ["GET", "POST"],
|
||||
credentials: true,
|
||||
},
|
||||
});
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
// 初始化每个连接所加入的设备房间集合
|
||||
socket.data.deviceUuids = new Set();
|
||||
|
||||
// 仅允许通过 query.token/apptoken 加入
|
||||
const qToken = socket.handshake?.query?.token || socket.handshake?.query?.apptoken;
|
||||
if (qToken && typeof qToken === "string") {
|
||||
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(() => {});
|
||||
}
|
||||
io = new Server(server, {
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
allowedHeaders: ["*"],
|
||||
credentials: false
|
||||
},
|
||||
// 传输方式回退策略:优先使用WebSocket,回退到轮询
|
||||
transports: ["polling", "websocket"],
|
||||
});
|
||||
|
||||
// 客户端使用 token 离开房间
|
||||
socket.on("leave-token", async (payload) => {
|
||||
try {
|
||||
const token = payload?.token || payload?.apptoken;
|
||||
if (typeof token !== "string" || token.length === 0) return;
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: { token },
|
||||
include: { device: { select: { uuid: true } } },
|
||||
io.on("connection", (socket) => {
|
||||
// 初始化每个连接所加入的设备房间集合
|
||||
socket.data.deviceUuids = new Set();
|
||||
|
||||
// 仅允许通过 query.token/apptoken 加入
|
||||
const qToken = socket.handshake?.query?.token || socket.handshake?.query?.apptoken;
|
||||
if (qToken && typeof qToken === "string") {
|
||||
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(() => {
|
||||
});
|
||||
}
|
||||
});
|
||||
const uuid = appInstall?.device?.uuid;
|
||||
if (uuid) leaveDeviceRoom(socket, uuid);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
// 离开所有已加入的设备房间
|
||||
socket.on("leave-all", () => {
|
||||
const uuids = Array.from(socket.data.deviceUuids || []);
|
||||
uuids.forEach((u) => leaveDeviceRoom(socket, u));
|
||||
});
|
||||
|
||||
// 聊天室:发送文本消息到加入的设备频道
|
||||
socket.on("chat:send", (data) => {
|
||||
try {
|
||||
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;
|
||||
|
||||
const at = new Date().toISOString();
|
||||
const payload = { text: safeText, at, senderId: socket.id };
|
||||
|
||||
uuids.forEach((uuid) => {
|
||||
io.to(uuid).emit("chat:message", { uuid, ...payload });
|
||||
// 客户端使用 token 离开房间
|
||||
socket.on("leave-token", async (payload) => {
|
||||
try {
|
||||
const token = payload?.token || payload?.apptoken;
|
||||
if (typeof token !== "string" || token.length === 0) return;
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: { token },
|
||||
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);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
// 离开所有已加入的设备房间
|
||||
socket.on("leave-all", () => {
|
||||
const uuids = Array.from(socket.data.deviceUuids || []);
|
||||
uuids.forEach((u) => leaveDeviceRoom(socket, u));
|
||||
});
|
||||
|
||||
// 获取事件历史记录
|
||||
socket.on("get-event-history", (data) => {
|
||||
try {
|
||||
const { limit = 50, offset = 0 } = data || {};
|
||||
const uuids = Array.from(socket.data.deviceUuids || []);
|
||||
|
||||
if (uuids.length === 0) {
|
||||
socket.emit("event-history-error", { reason: "not_joined_any_device" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 返回所有加入设备的事件历史
|
||||
const historyData = {};
|
||||
uuids.forEach((uuid) => {
|
||||
historyData[uuid] = getEventHistory(uuid, limit, offset);
|
||||
});
|
||||
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("chat:send error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
const uuids = Array.from(socket.data.deviceUuids || []);
|
||||
uuids.forEach((u) => removeOnline(u, socket.id));
|
||||
});
|
||||
});
|
||||
|
||||
return io;
|
||||
return io;
|
||||
}
|
||||
|
||||
/** 返回 Socket.IO 实例 */
|
||||
export function getIO() {
|
||||
return io;
|
||||
return io;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -122,15 +314,33 @@ export function getIO() {
|
||||
* @param {string} uuid
|
||||
*/
|
||||
function joinDeviceRoom(socket, uuid) {
|
||||
socket.join(uuid);
|
||||
if (!socket.data.deviceUuids) socket.data.deviceUuids = new Set();
|
||||
socket.data.deviceUuids.add(uuid);
|
||||
// 记录在线
|
||||
const set = onlineMap.get(uuid) || new Set();
|
||||
set.add(socket.id);
|
||||
onlineMap.set(uuid, set);
|
||||
// 可选:通知加入
|
||||
io.to(uuid).emit("device-joined", { uuid, connections: set.size });
|
||||
socket.join(uuid);
|
||||
if (!socket.data.deviceUuids) socket.data.deviceUuids = new Set();
|
||||
socket.data.deviceUuids.add(uuid);
|
||||
// 记录在线
|
||||
const set = onlineMap.get(uuid) || new Set();
|
||||
set.add(socket.id);
|
||||
onlineMap.set(uuid, set);
|
||||
// 可选:通知加入
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -139,20 +349,38 @@ function joinDeviceRoom(socket, uuid) {
|
||||
* @param {string} uuid
|
||||
*/
|
||||
function leaveDeviceRoom(socket, uuid) {
|
||||
socket.leave(uuid);
|
||||
if (socket.data.deviceUuids) socket.data.deviceUuids.delete(uuid);
|
||||
removeOnline(uuid, socket.id);
|
||||
socket.leave(uuid);
|
||||
if (socket.data.deviceUuids) socket.data.deviceUuids.delete(uuid);
|
||||
removeOnline(uuid, socket.id);
|
||||
}
|
||||
|
||||
function removeOnline(uuid, socketId) {
|
||||
const set = onlineMap.get(uuid);
|
||||
if (!set) return;
|
||||
set.delete(socketId);
|
||||
if (set.size === 0) {
|
||||
onlineMap.delete(uuid);
|
||||
} else {
|
||||
onlineMap.set(uuid, set);
|
||||
}
|
||||
const set = onlineMap.get(uuid);
|
||||
if (!set) return;
|
||||
set.delete(socketId);
|
||||
if (set.size === 0) {
|
||||
onlineMap.delete(uuid);
|
||||
} else {
|
||||
onlineMap.set(uuid, set);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 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);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -161,28 +389,158 @@ function removeOnline(uuid, socketId) {
|
||||
* @param {object} payload { key, action: 'upsert'|'delete'|'batch', updatedAt?, created? }
|
||||
*/
|
||||
export function broadcastKeyChanged(uuid, payload) {
|
||||
if (!io || !uuid) return;
|
||||
io.to(uuid).emit("kv-key-changed", { 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取在线设备列表
|
||||
* @returns {Array<{uuid:string, connections:number}>}
|
||||
* @returns {Array<{token:string, connections:number}>}
|
||||
*/
|
||||
export function getOnlineDevices() {
|
||||
const list = [];
|
||||
for (const [uuid, set] of onlineMap.entries()) {
|
||||
list.push({ uuid, connections: set.size });
|
||||
}
|
||||
// 默认按连接数降序
|
||||
return list.sort((a, b) => b.connections - a.connections);
|
||||
const list = [];
|
||||
for (const [token, set] of onlineTokens.entries()) {
|
||||
list.push({ token, connections: set.size });
|
||||
}
|
||||
// 默认按连接数降序
|
||||
return list.sort((a, b) => b.connections - a.connections);
|
||||
}
|
||||
|
||||
export default {
|
||||
initSocket,
|
||||
getIO,
|
||||
broadcastKeyChanged,
|
||||
getOnlineDevices,
|
||||
initSocket,
|
||||
getIO,
|
||||
broadcastKeyChanged,
|
||||
broadcastDeviceEvent,
|
||||
getOnlineDevices,
|
||||
getEventHistory,
|
||||
getTokenInfo,
|
||||
};
|
||||
|
||||
/**
|
||||
@ -191,16 +549,69 @@ export default {
|
||||
* @param {string} token
|
||||
*/
|
||||
async function joinByToken(socket, token) {
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: { token },
|
||||
include: { device: { select: { uuid: true } } },
|
||||
});
|
||||
const uuid = appInstall?.device?.uuid;
|
||||
if (uuid) {
|
||||
joinDeviceRoom(socket, uuid);
|
||||
// 可选:回执
|
||||
socket.emit("joined", { by: "token", uuid });
|
||||
} else {
|
||||
socket.emit("join-error", { by: "token", reason: "invalid_token" });
|
||||
}
|
||||
try {
|
||||
const appInstall = await prisma.appInstall.findUnique({
|
||||
where: { token },
|
||||
include: {
|
||||
device: {
|
||||
select: {
|
||||
uuid: true,
|
||||
name: 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;
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
} 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" });
|
||||
}
|
||||
}
|
||||
|
||||
298
utils/tokenManager.js
Normal file
298
utils/tokenManager.js
Normal file
@ -0,0 +1,298 @@
|
||||
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;
|
||||
@ -2,14 +2,16 @@
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Classworks 服务端</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Classworks 服务端</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Classworks 服务端</h1>
|
||||
<p>服务运行中</p>
|
||||
<h1>Classworks 服务端</h1>
|
||||
<p>服务运行中</p>
|
||||
</body>
|
||||
|
||||
<script>
|
||||
window.open('https://kv.houlang.cloud')
|
||||
</script>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user