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:
parent
9f051885c2
commit
2ab90ffebc
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
|
||||||
|
**兼容性**: 向后兼容,支持渐进式迁移
|
||||||
27
app.js
27
app.js
@ -8,12 +8,6 @@ import logger from "morgan";
|
|||||||
import bodyParser from "body-parser";
|
import bodyParser from "body-parser";
|
||||||
import errorHandler from "./middleware/errorHandler.js";
|
import errorHandler from "./middleware/errorHandler.js";
|
||||||
import errors from "./utils/errors.js";
|
import errors from "./utils/errors.js";
|
||||||
import {
|
|
||||||
globalLimiter,
|
|
||||||
apiLimiter,
|
|
||||||
methodBasedRateLimiter,
|
|
||||||
tokenBasedRateLimiter,
|
|
||||||
} from "./middleware/rateLimiter.js";
|
|
||||||
|
|
||||||
import kvRouter from "./routes/kv-token.js";
|
import kvRouter from "./routes/kv-token.js";
|
||||||
import appsRouter from "./routes/apps.js";
|
import appsRouter from "./routes/apps.js";
|
||||||
@ -38,9 +32,6 @@ app.disable("x-powered-by");
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
// 应用全局限速
|
|
||||||
app.use(globalLimiter);
|
|
||||||
|
|
||||||
// view engine setup
|
// view engine setup
|
||||||
app.set("views", join(__dirname, "views"));
|
app.set("views", join(__dirname, "views"));
|
||||||
app.set("view engine", "ejs");
|
app.set("view engine", "ejs");
|
||||||
@ -77,31 +68,31 @@ app.use((req, res, next) => {
|
|||||||
app.get("/", (req, res) => {
|
app.get("/", (req, res) => {
|
||||||
res.render("index.ejs");
|
res.render("index.ejs");
|
||||||
});
|
});
|
||||||
app.get("/check", apiLimiter, (req, res) => {
|
app.get("/check", (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: "success",
|
status: "success",
|
||||||
message: "API is running",
|
message: "Classworks KV is running",
|
||||||
time: new Date().getTime(),
|
time: new Date().getTime(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mount the Apps router with API rate limiting
|
// 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
|
// 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
|
// 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 (更宽松的限速)
|
// Mount the KV store router
|
||||||
app.use("/kv", tokenBasedRateLimiter, kvRouter);
|
app.use("/kv", kvRouter);
|
||||||
|
|
||||||
// Mount the Device Authorization router with API rate limiting
|
// 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
|
// Mount the Accounts router with API rate limiting
|
||||||
app.use("/accounts", apiLimiter, accountsRouter);
|
app.use("/accounts", accountsRouter);
|
||||||
|
|
||||||
// 兜底404路由 - 处理所有未匹配的路由
|
// 兜底404路由 - 处理所有未匹配的路由
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* 纯账户JWT认证中间件
|
* 纯账户JWT认证中间件
|
||||||
*
|
*
|
||||||
* 只验证账户JWT是否正确,不需要设备上下文
|
* 支持新的refresh token系统,验证access token
|
||||||
|
* 如果access token即将过期,会在响应头中提供新的token
|
||||||
* 适用于只需要账户验证的接口
|
* 适用于只需要账户验证的接口
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { verifyAccessToken, validateAccountToken, generateAccessToken } from "../utils/tokenManager.js";
|
||||||
import { verifyToken } from "../utils/jwt.js";
|
import { verifyToken } from "../utils/jwt.js";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import errors from "../utils/errors.js";
|
import errors from "../utils/errors.js";
|
||||||
@ -12,8 +14,7 @@ import errors from "../utils/errors.js";
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 纯JWT认证中间件
|
* 新的JWT认证中间件(支持refresh token系统)
|
||||||
* 只验证Bearer token并将账户信息存储到res.locals
|
|
||||||
*/
|
*/
|
||||||
export const jwtAuth = async (req, res, next) => {
|
export const jwtAuth = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@ -25,30 +26,79 @@ export const jwtAuth = async (req, res, next) => {
|
|||||||
|
|
||||||
const token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
// 验证JWT token
|
try {
|
||||||
const decoded = verifyToken(token);
|
// 尝试使用新的token验证系统
|
||||||
|
const decoded = verifyAccessToken(token);
|
||||||
|
|
||||||
// 从数据库获取账户信息
|
// 验证账户并检查token版本
|
||||||
const account = await prisma.account.findUnique({
|
const account = await validateAccountToken(decoded);
|
||||||
where: { id: decoded.accountId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!account) {
|
// 将账户信息存储到res.locals
|
||||||
return next(errors.createError(401, "账户不存在"));
|
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) {
|
} 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, "认证过程出错"));
|
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);
|
||||||
};
|
};
|
||||||
@ -30,53 +30,32 @@ export const getRateLimitKey = (req) => {
|
|||||||
return `ip:${getClientIp(req)}`;
|
return `ip:${getClientIp(req)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 配置全局限速中间件
|
// 纯基于Token的keyGenerator,用于KV Token专用路由
|
||||||
export const globalLimiter = rateLimit({
|
// 这个函数假设token已经通过中间件设置在req对象上
|
||||||
windowMs: 15 * 60 * 1000, // 15分钟
|
export const getTokenOnlyKey = (req) => {
|
||||||
limit: 200, // 每个IP在windowMs时间内最多允许200个请求
|
// 尝试从多个位置获取token
|
||||||
standardHeaders: "draft-7", // 返回标准的RateLimit头信息
|
const token =
|
||||||
legacyHeaders: false, // 禁用X-RateLimit-*头
|
req.locals?.token || // 如果token被设置在req.locals中
|
||||||
message: "请求过于频繁,请稍后再试",
|
req.res?.locals?.token || // 如果token在res.locals中
|
||||||
keyGenerator: getClientIp, // 使用真实IP作为限速键
|
extractToken(req); // 从headers/query/body提取
|
||||||
skipSuccessfulRequests: false, // 成功的请求也计入限制
|
|
||||||
skipFailedRequests: false, // 失败的请求也计入限制
|
|
||||||
});
|
|
||||||
|
|
||||||
// API限速器
|
if (!token) {
|
||||||
export const apiLimiter = rateLimit({
|
// 如果没有token,返回一个特殊键用于统一限制
|
||||||
windowMs: 1 * 60 * 1000, // 1分钟
|
return "no-token";
|
||||||
limit: 100, // 每个IP在windowMs时间内最多允许100个请求
|
}
|
||||||
standardHeaders: "draft-7",
|
return `token:${token}`;
|
||||||
legacyHeaders: false,
|
};
|
||||||
message: "API请求过于频繁,请稍后再试",
|
|
||||||
keyGenerator: getClientIp,
|
// 创建一个中间件来将res.locals.token复制到req.locals.token,以便限速器使用
|
||||||
skipSuccessfulRequests: false,
|
export const prepareTokenForRateLimit = (req, res, next) => {
|
||||||
skipFailedRequests: false,
|
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({
|
export const authLimiter = rateLimit({
|
||||||
@ -90,19 +69,7 @@ export const authLimiter = rateLimit({
|
|||||||
skipFailedRequests: false, // 失败的认证计入限制
|
skipFailedRequests: false, // 失败的认证计入限制
|
||||||
});
|
});
|
||||||
|
|
||||||
// 批量操作限速器(比写操作更严格)
|
// === Token 专用限速器(更宽松的限制,纯基于Token) ===
|
||||||
export const batchLimiter = rateLimit({
|
|
||||||
windowMs: 1 * 60 * 1000, // 5分钟
|
|
||||||
limit: 10, // 每个IP在windowMs时间内最多允许10个批量操作
|
|
||||||
standardHeaders: "draft-7",
|
|
||||||
legacyHeaders: false,
|
|
||||||
message: "批量操作请求过于频繁,请稍后再试",
|
|
||||||
keyGenerator: getClientIp,
|
|
||||||
skipSuccessfulRequests: false,
|
|
||||||
skipFailedRequests: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// === Token 专用限速器(更宽松的限制) ===
|
|
||||||
|
|
||||||
// Token 读操作限速器
|
// Token 读操作限速器
|
||||||
export const tokenReadLimiter = rateLimit({
|
export const tokenReadLimiter = rateLimit({
|
||||||
@ -111,7 +78,7 @@ export const tokenReadLimiter = rateLimit({
|
|||||||
standardHeaders: "draft-7",
|
standardHeaders: "draft-7",
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
message: "读操作请求过于频繁,请稍后再试",
|
message: "读操作请求过于频繁,请稍后再试",
|
||||||
keyGenerator: getRateLimitKey,
|
keyGenerator: getTokenOnlyKey,
|
||||||
skipSuccessfulRequests: false,
|
skipSuccessfulRequests: false,
|
||||||
skipFailedRequests: false,
|
skipFailedRequests: false,
|
||||||
});
|
});
|
||||||
@ -123,7 +90,7 @@ export const tokenWriteLimiter = rateLimit({
|
|||||||
standardHeaders: "draft-7",
|
standardHeaders: "draft-7",
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
message: "写操作请求过于频繁,请稍后再试",
|
message: "写操作请求过于频繁,请稍后再试",
|
||||||
keyGenerator: getRateLimitKey,
|
keyGenerator: getTokenOnlyKey,
|
||||||
skipSuccessfulRequests: false,
|
skipSuccessfulRequests: false,
|
||||||
skipFailedRequests: false,
|
skipFailedRequests: false,
|
||||||
});
|
});
|
||||||
@ -135,7 +102,7 @@ export const tokenDeleteLimiter = rateLimit({
|
|||||||
standardHeaders: "draft-7",
|
standardHeaders: "draft-7",
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
message: "删除操作请求过于频繁,请稍后再试",
|
message: "删除操作请求过于频繁,请稍后再试",
|
||||||
keyGenerator: getRateLimitKey,
|
keyGenerator: getTokenOnlyKey,
|
||||||
skipSuccessfulRequests: false,
|
skipSuccessfulRequests: false,
|
||||||
skipFailedRequests: false,
|
skipFailedRequests: false,
|
||||||
});
|
});
|
||||||
@ -147,53 +114,7 @@ export const tokenBatchLimiter = rateLimit({
|
|||||||
standardHeaders: "draft-7",
|
standardHeaders: "draft-7",
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
message: "批量操作请求过于频繁,请稍后再试",
|
message: "批量操作请求过于频繁,请稍后再试",
|
||||||
keyGenerator: getRateLimitKey,
|
keyGenerator: getTokenOnlyKey,
|
||||||
skipSuccessfulRequests: false,
|
skipSuccessfulRequests: false,
|
||||||
skipFailedRequests: 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);
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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? // 用户名称
|
name String? // 用户名称
|
||||||
avatarUrl String? // 用户头像URL
|
avatarUrl String? // 用户头像URL
|
||||||
providerData Json? // OAuth提供者返回的完整信息
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,9 @@ import { Router } from "express";
|
|||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { oauthProviders, getCallbackURL, generateState } from "../config/oauth.js";
|
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 { jwtAuth } from "../middleware/jwt-auth.js";
|
||||||
|
import errors from "../utils/errors.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
@ -331,13 +332,15 @@ router.get("/oauth/:provider/callback", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 生成JWT token
|
// 5. 生成令牌对(访问令牌 + 刷新令牌)
|
||||||
const jwtToken = generateAccountToken(account);
|
const tokens = await generateTokenPair(account);
|
||||||
|
|
||||||
// 6. 重定向到前端根路径,携带JWT token
|
// 6. 重定向到前端根路径,携带JWT token
|
||||||
const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||||
const callbackUrl = new URL(frontendBaseUrl);
|
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);
|
callbackUrl.searchParams.append("provider", provider);
|
||||||
// 附带展示信息,便于前端显示品牌与名称
|
// 附带展示信息,便于前端显示品牌与名称
|
||||||
const pconf = oauthProviders[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;
|
export default router;
|
||||||
@ -3,6 +3,13 @@ const router = Router();
|
|||||||
import kvStore from "../utils/kvStore.js";
|
import kvStore from "../utils/kvStore.js";
|
||||||
import { broadcastKeyChanged } from "../utils/socket.js";
|
import { broadcastKeyChanged } from "../utils/socket.js";
|
||||||
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
|
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
|
||||||
|
import {
|
||||||
|
tokenReadLimiter,
|
||||||
|
tokenWriteLimiter,
|
||||||
|
tokenDeleteLimiter,
|
||||||
|
tokenBatchLimiter,
|
||||||
|
prepareTokenForRateLimit
|
||||||
|
} from "../middleware/rateLimiter.js";
|
||||||
import errors from "../utils/errors.js";
|
import errors from "../utils/errors.js";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
@ -11,12 +18,16 @@ const prisma = new PrismaClient();
|
|||||||
// 使用KV专用token认证
|
// 使用KV专用token认证
|
||||||
router.use(kvTokenAuth);
|
router.use(kvTokenAuth);
|
||||||
|
|
||||||
|
// 准备token用于限速器
|
||||||
|
router.use(prepareTokenForRateLimit);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /_info
|
* GET /_info
|
||||||
* 获取当前token所属设备的信息,如果关联了账号也返回账号信息
|
* 获取当前token所属设备的信息,如果关联了账号也返回账号信息
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/_info",
|
"/_info",
|
||||||
|
tokenReadLimiter,
|
||||||
errors.catchAsync(async (req, res) => {
|
errors.catchAsync(async (req, res) => {
|
||||||
const deviceId = res.locals.deviceId;
|
const deviceId = res.locals.deviceId;
|
||||||
|
|
||||||
@ -62,6 +73,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/_token",
|
"/_token",
|
||||||
|
tokenReadLimiter,
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const token = res.locals.token;
|
const token = res.locals.token;
|
||||||
const deviceId = res.locals.deviceId;
|
const deviceId = res.locals.deviceId;
|
||||||
@ -110,6 +122,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/_keys",
|
"/_keys",
|
||||||
|
tokenReadLimiter,
|
||||||
errors.catchAsync(async (req, res) => {
|
errors.catchAsync(async (req, res) => {
|
||||||
const deviceId = res.locals.deviceId;
|
const deviceId = res.locals.deviceId;
|
||||||
const { sortBy, sortDir, limit, skip } = req.query;
|
const { sortBy, sortDir, limit, skip } = req.query;
|
||||||
@ -160,6 +173,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/",
|
"/",
|
||||||
|
tokenReadLimiter,
|
||||||
errors.catchAsync(async (req, res) => {
|
errors.catchAsync(async (req, res) => {
|
||||||
const deviceId = res.locals.deviceId;
|
const deviceId = res.locals.deviceId;
|
||||||
const { sortBy, sortDir, limit, skip } = req.query;
|
const { sortBy, sortDir, limit, skip } = req.query;
|
||||||
@ -205,6 +219,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/:key",
|
"/:key",
|
||||||
|
tokenReadLimiter,
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const deviceId = res.locals.deviceId;
|
const deviceId = res.locals.deviceId;
|
||||||
const { key } = req.params;
|
const { key } = req.params;
|
||||||
@ -227,6 +242,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/:key/metadata",
|
"/:key/metadata",
|
||||||
|
tokenReadLimiter,
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const deviceId = res.locals.deviceId;
|
const deviceId = res.locals.deviceId;
|
||||||
const { key } = req.params;
|
const { key } = req.params;
|
||||||
@ -247,6 +263,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/_batchimport",
|
"/_batchimport",
|
||||||
|
tokenBatchLimiter,
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
// 检查token是否为只读
|
// 检查token是否为只读
|
||||||
if (res.locals.appInstall?.isReadOnly) {
|
if (res.locals.appInstall?.isReadOnly) {
|
||||||
@ -320,6 +337,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/:key",
|
"/:key",
|
||||||
|
tokenWriteLimiter,
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
// 检查token是否为只读
|
// 检查token是否为只读
|
||||||
if (res.locals.appInstall?.isReadOnly) {
|
if (res.locals.appInstall?.isReadOnly) {
|
||||||
@ -370,6 +388,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
"/:key",
|
"/:key",
|
||||||
|
tokenDeleteLimiter,
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
// 检查token是否为只读
|
// 检查token是否为只读
|
||||||
if (res.locals.appInstall?.isReadOnly) {
|
if (res.locals.appInstall?.isReadOnly) {
|
||||||
|
|||||||
29
utils/jwt.js
29
utils/jwt.js
@ -1,4 +1,12 @@
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import {
|
||||||
|
generateAccessToken,
|
||||||
|
verifyAccessToken,
|
||||||
|
generateTokenPair,
|
||||||
|
refreshAccessToken,
|
||||||
|
revokeAllTokens,
|
||||||
|
revokeRefreshToken,
|
||||||
|
} from './tokenManager.js';
|
||||||
|
|
||||||
// JWT 配置(支持 HS256 与 RS256)
|
// JWT 配置(支持 HS256 与 RS256)
|
||||||
const JWT_ALG = (process.env.JWT_ALG || 'HS256').toUpperCase();
|
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) {
|
export function signToken(payload) {
|
||||||
const { signKey } = getSignVerifyKeys();
|
const { signKey } = getSignVerifyKeys();
|
||||||
@ -34,7 +43,8 @@ export function signToken(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证JWT token
|
* 验证JWT token(向后兼容)
|
||||||
|
* @deprecated 建议使用 verifyAccessToken
|
||||||
*/
|
*/
|
||||||
export function verifyToken(token) {
|
export function verifyToken(token) {
|
||||||
const { verifyKey } = getSignVerifyKeys();
|
const { verifyKey } = getSignVerifyKeys();
|
||||||
@ -42,7 +52,8 @@ export function verifyToken(token) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 为账户生成JWT token
|
* 为账户生成JWT token(向后兼容)
|
||||||
|
* @deprecated 建议使用 generateTokenPair 获取完整的令牌对
|
||||||
*/
|
*/
|
||||||
export function generateAccountToken(account) {
|
export function generateAccountToken(account) {
|
||||||
return signToken({
|
return signToken({
|
||||||
@ -52,4 +63,14 @@ export function generateAccountToken(account) {
|
|||||||
name: account.name,
|
name: account.name,
|
||||||
avatarUrl: account.avatarUrl,
|
avatarUrl: account.avatarUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重新导出新的token管理功能
|
||||||
|
export {
|
||||||
|
generateAccessToken,
|
||||||
|
verifyAccessToken,
|
||||||
|
generateTokenPair,
|
||||||
|
refreshAccessToken,
|
||||||
|
revokeAllTokens,
|
||||||
|
revokeRefreshToken,
|
||||||
|
};
|
||||||
293
utils/tokenManager.js
Normal file
293
utils/tokenManager.js
Normal 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;
|
||||||
Loading…
x
Reference in New Issue
Block a user