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

feat: Implement Refresh Token system with enhanced security and user experience

- Added refresh token support in the account model with new fields: refreshToken, refreshTokenExpiry, and tokenVersion.
- Created a new token management utility (utils/tokenManager.js) for generating and verifying access and refresh tokens.
- Updated JWT utility (utils/jwt.js) to maintain backward compatibility while introducing new token generation methods.
- Enhanced middleware for JWT authentication to support new token types and automatic token refreshing.
- Expanded API endpoints in routes/accounts.js to include refresh token functionality, logout options, and token info retrieval.
- Introduced automatic token refresh mechanism in the front-end integration examples.
- Comprehensive migration checklist and documentation for the new refresh token system.
- Added database migration script to accommodate new fields in the Account table.
This commit is contained in:
SunWuyuan 2025-11-02 09:48:03 +08:00
parent 9f051885c2
commit 2ab90ffebc
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
13 changed files with 1498 additions and 157 deletions

129
MIGRATION_CHECKLIST.md Normal file
View 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
View 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
View 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 Token15分钟
- ✅ 长期Refresh Token7天
- ✅ Token版本控制
- ✅ 设备级登出
- ✅ 全局登出
- ✅ 自动刷新机制
- ✅ 向后兼容
## 🔄 迁移步骤
1. **更新环境变量**
2. **运行数据库迁移**
3. **更新前端OAuth回调处理**
4. **实现Token刷新逻辑**
5. **测试登出功能**
详细文档请参考:`REFRESH_TOKEN_API.md`

174
REFRESH_TOKEN_SUMMARY.md Normal file
View File

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

27
app.js
View File

@ -8,12 +8,6 @@ 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";
@ -38,9 +32,6 @@ 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");
@ -77,31 +68,31 @@ app.use((req, res, next) => {
app.get("/", (req, res) => {
res.render("index.ejs");
});
app.get("/check", apiLimiter, (req, res) => {
app.get("/check", (req, res) => {
res.json({
status: "success",
message: "API is running",
message: "Classworks KV is running",
time: new Date().getTime(),
});
});
// 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) => {

View File

@ -1,10 +1,12 @@
/**
* 纯账户JWT认证中间件
*
* 只验证账户JWT是否正确不需要设备上下文
* 支持新的refresh token系统验证access token
* 如果access token即将过期会在响应头中提供新的token
* 适用于只需要账户验证的接口
*/
import { verifyAccessToken, validateAccountToken, generateAccessToken } from "../utils/tokenManager.js";
import { verifyToken } from "../utils/jwt.js";
import { PrismaClient } from "@prisma/client";
import errors from "../utils/errors.js";
@ -12,8 +14,7 @@ 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 {
@ -25,30 +26,79 @@ export const jwtAuth = async (req, res, next) => {
const token = authHeader.substring(7);
// 验证JWT token
const decoded = verifyToken(token);
try {
// 尝试使用新的token验证系统
const decoded = verifyAccessToken(token);
// 从数据库获取账户信息
const account = await prisma.account.findUnique({
where: { id: decoded.accountId },
});
// 验证账户并检查token版本
const account = await validateAccountToken(decoded);
if (!account) {
return next(errors.createError(401, "账户不存在"));
// 将账户信息存储到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') {
return next(errors.createError(401, "JWT token已过期"));
}
return next(errors.createError(401, "token验证失败"));
}
}
// 将账户信息存储到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, "认证过程出错"));
}
};
/**
* 可选的JWT认证中间件
* 如果提供了token则验证没有提供则跳过
*/
export const optionalJwtAuth = async (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
// 没有提供token跳过认证
return next();
}
// 有token则进行验证
return jwtAuth(req, res, next);
};

View File

@ -30,53 +30,32 @@ export const getRateLimitKey = (req) => {
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}`;
};
// 创建一个中间件来将res.locals.token复制到req.locals.token以便限速器使用
export const prepareTokenForRateLimit = (req, res, next) => {
if (res.locals.token) {
req.locals = req.locals || {};
req.locals.token = res.locals.token;
}
next();
};
// 写操作限速器(更严格)
export const writeLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟
limit: 20, // 每个IP在windowMs时间内最多允许20个写操作
standardHeaders: "draft-7",
legacyHeaders: false,
message: "写操作请求过于频繁,请稍后再试",
keyGenerator: getClientIp,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
// 删除操作限速器(最严格)
export const deleteLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 5分钟
limit: 10, // 每个IP在windowMs时间内最多允许10个删除操作
standardHeaders: "draft-7",
legacyHeaders: false,
message: "删除操作请求过于频繁,请稍后再试",
keyGenerator: getClientIp,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
// 认证相关路由限速器(防止暴力破解)
export const authLimiter = rateLimit({
@ -90,19 +69,7 @@ export const authLimiter = rateLimit({
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({
@ -111,7 +78,7 @@ export const tokenReadLimiter = rateLimit({
standardHeaders: "draft-7",
legacyHeaders: false,
message: "读操作请求过于频繁,请稍后再试",
keyGenerator: getRateLimitKey,
keyGenerator: getTokenOnlyKey,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
@ -123,7 +90,7 @@ export const tokenWriteLimiter = rateLimit({
standardHeaders: "draft-7",
legacyHeaders: false,
message: "写操作请求过于频繁,请稍后再试",
keyGenerator: getRateLimitKey,
keyGenerator: getTokenOnlyKey,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
@ -135,7 +102,7 @@ export const tokenDeleteLimiter = rateLimit({
standardHeaders: "draft-7",
legacyHeaders: false,
message: "删除操作请求过于频繁,请稍后再试",
keyGenerator: getRateLimitKey,
keyGenerator: getTokenOnlyKey,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
@ -147,53 +114,7 @@ export const tokenBatchLimiter = rateLimit({
standardHeaders: "draft-7",
legacyHeaders: false,
message: "批量操作请求过于频繁,请稍后再试",
keyGenerator: getRateLimitKey,
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);
};

View File

@ -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;

View File

@ -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

View File

@ -2,8 +2,9 @@ import { Router } from "express";
import { PrismaClient } from "@prisma/client";
import crypto from "crypto";
import { oauthProviders, getCallbackURL, generateState } from "../config/oauth.js";
import { generateAccountToken, verifyToken } from "../utils/jwt.js";
import { generateAccountToken, generateTokenPair, refreshAccessToken, revokeAllTokens, revokeRefreshToken } from "../utils/jwt.js";
import { jwtAuth } from "../middleware/jwt-auth.js";
import errors from "../utils/errors.js";
const router = Router();
const prisma = new PrismaClient();
@ -331,13 +332,15 @@ router.get("/oauth/:provider/callback", async (req, res) => {
});
}
// 5. 生成JWT token
const jwtToken = generateAccountToken(account);
// 5. 生成令牌对(访问令牌 + 刷新令牌)
const tokens = await generateTokenPair(account);
// 6. 重定向到前端根路径携带JWT token
const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
const callbackUrl = new URL(frontendBaseUrl);
callbackUrl.searchParams.append("token", jwtToken);
callbackUrl.searchParams.append("access_token", tokens.accessToken);
callbackUrl.searchParams.append("refresh_token", tokens.refreshToken);
callbackUrl.searchParams.append("expires_in", tokens.accessTokenExpiresIn);
callbackUrl.searchParams.append("provider", provider);
// 附带展示信息,便于前端显示品牌与名称
const pconf = oauthProviders[provider] || {};
@ -652,4 +655,136 @@ router.get("/device/:uuid/account", async (req, res, next) => {
}
});
/**
* 刷新访问令牌
* POST /api/accounts/refresh
*
* Body:
* {
* refresh_token: string // 刷新令牌
* }
*/
router.post("/refresh", async (req, res, next) => {
try {
const { refresh_token } = req.body;
if (!refresh_token) {
return res.status(400).json({
success: false,
message: "缺少刷新令牌",
});
}
// 刷新访问令牌
const result = await refreshAccessToken(refresh_token);
res.json({
success: true,
message: "令牌刷新成功",
data: {
access_token: result.accessToken,
expires_in: result.accessTokenExpiresIn,
account: result.account,
},
});
} catch (error) {
if (error.message === 'Account not found') {
return next(errors.createError(401, "账户不存在"));
}
if (error.message === 'Invalid refresh token') {
return next(errors.createError(401, "无效的刷新令牌"));
}
if (error.message === 'Refresh token expired') {
return next(errors.createError(401, "刷新令牌已过期"));
}
if (error.message === 'Token version mismatch') {
return next(errors.createError(401, "令牌版本不匹配,请重新登录"));
}
next(error);
}
});
/**
* 登出撤销当前设备的刷新令牌
* POST /api/accounts/logout
*
* Headers:
* Authorization: Bearer <JWT Token>
*/
router.post("/logout", jwtAuth, async (req, res, next) => {
try {
const accountContext = res.locals.account;
// 撤销当前设备的刷新令牌
await revokeRefreshToken(accountContext.id);
res.json({
success: true,
message: "登出成功",
});
} catch (error) {
next(error);
}
});
/**
* 登出所有设备撤销所有令牌
* POST /api/accounts/logout-all
*
* Headers:
* Authorization: Bearer <JWT Token>
*/
router.post("/logout-all", jwtAuth, async (req, res, next) => {
try {
const accountContext = res.locals.account;
// 撤销所有令牌
await revokeAllTokens(accountContext.id);
res.json({
success: true,
message: "已从所有设备登出",
});
} catch (error) {
next(error);
}
});
/**
* 获取令牌信息
* GET /api/accounts/token-info
*
* Headers:
* Authorization: Bearer <JWT Token>
*/
router.get("/token-info", jwtAuth, async (req, res, next) => {
try {
const decoded = res.locals.tokenDecoded;
const account = res.locals.account;
// 计算token剩余有效时间
const now = Math.floor(Date.now() / 1000);
const expiresIn = decoded.exp - now;
res.json({
success: true,
data: {
accountId: account.id,
tokenType: decoded.type || 'legacy',
tokenVersion: decoded.tokenVersion || account.tokenVersion,
issuedAt: new Date(decoded.iat * 1000),
expiresAt: new Date(decoded.exp * 1000),
expiresIn: expiresIn,
isExpired: expiresIn <= 0,
isLegacyToken: res.locals.isLegacyToken || false,
hasRefreshToken: !!account.refreshToken,
refreshTokenExpiry: account.refreshTokenExpiry,
},
});
} catch (error) {
next(error);
}
});
export default router;

View File

@ -3,6 +3,13 @@ const router = Router();
import kvStore from "../utils/kvStore.js";
import { broadcastKeyChanged } from "../utils/socket.js";
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
import {
tokenReadLimiter,
tokenWriteLimiter,
tokenDeleteLimiter,
tokenBatchLimiter,
prepareTokenForRateLimit
} from "../middleware/rateLimiter.js";
import errors from "../utils/errors.js";
import { PrismaClient } from "@prisma/client";
@ -11,12 +18,16 @@ const prisma = new PrismaClient();
// 使用KV专用token认证
router.use(kvTokenAuth);
// 准备token用于限速器
router.use(prepareTokenForRateLimit);
/**
* GET /_info
* 获取当前token所属设备的信息如果关联了账号也返回账号信息
*/
router.get(
"/_info",
tokenReadLimiter,
errors.catchAsync(async (req, res) => {
const deviceId = res.locals.deviceId;
@ -62,6 +73,7 @@ router.get(
*/
router.get(
"/_token",
tokenReadLimiter,
errors.catchAsync(async (req, res, next) => {
const token = res.locals.token;
const deviceId = res.locals.deviceId;
@ -110,6 +122,7 @@ router.get(
*/
router.get(
"/_keys",
tokenReadLimiter,
errors.catchAsync(async (req, res) => {
const deviceId = res.locals.deviceId;
const { sortBy, sortDir, limit, skip } = req.query;
@ -160,6 +173,7 @@ router.get(
*/
router.get(
"/",
tokenReadLimiter,
errors.catchAsync(async (req, res) => {
const deviceId = res.locals.deviceId;
const { sortBy, sortDir, limit, skip } = req.query;
@ -205,6 +219,7 @@ router.get(
*/
router.get(
"/:key",
tokenReadLimiter,
errors.catchAsync(async (req, res, next) => {
const deviceId = res.locals.deviceId;
const { key } = req.params;
@ -227,6 +242,7 @@ router.get(
*/
router.get(
"/:key/metadata",
tokenReadLimiter,
errors.catchAsync(async (req, res, next) => {
const deviceId = res.locals.deviceId;
const { key } = req.params;
@ -247,6 +263,7 @@ router.get(
*/
router.post(
"/_batchimport",
tokenBatchLimiter,
errors.catchAsync(async (req, res, next) => {
// 检查token是否为只读
if (res.locals.appInstall?.isReadOnly) {
@ -320,6 +337,7 @@ router.post(
*/
router.post(
"/:key",
tokenWriteLimiter,
errors.catchAsync(async (req, res, next) => {
// 检查token是否为只读
if (res.locals.appInstall?.isReadOnly) {
@ -370,6 +388,7 @@ router.post(
*/
router.delete(
"/:key",
tokenDeleteLimiter,
errors.catchAsync(async (req, res, next) => {
// 检查token是否为只读
if (res.locals.appInstall?.isReadOnly) {

View File

@ -1,4 +1,12 @@
import jwt from 'jsonwebtoken';
import {
generateAccessToken,
verifyAccessToken,
generateTokenPair,
refreshAccessToken,
revokeAllTokens,
revokeRefreshToken,
} from './tokenManager.js';
// JWT 配置(支持 HS256 与 RS256
const JWT_ALG = (process.env.JWT_ALG || 'HS256').toUpperCase();
@ -23,7 +31,8 @@ function getSignVerifyKeys() {
}
/**
* 签发JWT token
* 签发JWT token向后兼容
* @deprecated 建议使用 generateAccessToken
*/
export function signToken(payload) {
const { signKey } = getSignVerifyKeys();
@ -34,7 +43,8 @@ export function signToken(payload) {
}
/**
* 验证JWT token
* 验证JWT token向后兼容
* @deprecated 建议使用 verifyAccessToken
*/
export function verifyToken(token) {
const { verifyKey } = getSignVerifyKeys();
@ -42,7 +52,8 @@ export function verifyToken(token) {
}
/**
* 为账户生成JWT token
* 为账户生成JWT token向后兼容
* @deprecated 建议使用 generateTokenPair 获取完整的令牌对
*/
export function generateAccountToken(account) {
return signToken({
@ -52,4 +63,14 @@ export function generateAccountToken(account) {
name: account.name,
avatarUrl: account.avatarUrl,
});
}
}
// 重新导出新的token管理功能
export {
generateAccessToken,
verifyAccessToken,
generateTokenPair,
refreshAccessToken,
revokeAllTokens,
revokeRefreshToken,
};

293
utils/tokenManager.js Normal file
View File

@ -0,0 +1,293 @@
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;