diff --git a/.env.oauth.example b/.env.oauth.example new file mode 100644 index 0000000..e288a0d --- /dev/null +++ b/.env.oauth.example @@ -0,0 +1,18 @@ +# OAuth配置示例 +# 复制此文件为 .env 并填入实际的值 + +# 服务基础URL(用于生成回调地址) +BASE_URL=http://localhost:3000 + +# GitHub OAuth +# 在 https://github.com/settings/developers 创建OAuth App +# Authorization callback URL: http://localhost:3000/accounts/oauth/github/callback +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret + +# ZeroCat OAuth +# 在 ZeroCat 开发者平台创建应用 +# 回调地址: http://localhost:3000/accounts/oauth/zerocat/callback +# 权限范围: user:basic user:email +ZEROCAT_CLIENT_ID=your_zerocat_client_id +ZEROCAT_CLIENT_SECRET=your_zerocat_client_secret \ No newline at end of file diff --git a/app.js b/app.js index 2217d39..4c56089 100644 --- a/app.js +++ b/app.js @@ -17,7 +17,9 @@ import { import kvRouter from "./routes/kv-token.js"; import appsRouter from "./routes/apps.js"; +import deviceRouter from "./routes/device.js"; import deviceAuthRouter from "./routes/device-auth.js"; +import accountsRouter from "./routes/accounts.js"; var app = express(); @@ -85,12 +87,18 @@ app.get("/check", apiLimiter, (req, res) => { // Mount the Apps router with API rate limiting app.use("/apps", apiLimiter, appsRouter); +// Mount the Device router with API rate limiting +app.use("/devices", apiLimiter, deviceRouter); + // Mount the KV store router with token-based rate limiting (更宽松的限速) app.use("/kv", tokenBasedRateLimiter, kvRouter); // Mount the Device Authorization router with API rate limiting app.use("/auth", apiLimiter, deviceAuthRouter); +// Mount the Accounts router with API rate limiting +app.use("/accounts", apiLimiter, accountsRouter); + // 兜底404路由 - 处理所有未匹配的路由 app.use((req, res, next) => { const notFoundError = errors.createError(404, `找不到路径: ${req.path}`); diff --git a/cli/README.md b/cli/README.md index eefc38c..fac040f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,22 +1,33 @@ # 设备授权流程 - CLI 工具 -命令行工具,用于通过设备授权流程获取访问令牌。 +命令行工具,用于通过设备授权流程获取访问令牌。支持两种授权模式: + +- **设备代码模式** (`get-token.js`) - 用户手动输入设备代码完成授权 +- **回调模式** (`get-token-callback.js`) - 通过浏览器回调自动完成授权 ## 使用方法 -### 基本使用 +### 1. 设备代码模式(推荐用于无GUI环境) ```bash node cli/get-token.js ``` -### 配置环境变量 +### 2. 回调模式(推荐用于桌面环境) + +```bash +node cli/get-token-callback.js +``` + +### 环境变量配置 + +两种模式都支持以下环境变量: ```bash # 设置API服务器地址(默认: http://localhost:3030) export API_BASE_URL=https://your-api-server.com -# 设置授权页面地址(默认: https://classworks.xiaomo.tech/authorize) +# 设置授权页面地址(默认: http://localhost:5173/authorize) export AUTH_PAGE_URL=https://your-classworks-frontend.com/authorize # 设置应用ID(默认: 1) @@ -25,8 +36,13 @@ export APP_ID=1 # 设置站点密钥(如果需要) export SITE_KEY=your-site-key +# 回调模式特有配置 +export CALLBACK_PORT=8080 # 回调服务器端口(默认: 8080) +export TIMEOUT=300 # 授权超时时间(默认: 300秒) + # 运行工具 -node cli/get-token.js +node cli/get-token.js # 设备代码模式 +node cli/get-token-callback.js # 回调模式 ``` ### 使其可执行(Linux/Mac) @@ -38,15 +54,28 @@ chmod +x cli/get-token.js ## 工作流程 +### 设备代码模式 (`get-token.js`) + 1. **生成设备代码** - 工具会自动调用 API 生成形如 `1234-ABCD` 的授权码 2. **显示授权链接** - 在终端显示完整的授权URL,包含设备代码 3. **等待授权** - 用户点击链接或在授权页面手动输入设备代码完成授权 4. **获取令牌** - 工具自动轮询并获取令牌 5. **保存令牌** - 令牌会保存到 `~/.classworks/token.txt` +### 回调模式 (`get-token-callback.js`) + +1. **获取设备UUID** - 自动获取或生成设备UUID +2. **启动回调服务器** - 在本地启动HTTP服务器监听回调 +3. **打开授权页面** - 自动在浏览器中打开授权页面 +4. **用户授权** - 用户在浏览器中完成授权操作 +5. **接收回调** - 本地服务器接收授权回调并获取令牌 +6. **保存令牌** - 令牌会保存到 `~/.classworks/token-callback.txt` + ## 输出示例 -``` +### 设备代码模式输出 + +```text 设备授权流程 - 令牌获取工具 ✓ 设备授权码生成成功! @@ -80,19 +109,83 @@ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... curl -H "Authorization: Bearer eyJhbGc..." http://localhost:3030/kv ``` +### 回调模式输出 + +```text +回调授权流程 - 令牌获取工具 + +ℹ 正在获取设备UUID... +✓ 设备UUID: 1234567890abcdef1234567890abcdef + +============================================================ + 请访问以下地址完成授权: + + http://localhost:5173/authorize?app_id=1&mode=callback&callback_url=http://localhost:8080/callback&state=abc123 + + 设备UUID: 1234567890abcdef1234567890abcdef + 状态参数: abc123 +============================================================ +ℹ 回调地址: http://localhost:8080/callback +ℹ API服务器: http://localhost:3030 +ℹ 超时时间: 300 秒 + +ℹ 正在启动回调服务器... +✓ 回调服务器已启动: http://localhost:8080/callback +ℹ 正在尝试打开浏览器... +✓ 已尝试打开浏览器 +ℹ 等待授权完成... + +================================================== +✓ 授权成功!令牌获取完成 +================================================== + +您的访问令牌: +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + +✓ 令牌已保存到: /home/user/.classworks/token-callback.txt + +使用示例: + curl -H "Authorization: Bearer eyJhbGc..." http://localhost:3030/kv +``` + ## 配置选项 -可以通过修改 `cli/get-token.js` 中的 `CONFIG` 对象或设置环境变量来调整: +### 通用配置 -- `baseUrl` / `API_BASE_URL` - API 服务器地址(默认: http://localhost:3030) -- `authPageUrl` / `AUTH_PAGE_URL` - Classworks 授权页面地址(默认: https://classworks.xiaomo.tech/authorize) +可以通过修改相应文件中的 `CONFIG` 对象或设置环境变量来调整: + +- `baseUrl` / `API_BASE_URL` - API 服务器地址(默认: `http://localhost:3030`) +- `authPageUrl` / `AUTH_PAGE_URL` - Classworks 授权页面地址(默认: `http://localhost:5173/authorize`) - `appId` / `APP_ID` - 应用ID(默认: 1) - `siteKey` / `SITE_KEY` - 站点密钥(如果需要) + +### 设备代码模式专用配置 + - `pollInterval` - 轮询间隔(秒,默认3秒) - `maxPolls` - 最大轮询次数(默认100次) +### 回调模式专用配置 + +- `callbackPort` / `CALLBACK_PORT` - 回调服务器端口(默认: 8080) +- `timeout` / `TIMEOUT` - 授权超时时间(秒,默认: 300) +- `callbackPath` - 回调路径(默认: /callback) + ## 错误处理 +### 设备代码模式 + - 如果设备代码过期,会显示错误并退出 - 如果轮询超时(默认5分钟),会显示超时错误 - 如果无法连接到服务器,会显示连接错误 + +### 回调模式 + +- 如果回调端口被占用,会提示更换端口 +- 如果授权超时,会显示超时错误并提示延长超时时间 +- 如果状态参数不匹配,会拒绝授权防止CSRF攻击 +- 如果无法连接到服务器,会显示连接错误 + +## 选择模式建议 + +- **设备代码模式** - 适用于无GUI环境、服务器环境、或无法启动本地服务器的场景 +- **回调模式** - 适用于桌面环境、开发环境、或希望更流畅授权体验的场景 diff --git a/cli/get-token-callback.js b/cli/get-token-callback.js new file mode 100644 index 0000000..11be220 --- /dev/null +++ b/cli/get-token-callback.js @@ -0,0 +1,422 @@ +#!/usr/bin/env node + +/** + * 回调授权流程 - 命令行工具 + * + * 用于演示回调授权流程,获取访问令牌 + * 通过启动本地HTTP服务器接收回调来获取令牌 + * + * 使用方法: + * node cli/get-token-callback.js + * 或配置为可执行:chmod +x cli/get-token-callback.js && ./cli/get-token-callback.js + */ + +import http from 'http'; +import url from 'url'; +import { randomBytes } from 'crypto'; + +// 配置 +const CONFIG = { + // API服务器地址 + baseUrl: process.env.API_BASE_URL || 'http://localhost:3030', + // 站点密钥 + siteKey: process.env.SITE_KEY || '', + // 应用ID + appId: process.env.APP_ID || '1', + // 授权页面地址(Classworks前端) + authPageUrl: process.env.AUTH_PAGE_URL || 'http://localhost:5173/authorize', + // 本地回调服务器端口 + callbackPort: process.env.CALLBACK_PORT || '8080', + // 回调路径 + callbackPath: '/callback', + // 超时时间(秒) + timeout: 300, +}; + +// 颜色输出 +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', +}; + +function log(message, color = '') { + console.log(`${color}${message}${colors.reset}`); +} + +function logSuccess(message) { + log(`✓ ${message}`, colors.green); +} + +function logError(message) { + log(`✗ ${message}`, colors.red); +} + +function logInfo(message) { + log(`ℹ ${message}`, colors.cyan); +} + +function logWarning(message) { + log(`⚠ ${message}`, colors.yellow); +} + +// HTTP请求封装 +async function request(path, options = {}) { + const requestUrl = `${CONFIG.baseUrl}${path}`; + const headers = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + if (CONFIG.siteKey) { + headers['X-Site-Key'] = CONFIG.siteKey; + } + + try { + const response = await fetch(requestUrl, { + ...options, + headers, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || `HTTP ${response.status}`); + } + + return data; + } catch (error) { + if (error.message.includes('fetch')) { + throw new Error(`无法连接到服务器: ${CONFIG.baseUrl}`); + } + throw error; + } +} + +// 生成随机状态字符串 +function generateState() { + return randomBytes(16).toString('hex'); +} + +// 获取设备UUID +async function getDeviceUuid() { + try { + const deviceInfo = await request('/device/info'); + return deviceInfo.uuid; + } catch (error) { + // 如果设备不存在,生成新的UUID + const uuid = randomBytes(16).toString('hex'); + logInfo(`生成新的设备UUID: ${uuid}`); + return uuid; + } +} + +// 创建回调服务器 +function createCallbackServer(state) { + return new Promise((resolve, reject) => { + let server; + let resolved = false; + + const handleRequest = (req, res) => { + if (resolved) return; + + const parsedUrl = url.parse(req.url, true); + + if (parsedUrl.pathname === CONFIG.callbackPath) { + const { token, error, state: returnedState } = parsedUrl.query; + + // 验证状态参数 + if (returnedState !== state) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(` + + + + + 授权失败 + + + +

授权失败

+

状态参数不匹配,可能存在安全风险。

+

请重新尝试授权流程。

+ + + `); + resolved = true; + server.close(); + reject(new Error('状态参数不匹配')); + return; + } + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(` + + + + + 授权失败 + + + +

授权失败

+

${error}

+

您可以关闭此页面并重新尝试。

+ + + `); + resolved = true; + server.close(); + reject(new Error(error)); + return; + } + + if (token) { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(` + + + + + 授权成功 + + + +

授权成功!

+

令牌已成功获取,您可以关闭此页面。

+
${token}
+

令牌已自动复制到命令行界面

+ + + `); + resolved = true; + server.close(); + resolve(token); + return; + } + + // 如果没有token和error参数 + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(` + + + + + 无效请求 + + + +

无效请求

+

缺少必要的参数。

+

请重新尝试授权流程。

+ + + `); + resolved = true; + server.close(); + reject(new Error('缺少必要的参数')); + } else { + // 404 for other paths + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + } + }; + + server = http.createServer(handleRequest); + + server.listen(CONFIG.callbackPort, (err) => { + if (err) { + reject(err); + } else { + logSuccess(`回调服务器已启动: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`); + } + }); + + // 设置超时 + setTimeout(() => { + if (!resolved) { + resolved = true; + server.close(); + reject(new Error('授权超时')); + } + }, CONFIG.timeout * 1000); + + server.on('error', (err) => { + if (!resolved) { + resolved = true; + reject(err); + } + }); + }); +} + +// 打开浏览器 +async function openBrowser(url) { + const { spawn } = await import('child_process'); + + let command; + let args; + + if (process.platform === 'win32') { + command = 'cmd'; + args = ['/c', 'start', url]; + } else if (process.platform === 'darwin') { + command = 'open'; + args = [url]; + } else { + command = 'xdg-open'; + args = [url]; + } + + try { + spawn(command, args, { detached: true, stdio: 'ignore' }); + logSuccess('已尝试打开浏览器'); + } catch (error) { + logWarning('无法自动打开浏览器,请手动打开授权链接'); + } +} + +// 显示授权信息 +function displayAuthInfo(authUrl, deviceUuid, state) { + console.log('\n' + '='.repeat(60)); + log(` 请访问以下地址完成授权:`, colors.bright); + console.log(''); + log(` ${authUrl}`, colors.cyan + colors.bright); + console.log(''); + log(` 设备UUID: ${deviceUuid}`, colors.green); + log(` 状态参数: ${state}`, colors.dim); + console.log('='.repeat(60)); + logInfo(`回调地址: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`); + logInfo(`API服务器: ${CONFIG.baseUrl}`); + logInfo(`超时时间: ${CONFIG.timeout} 秒`); + console.log(''); +} + +// 保存令牌到文件 +async function saveToken(token) { + const fs = await import('fs'); + const path = await import('path'); + const os = await import('os'); + + const tokenDir = path.join(os.homedir(), '.classworks'); + const tokenFile = path.join(tokenDir, 'token-callback.txt'); + + try { + // 确保目录存在 + if (!fs.existsSync(tokenDir)) { + fs.mkdirSync(tokenDir, { recursive: true }); + } + + // 写入令牌 + fs.writeFileSync(tokenFile, token, 'utf8'); + logSuccess(`令牌已保存到: ${tokenFile}`); + } catch (error) { + logWarning(`无法保存令牌到文件: ${error.message}`); + logInfo('您可以手动保存令牌'); + } +} + +// 主函数 +async function main() { + console.log('\n' + colors.cyan + colors.bright + '回调授权流程 - 令牌获取工具' + colors.reset + '\n'); + + try { + // 检查配置 + if (!CONFIG.siteKey) { + logWarning('未设置 SITE_KEY 环境变量,可能需要站点密钥才能访问'); + logInfo('设置方法: export SITE_KEY=your-site-key'); + console.log(''); + } + + // 1. 获取设备UUID + logInfo('正在获取设备UUID...'); + const deviceUuid = await getDeviceUuid(); + logSuccess(`设备UUID: ${deviceUuid}`); + + // 2. 生成状态参数 + const state = generateState(); + + // 3. 构建回调URL + const callbackUrl = `http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`; + + // 4. 构建授权URL + const authUrl = new URL(CONFIG.authPageUrl); + authUrl.searchParams.set('app_id', CONFIG.appId); + authUrl.searchParams.set('mode', 'callback'); + authUrl.searchParams.set('callback_url', callbackUrl); + authUrl.searchParams.set('state', state); + + // 5. 显示授权信息 + displayAuthInfo(authUrl.toString(), deviceUuid, state); + + // 6. 启动回调服务器 + logInfo('正在启动回调服务器...'); + const serverPromise = createCallbackServer(state); + + // 7. 打开浏览器 + logInfo('正在尝试打开浏览器...'); + await openBrowser(authUrl.toString()); + + // 8. 等待授权完成 + logInfo('等待授权完成...\n'); + const token = await serverPromise; + + // 9. 显示令牌 + console.log('\n' + '='.repeat(50)); + logSuccess('授权成功!令牌获取完成'); + console.log('='.repeat(50)); + console.log('\n' + colors.bright + '您的访问令牌:' + colors.reset); + log(token, colors.green); + console.log(''); + + // 10. 保存令牌 + await saveToken(token); + + // 11. 使用示例 + console.log('\n' + colors.bright + '使用示例:' + colors.reset); + console.log(` curl -H "Authorization: Bearer ${token}" ${CONFIG.baseUrl}/kv`); + console.log(''); + + process.exit(0); + } catch (error) { + console.log(''); + logError(`错误: ${error.message}`); + + // 提供一些常见问题的解决方案 + if (error.message.includes('EADDRINUSE')) { + logInfo(`端口 ${CONFIG.callbackPort} 已被占用,请尝试设置不同的端口:`); + logInfo(`CALLBACK_PORT=8081 node cli/get-token-callback.js`); + } else if (error.message.includes('无法连接到服务器')) { + logInfo('请检查API服务器是否正在运行'); + logInfo(`当前API地址: ${CONFIG.baseUrl}`); + } else if (error.message.includes('授权超时')) { + logInfo(`授权超时(${CONFIG.timeout}秒),请重新尝试`); + logInfo('您可以设置更长的超时时间:TIMEOUT=600 node cli/get-token-callback.js'); + } + + console.log(''); + process.exit(1); + } +} + +// 运行 +main(); \ No newline at end of file diff --git a/config/oauth.js b/config/oauth.js new file mode 100644 index 0000000..4a3529f --- /dev/null +++ b/config/oauth.js @@ -0,0 +1,39 @@ +// OAuth 提供者配置 +export const oauthProviders = { + github: { + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + authorizationURL: "https://github.com/login/oauth/authorize", + tokenURL: "https://github.com/login/oauth/access_token", + userInfoURL: "https://api.github.com/user", + scope: "read:user user:email", + name: "GitHub", + icon: "github", + color: "#24292e", + description: "使用 GitHub 账号登录", + }, + zerocat: { + clientId: process.env.ZEROCAT_CLIENT_ID, + clientSecret: process.env.ZEROCAT_CLIENT_SECRET, + authorizationURL: "https://zerocat-api.houlangs.com/oauth/authorize", + tokenURL: "https://zerocat-api.houlangs.com/oauth/token", + userInfoURL: "https://zerocat-api.houlangs.com/oauth/userinfo", + scope: "user:basic user:email", + name: "ZeroCat", + icon: "zerocat", + color: "#6366f1", + description: "使用 ZeroCat 账号登录", + }, +}; + +// 获取OAuth回调URL +export function getCallbackURL(provider) { + const baseUrl = process.env.BASE_URL; + return `${baseUrl}/accounts/oauth/${provider}/callback`; +} + +// 生成随机state参数 +export function generateState() { + return Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); +} \ No newline at end of file diff --git a/docs/API_CURL_EXAMPLES.md b/docs/API_CURL_EXAMPLES.md deleted file mode 100644 index c2c9ed8..0000000 --- a/docs/API_CURL_EXAMPLES.md +++ /dev/null @@ -1,671 +0,0 @@ -# API 使用示例 - cURL - -本文档提供所有API接口的完整cURL示例。 - -## 环境变量设置 - -```bash -# 设置基础URL和站点密钥 -export BASE_URL="http://localhost:3030" -export SITE_KEY="your-site-key-here" -``` - -## 1. 应用管理 API - -### 1.1 获取应用列表 - -```bash -# 基本查询 -curl -X GET "http://localhost:3030/apps" \ - -H "x-site-key: ${SITE_KEY}" - -# 带分页和搜索 -curl -X GET "http://localhost:3030/apps?limit=10&skip=0&search=my-app" \ - -H "x-site-key: ${SITE_KEY}" -``` - -**响应示例:** -```json -{ - "apps": [ - { - "id": 1, - "name": "我的应用", - "description": "应用描述", - "developerName": "开发者名称", - "developerLink": "https://developer.com", - "homepageLink": "https://app.com", - "iconHash": "abc123", - "metadata": {}, - "createdAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-01-01T00:00:00.000Z" - } - ], - "total": 1, - "limit": 10, - "skip": 0 -} -``` - -### 1.2 获取单个应用详情 - -```bash -curl -X GET "http://localhost:3030/apps/1" \ - -H "x-site-key: ${SITE_KEY}" -``` - -**响应示例:** -```json -{ - "id": 1, - "name": "我的应用", - "description": "应用描述", - "developerName": "开发者名称", - "developerLink": "https://developer.com", - "homepageLink": "https://app.com", - "iconHash": "abc123", - "metadata": {}, - "createdAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-01-01T00:00:00.000Z" -} -``` - -### 1.4 获取应用的所有安装记录 - -```bash -curl -X GET "http://localhost:3030/apps/1/installations?limit=10&skip=0" \ - -H "x-site-key: ${SITE_KEY}" -``` - -**响应示例:** -```json -{ - "appId": 1, - "installations": [ - { - "id": "clx1234567890", - "token": "a1b2c3d4e5f6...", - "device": { - "uuid": "550e8400-e29b-41d4-a716-446655440000", - "name": "我的设备" - }, - "note": "完整访问", - "installedAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-01-01T00:00:00.000Z" - } - ], - "total": 1, - "limit": 10, - "skip": 0 -} -``` - -## 2. Token 管理 API - -### 2.1 获取设备的所有Token - -```bash -curl -X GET "http://localhost:3030/apps/devices/550e8400-e29b-41d4-a716-446655440000/tokens" \ - -H "x-site-key: ${SITE_KEY}" -``` - -**响应示例:** -```json -{ - "deviceUuid": "550e8400-e29b-41d4-a716-446655440000", - "deviceName": "我的设备", - "tokens": [ - { - "id": "clx1234567890", - "token": "a1b2c3d4e5f6...", - "app": { - "id": 1, - "name": "我的应用", - "description": "应用描述", - "developerName": "开发者", - "iconHash": "abc123" - }, - "note": "完整访问", - "installedAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-01-01T00:00:00.000Z" - } - ], - "total": 1 -} -``` - -### 2.2 撤销Token - -```bash -curl -X DELETE "http://localhost:3030/apps/tokens/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" \ - -H "x-site-key: ${SITE_KEY}" -``` - -**成功响应:** HTTP 204 No Content - -**错误响应:** -```json -{ - "statusCode": 404, - "message": "Token不存在" -} -``` - -## 3. KV 操作 API(需要Token) - -**设置Token环境变量:** -```bash -export TOKEN="a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6" -``` - -### 3.1 获取所有键(含元数据) - -```bash -# 基本查询 -curl -X GET "http://localhost:3030/kv" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}" - -# 带分页和排序 -curl -X GET "http://localhost:3030/kv?sortBy=key&sortDir=asc&limit=50&skip=0" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}" - -# 按更新时间排序 -curl -X GET "http://localhost:3030/kv?sortBy=updatedAt&sortDir=desc&limit=20" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}" -``` - -**响应示例:** -```json -{ - "items": [ - { - "deviceId": 1, - "key": "user.profile", - "metadata": { - "creatorIp": "192.168.1.1", - "createdAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-01-01T00:00:00.000Z" - } - } - ], - "total_rows": 1, - "load_more": "/kv?sortBy=key&sortDir=asc&limit=50&skip=50" -} -``` - -### 3.2 获取键名列表(仅键名) - -```bash -curl -X GET "http://localhost:3030/kv/_keys" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}" - -# 带分页 -curl -X GET "http://localhost:3030/kv/_keys?limit=100&skip=0" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}" -``` - -**响应示例:** -```json -{ - "keys": [ - "user.profile", - "user.settings", - "app.config" - ], - "total_rows": 3, - "current_page": { - "limit": 100, - "skip": 0, - "count": 3 - } -} -``` - -### 3.3 获取键值 - -```bash -# 使用 Authorization header(推荐) -curl -X GET "http://localhost:3030/kv/user.profile" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}" - -# 使用 query 参数 -curl -X GET "http://localhost:3030/kv/user.profile?token=${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}" -``` - -**响应示例:** -```json -{ - "name": "张三", - "email": "zhangsan@example.com", - "avatar": "https://example.com/avatar.jpg", - "preferences": { - "theme": "dark", - "language": "zh-CN" - } -} -``` - -**错误响应(键不存在):** -```json -{ - "statusCode": 404, - "message": "未找到键名为 'user.profile' 的记录" -} -``` - -### 3.4 获取键的元数据 - -```bash -curl -X GET "http://localhost:3030/kv/user.profile/metadata" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}" -``` - -**响应示例:** -```json -{ - "deviceId": 1, - "key": "user.profile", - "metadata": { - "creatorIp": "192.168.1.1", - "createdAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-01-01T12:30:00.000Z" - } -} -``` - -### 3.5 创建/更新键值 - -```bash -# 创建新键 -curl -X POST "http://localhost:3030/kv/user.profile" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "Content-Type: application/json" \ - -H "x-site-key: ${SITE_KEY}" \ - -d '{ - "name": "张三", - "email": "zhangsan@example.com", - "avatar": "https://example.com/avatar.jpg" - }' - -# 更新已存在的键 -curl -X POST "http://localhost:3030/kv/user.profile" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "Content-Type: application/json" \ - -H "x-site-key: ${SITE_KEY}" \ - -d '{ - "name": "张三", - "email": "newemail@example.com", - "avatar": "https://example.com/new-avatar.jpg", - "updatedBy": "admin" - }' - -# 存储数组 -curl -X POST "http://localhost:3030/kv/user.tags" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "Content-Type: application/json" \ - -H "x-site-key: ${SITE_KEY}" \ - -d '["developer", "admin", "vip"]' - -# 存储嵌套对象 -curl -X POST "http://localhost:3030/kv/app.config" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "Content-Type: application/json" \ - -H "x-site-key: ${SITE_KEY}" \ - -d '{ - "database": { - "host": "localhost", - "port": 3306, - "name": "mydb" - }, - "cache": { - "enabled": true, - "ttl": 3600 - } - }' -``` - -**响应示例(创建):** -```json -{ - "deviceId": 1, - "key": "user.profile", - "created": true, - "updatedAt": "2025-01-01T00:00:00.000Z" -} -``` - -**响应示例(更新):** -```json -{ - "deviceId": 1, - "key": "user.profile", - "created": false, - "updatedAt": "2025-01-01T12:30:00.000Z" -} -``` - -### 3.6 批量导入键值对 - -```bash -curl -X POST "http://localhost:3030/kv/_batchimport" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "Content-Type: application/json" \ - -H "x-site-key: ${SITE_KEY}" \ - -d '{ - "user.profile": { - "name": "张三", - "email": "zhangsan@example.com" - }, - "user.settings": { - "theme": "dark", - "language": "zh-CN" - }, - "app.config": { - "version": "1.0.0", - "debug": false - } - }' -``` - -**响应示例:** -```json -{ - "deviceId": 1, - "total": 3, - "successful": 3, - "failed": 0, - "results": [ - { - "key": "user.profile", - "created": true - }, - { - "key": "user.settings", - "created": true - }, - { - "key": "app.config", - "created": false - } - ] -} -``` - -**部分失败响应:** -```json -{ - "deviceId": 1, - "total": 3, - "successful": 2, - "failed": 1, - "results": [ - { - "key": "user.profile", - "created": true - }, - { - "key": "user.settings", - "created": true - } - ], - "errors": [ - { - "key": "invalid.key", - "error": "验证失败" - } - ] -} -``` - -### 3.7 删除键值对 - -```bash -curl -X DELETE "http://localhost:3030/kv/user.profile" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}" -``` - -**成功响应:** HTTP 204 No Content - -**错误响应(键不存在):** -```json -{ - "statusCode": 404, - "message": "未找到键名为 'user.profile' 的记录" -} -``` - -## 4. 完整工作流示例 - -### 场景:应用首次访问设备的KV存储 - -```bash -#!/bin/bash - -# 1. 设置环境变量 -export BASE_URL="http://localhost:3030" -export SITE_KEY="your-site-key" -export APP_ID="1" -export DEVICE_UUID="550e8400-e29b-41d4-a716-446655440000" -export DEVICE_PASSWORD="my-password" - -# 2. 为应用授权获取token -echo "正在授权应用..." -RESPONSE=$(curl -s -X POST "http://localhost:3030/apps/${APP_ID}/authorize" \ - -H "Content-Type: application/json" \ - -H "x-site-key: ${SITE_KEY}" \ - -d "{ - \"deviceUuid\": \"${DEVICE_UUID}\", - \"password\": \"${DEVICE_PASSWORD}\", - \"readOnly\": false, - \"note\": \"自动授权\" - }") - -# 3. 提取token -TOKEN=$(echo $RESPONSE | jq -r '.token') -echo "获取到Token: ${TOKEN:0:20}..." - -# 4. 写入数据 -echo "写入用户配置..." -curl -X POST "http://localhost:3030/kv/user.config" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "Content-Type: application/json" \ - -H "x-site-key: ${SITE_KEY}" \ - -d '{ - "theme": "dark", - "notifications": true, - "language": "zh-CN" - }' - -# 5. 读取数据 -echo "读取用户配置..." -curl -X GET "http://localhost:3030/kv/user.config" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}" - -# 6. 获取所有键名 -echo "获取所有键名..." -curl -X GET "http://localhost:3030/kv/_keys" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}" - -# 7. 批量导入数据 -echo "批量导入数据..." -curl -X POST "http://localhost:3030/kv/_batchimport" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "Content-Type: application/json" \ - -H "x-site-key: ${SITE_KEY}" \ - -d '{ - "user.profile": {"name": "张三", "age": 25}, - "user.preferences": {"color": "blue"}, - "app.version": {"current": "1.0.0"} - }' - -echo "完成!" -``` - -## 5. 错误处理示例 - -### 5.1 Token认证失败 - -```bash -# 使用无效token -curl -X GET "http://localhost:3030/kv/mykey" \ - -H "Authorization: Bearer invalid-token" \ - -H "x-site-key: ${SITE_KEY}" -``` - -**响应:** -```json -{ - "statusCode": 401, - "message": "无效的身份验证令牌" -} -``` - -### 5.2 缺少Token - -```bash -curl -X GET "http://localhost:3030/kv/mykey" \ - -H "x-site-key: ${SITE_KEY}" -``` - -**响应:** -```json -{ - "statusCode": 401, - "message": "未提供身份验证令牌" -} -``` - -### 5.3 站点密钥错误 - -```bash -curl -X GET "http://localhost:3030/apps" \ - -H "x-site-key: wrong-key" -``` - -**响应:** -```json -{ - "statusCode": 401, - "message": "无效的站点密钥" -} -``` - -### 5.4 设备不存在 - -```bash -curl -X POST "http://localhost:3030/apps/1/authorize" \ - -H "Content-Type: application/json" \ - -H "x-site-key: ${SITE_KEY}" \ - -d '{ - "deviceUuid": "non-existent-uuid" - }' -``` - -**响应:** -```json -{ - "statusCode": 404, - "message": "设备不存在" -} -``` - -## 6. 高级用例 - -### 6.1 使用jq处理响应 - -```bash -# 提取所有键名 -curl -s -X GET "http://localhost:3030/kv/_keys" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}" \ - | jq -r '.keys[]' - -# 获取token并保存 -TOKEN=$(curl -s -X POST "http://localhost:3030/apps/1/authorize" \ - -H "Content-Type: application/json" \ - -H "x-site-key: ${SITE_KEY}" \ - -d '{"deviceUuid":"550e8400-e29b-41d4-a716-446655440000"}' \ - | jq -r '.token') - -# 格式化输出 -curl -s -X GET "http://localhost:3030/kv/user.profile" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}" \ - | jq '.' -``` - -### 6.2 循环批量操作 - -```bash -# 批量创建键值对 -for i in {1..10}; do - curl -X POST "http://localhost:3030/kv/item.${i}" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "Content-Type: application/json" \ - -H "x-site-key: ${SITE_KEY}" \ - -d "{\"id\": ${i}, \"name\": \"Item ${i}\"}" - echo "Created item.${i}" -done - -# 批量读取 -for key in user.profile user.settings app.config; do - echo "Reading ${key}:" - curl -s -X GET "http://localhost:3030/kv/${key}" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}" \ - | jq '.' -done -``` - -### 6.3 条件更新模式 - -```bash -# 读取当前值 -CURRENT=$(curl -s -X GET "http://localhost:3030/kv/counter" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}") - -# 修改值 -NEW_VALUE=$(echo $CURRENT | jq '.count += 1') - -# 写回 -curl -X POST "http://localhost:3030/kv/counter" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "Content-Type: application/json" \ - -H "x-site-key: ${SITE_KEY}" \ - -d "${NEW_VALUE}" -``` - -## 7. 性能测试 - -### 7.1 并发请求测试 - -```bash -# 使用 xargs 进行并发测试 -seq 1 10 | xargs -P 10 -I {} curl -s -X GET "http://localhost:3030/kv/test.key" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}" \ - -o /dev/null -w "Request {}: %{http_code} in %{time_total}s\n" -``` - -### 7.2 响应时间测试 - -```bash -# 测量单个请求时间 -curl -X GET "http://localhost:3030/kv/user.profile" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "x-site-key: ${SITE_KEY}" \ - -w "\nTotal time: %{time_total}s\n" \ - -o /dev/null -s -``` \ No newline at end of file diff --git a/docs/API_REFACTOR.md b/docs/API_REFACTOR.md deleted file mode 100644 index d5875fa..0000000 --- a/docs/API_REFACTOR.md +++ /dev/null @@ -1,226 +0,0 @@ -# API 重构文档 - -## 概述 - -本次重构将数据库从基于 `namespace` (UUID字符串) 的架构迁移到基于 `deviceId` (自增整数) 的架构,并实现了完整的token授权系统。 - -## 数据库变更 - -### Device 表 -- **主键变更**: `uuid` (VARCHAR) → `id` (INT AUTO_INCREMENT) -- **uuid**: 改为 UNIQUE 索引 -- **新增字段**: `accountId` (用于未来关联社区账户) - -### KVStore 表 -- **外键变更**: `namespace` (VARCHAR) → `deviceId` (INT) -- **主键**: `(deviceId, key)` 复合主键 -- **关联**: 外键关联 `Device.id`,支持级联删除 - -### 新增表 - -#### App 表 -```sql -CREATE TABLE `App` ( - `id` INT AUTO_INCREMENT PRIMARY KEY, - `name` VARCHAR(191) NOT NULL, - `description` VARCHAR(191), - `developerName` VARCHAR(191) NOT NULL, - `developerLink` VARCHAR(191), - `homepageLink` VARCHAR(191), - `iconHash` VARCHAR(191), - `metadata` JSON, - `createdAt` DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), - `updatedAt` DATETIME(3) NOT NULL -); -``` - -#### AppInstall 表 -```sql -CREATE TABLE `AppInstall` ( - `id` VARCHAR(191) PRIMARY KEY, - `deviceId` INT NOT NULL, - `appId` INT NOT NULL, - `token` VARCHAR(191) UNIQUE NOT NULL, - `note` VARCHAR(191), - `installedAt` DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), - `updatedAt` DATETIME(3) NOT NULL, - FOREIGN KEY (`deviceId`) REFERENCES `Device`(`id`) ON DELETE CASCADE, - FOREIGN KEY (`appId`) REFERENCES `App`(`id`) ON DELETE CASCADE -); -``` - -## API 架构 - -### 1. 应用授权流程 - -#### POST /apps/:appId/authorize -为应用获取访问token - -**请求体:** -```json -{ - "deviceUuid": "设备UUID", - "password": "设备密码(如需要)", - "readOnly": false, - "note": "备注信息" -} -``` - -**响应:** -```json -{ - "token": "生成的访问token", - "appId": 1, - "appName": "应用名称", - "deviceUuid": "设备UUID", - "deviceName": "设备名称", - "readOnly": false, - "note": "读写访问", - "authorizedAt": "2025-01-01T00:00:00.000Z" -} -``` - -### 2. Token-based KV 操作(唯一方式) - -⚠️ **重要变更**: 所有KV操作现在仅支持基于token的访问,旧的 `/kv/:namespace/:key` API已移除。 - -#### Token提供方式 -1. Authorization Header: `Authorization: Bearer ` -2. Query 参数: `?token=` -3. Request Body: `{"token": ""}` - -#### KV API端点 -``` -GET /kv - 列出所有键(含元数据) -GET /kv/_keys - 列出所有键名(仅键名) -GET /kv/:key - 获取键值 -GET /kv/:key/metadata - 获取键元数据 -POST /kv/:key - 创建/更新键值 -POST /kv/_batchimport - 批量导入 -DELETE /kv/:key - 删除键值 -``` - -### 3. 主要接口 - -#### 应用管理 -- `GET /apps` - 获取应用列表 -- `GET /apps/:id` - 获取应用详情 -- `POST /apps/:id/authorize` - 授权应用获取token -- `GET /apps/:id/installations` - 获取应用的所有安装记录 - -#### Token管理 -- `GET /apps/devices/:deviceUuid/tokens` - 获取设备的所有token -- `DELETE /apps/tokens/:token` - 撤销token - -#### KV操作(仅Token方式) -- `GET /kv` - 列出所有键(含元数据) -- `GET /kv/_keys` - 列出所有键名(仅键名) -- `GET /kv/:key` - 获取键值 -- `GET /kv/:key/metadata` - 获取键元数据 -- `POST /kv/:key` - 创建/更新键值 -- `POST /kv/_batchimport` - 批量导入 -- `DELETE /kv/:key` - 删除键值 - -## 使用示例 - -### 1. 授权应用 -```javascript -const response = await fetch('http://localhost:3000/apps/1/authorize', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-site-key': 'your-site-key' - }, - body: JSON.stringify({ - deviceUuid: 'your-device-uuid', - password: 'device-password-if-needed', - readOnly: false, - note: '我的应用授权' - }) -}); - -const { token } = await response.json(); -``` - -### 2. 使用Token读取KV -```javascript -const response = await fetch('http://localhost:3000/kv/mykey', { - headers: { - 'Authorization': `Bearer ${token}`, - 'x-site-key': 'your-site-key' - } -}); - -const value = await response.json(); -``` - -### 3. 使用Token写入KV -```javascript -const response = await fetch('http://localhost:3000/kv/mykey', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - 'x-site-key': 'your-site-key' - }, - body: JSON.stringify({ - data: 'my value', - timestamp: Date.now() - }) -}); -``` - -## 迁移指南 - -### 数据库迁移 - -1. 标记旧迁移为已应用: -```bash -npx prisma migrate resolve --applied 20250524123414_2025_05_25 -``` - -2. 执行新迁移: -```bash -npx prisma migrate deploy -``` - -### 代码更新 - -⚠️ **破坏性变更**: 旧的基于namespace的API已完全移除。 - -**旧代码(不再支持):** -```javascript -// ❌ 已移除 -GET /kv/:namespace/:key -POST /kv/:namespace/:key -DELETE /kv/:namespace/:key -``` - -**新代码(唯一方式):** -```javascript -// ✅ 使用token-based API -GET /kv/:key -POST /kv/:key -DELETE /kv/:key - -// 必须在header中提供token -Headers: { - 'Authorization': 'Bearer ', - 'x-site-key': 'your-site-key' -} -``` - -**迁移步骤:** -1. 为每个需要访问KV的应用调用 `POST /apps/:id/authorize` 获取token -2. 将所有KV API调用从 `/kv/:namespace/:key` 改为 `/kv/:key` -3. 在所有请求中添加 `Authorization: Bearer ` header -4. 测试确保所有功能正常 - -## 优势 - -1. **安全性提升**: Token-based认证,无需在URL中暴露namespace -2. **多设备支持**: 同一UUID可在不同设备上使用不同token -3. **细粒度权限**: 可为每个应用授权只读或读写权限 -4. **易于管理**: 可随时撤销token,不影响其他授权 -5. **性能优化**: 使用整数ID作为外键,查询效率更高 -6. **简化API**: 统一的token认证方式,无需在URL中指定namespace \ No newline at end of file diff --git a/docs/FRONTEND_MIGRATION_GUIDE.md b/docs/FRONTEND_MIGRATION_GUIDE.md deleted file mode 100644 index 0f0379f..0000000 --- a/docs/FRONTEND_MIGRATION_GUIDE.md +++ /dev/null @@ -1,600 +0,0 @@ -# 前端迁移指南 - -## 概述 - -本文档描述了后端中间件系统的重构,以及前端需要如何适配这些变化。核心变化是统一了设备信息获取和权限验证流程。 - ---- - -## 核心变化 - -### 1. 统一的设备中间件系统 - -后端现在使用统一的中间件处理所有与设备UUID相关的操作: - -- **`deviceMiddleware`**: 自动获取或创建设备,设备不存在时自动创建 -- **`requireWriteAuth`**: 验证写权限,检查设备密码 -- **`tokenAuth`**: Token认证,用于应用访问 - -### 2. 设备自动创建 - -**重要变化**: 当使用一个新的UUID访问API时,后端会自动创建该设备,无需手动调用创建设备接口。 - -### 3. 权限模型 - -- **读操作**: 永远不需要密码 -- **写操作**: 如果设备设置了密码则需要验证,否则直接允许 - ---- - - -## 场景1: 基于UUID的直接访问 - -适用于:用户直接操作设备数据(设备配置、设备管理等) - -### 读操作(无需密码) - -**请求方式**: `GET /device/:deviceUuid/*` - -**特点**: -- 设备不存在时自动创建 -- 无需提供密码 -- 任何知道UUID的人都可以读取 - -**请求示例**: -```http -GET /device/550e8400-e29b-41d4-a716-446655440000/info -Headers: - x-site-key: your-site-key -``` - -**成功响应** (200): -```json -{ - "id": 1, - "uuid": "550e8400-e29b-41d4-a716-446655440000", - "name": null, - "password": null, - "passwordHint": null, - "accountId": null, - "createdAt": "2025-01-30T10:00:00.000Z", - "updatedAt": "2025-01-30T10:00:00.000Z" -} -``` - -### 写操作(需要密码验证) - -**请求方式**: `POST|PUT|DELETE /device/:deviceUuid/*` - -**特点**: -- 设备不存在时自动创建 -- 如果设备设置了密码,必须提供正确密码 -- 如果设备没有密码,直接允许写入 - -#### 密码提供方式 - -**方式1: 通过请求体(推荐)** - -```http -POST /device/550e8400-e29b-41d4-a716-446655440000/config -Headers: - Content-Type: application/json - x-site-key: your-site-key -Body: -{ - "password": "device-password", - "data": { - "theme": "dark", - "language": "zh-CN" - } -} -``` - -**方式2: 通过查询参数** - -```http -POST /device/550e8400-e29b-41d4-a716-446655440000/config?password=device-password -Headers: - Content-Type: application/json - x-site-key: your-site-key -Body: -{ - "data": { - "theme": "dark", - "language": "zh-CN" - } -} -``` - -**成功响应** (200): -```json -{ - "message": "数据已更新", - "updatedAt": "2025-01-30T10:05:00.000Z" -} -``` - -**错误响应 - 需要密码** (401): -```json -{ - "statusCode": 401, - "message": "此操作需要密码", - "passwordHint": "您的生日(8位数字)" -} -``` - -**错误响应 - 密码错误** (401): -```json -{ - "statusCode": 401, - "message": "密码错误" -} -``` - ---- - -## 场景2: 基于Token的应用访问 - -适用于:应用访问KV存储数据 - -### 步骤1: 获取Token - -**请求方式**: `POST /apps/:appId/authorize` - -**请求示例**: -```http -POST /apps/1/authorize -Headers: - Content-Type: application/json - x-site-key: your-site-key -Body: -{ - "deviceUuid": "550e8400-e29b-41d4-a716-446655440000", - "password": "device-password", - "note": "我的应用授权" -} -``` - -**说明**: -- `deviceUuid`: 必填,设备UUID -- `password`: 如果设备有密码则必填 -- `note`: 可选,授权备注 - -**成功响应** (200): -```json -{ - "token": "clxxx123456789abcdefg", - "appId": 1, - "appName": "我的应用", - "deviceUuid": "550e8400-e29b-41d4-a716-446655440000", - "deviceName": null, - "note": "我的应用授权", - "authorizedAt": "2025-01-30T10:00:00.000Z" -} -``` - -**错误响应 - 需要密码** (401): -```json -{ - "statusCode": 401, - "message": "此操作需要密码", - "passwordHint": "您的生日(8位数字)" -} -``` - -### 步骤2: 使用Token访问KV存储 - -#### Token提供方式 - -**方式1: Authorization Header(推荐)** -```http -Headers: - Authorization: Bearer clxxx123456789abcdefg -``` - -**方式2: Query参数** -```http -?token=clxxx123456789abcdefg -``` - -**方式3: Request Body** -```json -{ - "token": "clxxx123456789abcdefg", - ... -} -``` - ---- - -### KV API端点 - -| 方法 | 端点 | 说明 | -|------|------|------| -| GET | `/kv` | 列出所有键(含元数据) | -| GET | `/kv/_keys` | 列出所有键名(仅键名) | -| GET | `/kv/:key` | 获取键值 | -| GET | `/kv/:key/metadata` | 获取键元数据 | -| POST | `/kv/:key` | 创建/更新键值 | -| POST | `/kv/_batchimport` | 批量导入 | -| DELETE | `/kv/:key` | 删除键值 | - ---- - -### GET /kv - 列出所有键(含元数据) - -**请求示例**: -```http -GET /kv?sortBy=key&sortDir=asc&limit=10&skip=0 -Headers: - Authorization: Bearer clxxx123456789abcdefg - x-site-key: your-site-key -``` - -**查询参数**: -- `sortBy`: 排序字段(key/createdAt/updatedAt),默认 key -- `sortDir`: 排序方向(asc/desc),默认 asc -- `limit`: 每页数量,默认 100 -- `skip`: 跳过数量,默认 0 - -**成功响应** (200): -```json -{ - "items": [ - { - "deviceId": 1, - "key": "config", - "metadata": { - "creatorIp": "192.168.1.1", - "createdAt": "2025-01-30T10:00:00.000Z", - "updatedAt": "2025-01-30T10:00:00.000Z" - } - }, - { - "deviceId": 1, - "key": "user.name", - "metadata": { - "creatorIp": "192.168.1.1", - "createdAt": "2025-01-30T10:01:00.000Z", - "updatedAt": "2025-01-30T10:01:00.000Z" - } - } - ], - "total_rows": 25, - "load_more": "/kv?sortBy=key&sortDir=asc&limit=10&skip=10" -} -``` - ---- - -### GET /kv/_keys - 列出所有键名 - -**请求示例**: -```http -GET /kv/_keys?limit=50&skip=0 -Headers: - Authorization: Bearer clxxx123456789abcdefg - x-site-key: your-site-key -``` - -**成功响应** (200): -```json -{ - "keys": ["config", "user.name", "user.theme", "app.settings"], - "total_rows": 4, - "current_page": { - "limit": 50, - "skip": 0, - "count": 4 - } -} -``` - ---- - -### GET /kv/:key - 获取键值 - -**请求示例**: -```http -GET /kv/config -Headers: - Authorization: Bearer clxxx123456789abcdefg - x-site-key: your-site-key -``` - -**成功响应** (200): -```json -{ - "theme": "dark", - "language": "zh-CN", - "fontSize": 14 -} -``` - -**错误响应 - 键不存在** (404): -```json -{ - "statusCode": 404, - "message": "未找到键名为 'config' 的记录" -} -``` - ---- - -### GET /kv/:key/metadata - 获取键元数据 - -**请求示例**: -```http -GET /kv/config/metadata -Headers: - Authorization: Bearer clxxx123456789abcdefg - x-site-key: your-site-key -``` - -**成功响应** (200): -```json -{ - "deviceId": 1, - "key": "config", - "metadata": { - "creatorIp": "192.168.1.1", - "createdAt": "2025-01-30T10:00:00.000Z", - "updatedAt": "2025-01-30T10:05:00.000Z" - } -} -``` - ---- - -### POST /kv/:key - 创建/更新键值 - -**请求示例**: -```http -POST /kv/config -Headers: - Authorization: Bearer clxxx123456789abcdefg - Content-Type: application/json - x-site-key: your-site-key -Body: -{ - "theme": "dark", - "language": "zh-CN", - "fontSize": 14 -} -``` - -**成功响应** (200): -```json -{ - "deviceId": 1, - "key": "config", - "created": false, - "updatedAt": "2025-01-30T10:10:00.000Z" -} -``` - -**说明**: -- `created`: true表示新建,false表示更新 - -**错误响应 - 空值** (400): -```json -{ - "statusCode": 400, - "message": "请提供有效的JSON值" -} -``` - ---- - -### POST /kv/_batchimport - 批量导入 - -**请求示例**: -```http -POST /kv/_batchimport -Headers: - Authorization: Bearer clxxx123456789abcdefg - Content-Type: application/json - x-site-key: your-site-key -Body: -{ - "config": { - "theme": "dark", - "language": "zh-CN" - }, - "user.name": { - "firstName": "John", - "lastName": "Doe" - }, - "app.settings": { - "notifications": true - } -} -``` - -**成功响应** (200): -```json -{ - "deviceId": 1, - "total": 3, - "successful": 3, - "failed": 0, - "results": [ - { - "key": "config", - "created": false - }, - { - "key": "user.name", - "created": true - }, - { - "key": "app.settings", - "created": true - } - ] -} -``` - -**部分失败响应** (200): -```json -{ - "deviceId": 1, - "total": 3, - "successful": 2, - "failed": 1, - "results": [ - { - "key": "config", - "created": false - }, - { - "key": "user.name", - "created": true - } - ], - "errors": [ - { - "key": "app.settings", - "error": "Invalid value" - } - ] -} -``` - ---- - -### DELETE /kv/:key - 删除键值 - -**请求示例**: -```http -DELETE /kv/config -Headers: - Authorization: Bearer clxxx123456789abcdefg - x-site-key: your-site-key -``` - -**成功响应** (204): -``` -无响应体 -``` - -**错误响应 - 键不存在** (404): -```json -{ - "statusCode": 404, - "message": "未找到键名为 'config' 的记录" -} -``` - ---- - -## 错误码参考 - -| 状态码 | 说明 | 场景 | -|--------|------|------| -| 200 | 成功 | 操作成功 | -| 204 | 成功(无内容) | 删除成功 | -| 400 | 请求错误 | 参数缺失或格式错误 | -| 401 | 未授权 | 需要密码、密码错误、Token无效 | -| 403 | 禁止访问 | 权限不足 | -| 404 | 未找到 | 资源不存在 | -| 500 | 服务器错误 | 服务器内部错误 | - ---- - -## 401错误详解 - -### 需要密码 -```json -{ - "statusCode": 401, - "message": "此操作需要密码", - "passwordHint": "您的生日(8位数字)" -} -``` - -**处理方式**: 提示用户输入密码,使用 `passwordHint` 作为提示信息 - -### 密码错误 -```json -{ - "statusCode": 401, - "message": "密码错误" -} -``` - -**处理方式**: 提示用户密码错误,允许重试 - -### Token无效 -```json -{ - "statusCode": 401, - "message": "未提供身份验证令牌" -} -``` - -或 - -```json -{ - "statusCode": 401, - "message": "无效的身份验证令牌" -} -``` - -**处理方式**: 清除本地Token,引导用户重新授权 - ---- - -## 迁移检查清单 - -### Phase 1: 基础适配 -- [ ] 移除手动创建设备的逻辑(设备会自动创建) -- [ ] 更新密码提供方式(从header改为body/query) -- [ ] 实现统一的错误处理 -- [ ] 更新API端点路径 - -### Phase 2: Token集成 -- [ ] 实现应用授权流程(POST /apps/:appId/authorize) -- [ ] 集成Token到KV操作 -- [ ] 实现Token存储和管理(localStorage) -- [ ] 处理Token过期/无效场景 - -### Phase 3: 优化 -- [ ] 封装统一的API客户端 -- [ ] 实现请求重试机制 -- [ ] 添加Loading状态管理 -- [ ] 优化错误提示用户体验 - -### Phase 4: 测试 -- [ ] 测试设备自动创建 -- [ ] 测试密码验证流程(需要密码、密码错误、密码正确) -- [ ] 测试Token授权流程 -- [ ] 测试各种错误场景(404、401、400等) - ---- - -## 关键注意事项 - -### 1. 设备自动创建 -- ✅ 无需手动创建设备,首次访问自动创建 -- ✅ 简化前端流程,减少API调用 -- ⚠️ 确保UUID使用正确的格式(建议使用uuidv4) - -### 2. 密码处理 -- ✅ 读操作永远不需要密码 -- ✅ 写操作只在设备设置了密码时才需要 -- ⚠️ 密码通过body或query提供,不要放在header中 -- ⚠️ 注意区分"需要密码"和"密码错误"两种情况 - -### 3. Token管理 -- ✅ Token一次获取,可重复使用 -- ✅ Token与设备和应用绑定 -- ⚠️ Token需要安全存储(localStorage/sessionStorage) -- ⚠️ Token失效时需要重新授权 - -### 4. Header要求 -- 所有请求必须携带 `x-site-key` header -- Token认证使用 `Authorization: Bearer ` header(推荐) - ---- \ No newline at end of file diff --git a/docs/apps.md b/docs/apps.md deleted file mode 100644 index f17ad25..0000000 --- a/docs/apps.md +++ /dev/null @@ -1,257 +0,0 @@ -# Apps API - -## 1. 获取应用权限信息 (Get Application Permissions) - -- **GET** `/apps/token/:token/permissions` - -通过token获取应用权限信息。 - -**路径参数:** - -- `token` (string, required): 应用访问令牌 - -**Curl 示例:** - -```bash -curl -X GET "http://localhost:3000/api/apps/token/your-app-token/permissions" \ - -H "X-Site-Key: your-site-key" -``` - -**响应示例 (200 OK):** - -```json -{ - "appId": 1, - "permissionPrefix": "myapp", - "specialPermissions": [], - "permissionKey": [], - "app": { - "id": 1, - "name": "应用名称", - "description": "应用描述", - "developerName": "开发者" - } -} -``` - -## 2. 获取应用列表 (Get App List) - -- **GET** `/apps` - -获取应用列表,支持搜索和分页。 - -**查询参数:** - -- `limit` (integer, optional, default: 20): 每页数量 -- `skip` (integer, optional, default: 0): 跳过数量 -- `search` (string, optional): 搜索关键词 - -**Curl 示例:** - -```bash -curl -X GET "http://localhost:3000/api/apps?limit=10&skip=0&search=test" \ - -H "X-Site-Key: your-site-key" -``` - -**响应示例 (200 OK):** - -```json -{ - "apps": [ - { - "id": 1, - "name": "Test App", - "description": "An application for testing.", - "developerName": "Test Developer", - "permissionPrefix": "testapp", - "specialPermissions": [], - "permissionKey": [], - "version": "1.0.0", - "createdAt": "2023-10-27T10:00:00.000Z", - "updatedAt": "2023-10-27T10:00:00.000Z" - } - ], - "total": 1, - "limit": 10, - "skip": 0 -} -``` - -## 3. 获取单个应用详情 (Get App Details) - -- **GET** `/apps/:id` - -获取单个应用详情。 - -**路径参数:** - -- `id` (integer, required): 应用ID - -**Curl 示例:** - -```bash -curl -X GET "http://localhost:3000/api/apps/1" \ - -H "X-Site-Key: your-site-key" -``` - -**响应示例 (200 OK):** - -```json -{ - "id": 1, - "name": "Test App", - "description": "An application for testing.", - "developerName": "Test Developer", - "permissionPrefix": "testapp", - "specialPermissions": [], - "permissionKey": [], - "version": "1.0.0", - "createdAt": "2023-10-27T10:00:00.000Z", - "updatedAt": "2023-10-27T10:00:00.000Z" -} -``` - -## 4. 为设备授权或升级应用 (Install or Upgrade App for Device) - -- **POST** `/apps/:id/install/:deviceUuid` - -为设备授权应用。如果应用已安装,则会将其升级到最新版本。 - -**路径参数:** - -- `id` (integer, required): 应用ID -- `deviceUuid` (string, required): 设备UUID - -**Curl 示例:** - -```bash -curl -X POST "http://localhost:3000/api/apps/1/install/your-device-uuid" \ - -H "X-Site-Key: your-site-key" -``` - -**响应示例 (200 OK):** - -```json -{ - "token": "a-unique-token-for-this-installation", - "appId": 1, - "permissionPrefix": "testapp", - "permissionKey": [], - "version": "1.1.0", - "authorizedAt": "2023-10-27T11:00:00.000Z" -} -``` - -## 6. 卸载设备上的应用 (Uninstall App from Device) - -- **DELETE** `/apps/:id/uninstall/:deviceUuid` - -卸载设备上已安装的应用。 - -**路径参数:** - -- `id` (integer, required): 应用ID -- `deviceUuid` (string, required): 设备UUID - -**Curl 示例:** - -```bash -curl -X DELETE "http://localhost:3000/api/apps/1/uninstall/your-device-uuid" \ - -H "X-Site-Key: your-site-key" -``` - -**响应示例 (200 OK):** - -```json -{ - "message": "应用卸载成功", - "appId": 1, - "uninstalledAt": "2023-10-27T12:00:00.000Z" -} -``` - -## 7. 获取设备已安装的应用列表 (Get Installed Apps on Device) - -- **GET** `/devices/:deviceUuid/apps` - -获取指定设备上已安装的所有应用列表。 - -**路径参数:** - -- `deviceUuid` (string, required): 设备UUID - -**查询参数:** - -- `limit` (integer, optional, default: 20): 每页数量 -- `skip` (integer, optional, default: 0): 跳过数量 - -**Curl 示例:** - -```bash -curl -X GET "http://localhost:3000/api/devices/your-device-uuid/apps?limit=10" \ - -H "X-Site-Key: your-site-key" -``` - -**响应示例 (200 OK):** - -```json -{ - "installs": [ - { - "id": 1, - "appId": 1, - "token": "a-unique-token-for-this-installation", - "permissionPrefix": "testapp", - "specialPermissions": [], - "permissionKey": [], - "version": "1.0.0", - "installedAt": "2023-10-27T10:00:00.000Z", - "updatedAt": "2023-10-27T10:00:00.000Z", - "app": { - "id": 1, - "name": "Test App", - "description": "An application for testing.", - "developerName": "Test Developer", - "permissionPrefix": "testapp" - } - } - ], - "total": 1, - "limit": 10, - "skip": 0 -} -``` - -## 8. 获取所有带有 permissionKey 的应用列表 (Get Apps with Permission Key) - -- **GET** `/apps/with-permission-key` - -获取所有设置了 `permissionKey` 的应用列表。 - -**Curl 示例:** - -```bash -curl -X GET "http://localhost:3000/api/apps/with-permission-key" \ - -H "X-Site-Key: your-site-key" -``` - -**响应示例 (200 OK):** - -```json -{ - "apps": [ - { - "id": 2, - "name": "App With Keys", - "description": "An application that uses permission keys.", - "developerName": "Key Developer", - "permissionPrefix": "keyapp", - "specialPermissions": [], - "permissionKey": ["read:data", "write:data"], - "version": "1.0.0", - "createdAt": "2023-10-26T10:00:00.000Z", - "updatedAt": "2023-10-26T10:00:00.000Z" - } - ] -} -``` \ No newline at end of file diff --git a/docs/device-auth-frontend.md b/docs/device-auth-frontend.md deleted file mode 100644 index 790dec2..0000000 --- a/docs/device-auth-frontend.md +++ /dev/null @@ -1,131 +0,0 @@ -# 设备授权流程 - 前端接口文档 - -## 概述 - -类似 Device Authorization Grant 的授权流程,允许应用通过设备代码获取用户的访问令牌。 - -## 前端相关接口 - -### 1. 绑定令牌到设备代码 - -将用户的访问令牌绑定到应用提供的设备代码。 - -**接口地址:** `POST /auth/device/bind` - -**请求头:** -``` -Content-Type: application/json -X-Site-Key: your-site-key -``` - -**请求体:** -```json -{ - "device_code": "1234-ABCD", - "token": "user-access-token-string" -} -``` - -**参数说明:** -- `device_code` (必填): 应用提供给用户的设备授权码,格式如 `1234-ABCD` -- `token` (必填): 用户在系统中已有的有效访问令牌 - -**成功响应:** `200 OK` -```json -{ - "success": true, - "message": "令牌已成功绑定到设备代码" -} -``` - -**错误响应:** - -400 Bad Request - 参数错误 -```json -{ - "statusCode": 400, - "message": "请提供 device_code 和 token" -} -``` - -400 Bad Request - 无效的令牌 -```json -{ - "statusCode": 400, - "message": "无效的令牌" -} -``` - -400 Bad Request - 设备代码不存在或已过期 -```json -{ - "statusCode": 400, - "message": "设备代码不存在或已过期" -} -``` - ---- - -### 2. 查询设备代码状态(可选,用于调试) - -查询设备代码的当前状态,不会删除或修改数据。 - -**接口地址:** `GET /auth/device/status` - -**请求头:** -``` -X-Site-Key: your-site-key -``` - -**查询参数:** -- `device_code` (必填): 设备授权码 - -**请求示例:** -``` -GET /auth/device/status?device_code=1234-ABCD -``` - -**成功响应:** `200 OK` - -设备代码存在: -```json -{ - "device_code": "1234-ABCD", - "exists": true, - "has_token": false, - "expires_in": 850, - "created_at": 1234567890000 -} -``` - -设备代码不存在或已过期: -```json -{ - "device_code": "1234-ABCD", - "exists": false, - "message": "设备代码不存在或已过期" -} -``` - -**字段说明:** -- `exists`: 设备代码是否存在且有效 -- `has_token`: 是否已绑定令牌 -- `expires_in`: 剩余有效时间(秒) -- `created_at`: 创建时间戳(毫秒) - ---- - -## 使用流程 - -1. **应用端**生成设备代码并展示给用户 -2. **用户**在前端页面输入设备代码 -3. **前端**调用 `/auth/device/bind` 接口,将用户的 token 绑定到设备代码 -4. **应用端**轮询获取到令牌,完成授权 - -## 注意事项 - -- 设备代码有效期为 15 分钟 -- 令牌必须是系统中已存在的有效令牌 -- 设备代码格式固定为 `XXXX-XXXX` (4位数字-4位字母/数字) -- 令牌获取后会从服务器内存中删除,只能获取一次 -- 如果需要站点密钥,需在请求头中添加 `X-Site-Key` diff --git a/docs/kv-token.md b/docs/kv-token.md deleted file mode 100644 index 1989d04..0000000 --- a/docs/kv-token.md +++ /dev/null @@ -1,224 +0,0 @@ -# KV 存储 Token API - -本文档描述了基于令牌的 KV 存储 API。这些 API 端点使用应用程序安装令牌进行身份验证,而不是直接使用设备 UUID。 - -## 身份验证 - -所有请求都需要提供一个有效的应用程序安装令牌。令牌可以通过以下方式之一提供: - -1. **Authorization Header**: -``` -Authorization: Bearer YOUR_TOKEN -``` - -2. **Query Parameter**: -``` -?token=YOUR_TOKEN -``` - -3. **Request Body**: -```json -{ - "token": "YOUR_TOKEN" -} -``` - -## API 端点 - -### 列出键名 - -获取命名空间下的所有键名(不包括值)。 - -```http -GET /kv/token/_keys -``` - -查询参数: -- `sortBy`: 排序字段(默认:'key') -- `sortDir`: 排序方向('asc' 或 'desc',默认:'asc') -- `limit`: 每页记录数(默认:100) -- `skip`: 跳过的记录数(默认:0) - -响应示例: -```json -{ - "keys": ["key1", "key2", "key3"], - "total_rows": 3, - "current_page": { - "limit": 100, - "skip": 0, - "count": 3 - } -} -``` - -### 列出所有键值对 - -获取命名空间下的所有键值对及其元数据。 - -```http -GET /kv/token/ -``` - -查询参数: -- `sortBy`: 排序字段(默认:'key') -- `sortDir`: 排序方向('asc' 或 'desc',默认:'asc') -- `limit`: 每页记录数(默认:100) -- `skip`: 跳过的记录数(默认:0) - -响应示例: -```json -{ - "items": [ - { - "key": "key1", - "value": { "data": "value1" }, - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z" - } - ], - "total_rows": 1 -} -``` - -### 获取单个键值 - -获取特定键的值。 - -```http -GET /kv/token/:key -``` - -响应示例: -```json -{ - "data": "value1" -} -``` - -### 获取键的元数据 - -获取特定键的元数据信息。 - -```http -GET /kv/token/:key/metadata -``` - -响应示例: -```json -{ - "key": "key1", - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z", - "creatorIp": "127.0.0.1" -} -``` - -### 批量导入 - -批量导入多个键值对。 - -```http -POST /kv/token/_batchimport -``` - -请求体示例: -```json -{ - "key1": { "data": "value1" }, - "key2": { "data": "value2" } -} -``` - -响应示例: -```json -{ - "namespace": "device-uuid", - "total": 2, - "successful": 2, - "failed": 0, - "results": [ - { - "key": "key1", - "created": true - }, - { - "key": "key2", - "created": true - } - ] -} -``` - -### 创建或更新键值 - -创建新的键值对或更新现有的键值对。 - -```http -POST /kv/token/:key -``` - -请求体示例: -```json -{ - "data": "value1" -} -``` - -响应示例: -```json -{ - "namespace": "device-uuid", - "key": "key1", - "created": true, - "updatedAt": "2024-01-01T00:00:00Z" -} -``` - -### 删除命名空间 - -删除整个命名空间及其所有键值对。 - -```http -DELETE /kv/token/ -``` - -成功时返回 204 No Content。 - -### 删除键值对 - -删除特定的键值对。 - -```http -DELETE /kv/token/:key -``` - -成功时返回 204 No Content。 - -## 错误处理 - -所有错误响应都遵循以下格式: - -```json -{ - "statusCode": 400, - "message": "错误描述" -} -``` - -常见错误代码: -- 400: 请求参数错误 -- 401: 未提供令牌或令牌无效 -- 403: 权限不足 -- 404: 资源不存在 -- 429: 请求过于频繁 -- 500: 服务器内部错误 - -## 权限 - -API 使用以下权限系统: -- `appReadAuthMiddleware`: 用于读取操作 -- `appWriteAuthMiddleware`: 用于写入操作 -- `appListAuthMiddleware`: 用于列表操作 - -这些权限基于应用程序的安装记录中的 `permissionPrefix` 和 `permissionKey` 字段进行验证。 \ No newline at end of file diff --git a/docs/kv.md b/docs/kv.md deleted file mode 100644 index d116b29..0000000 --- a/docs/kv.md +++ /dev/null @@ -1,614 +0,0 @@ -# KV Store API - -## 1. 获取设备信息 (Get Device Info) - -- **GET** `/:namespace/_info` - -获取指定命名空间(设备)的详细信息。 - -**路径参数:** - -- `namespace` (string, required): 设备UUID - -**Headers:** - -- `X-Site-Key` (string, required): 站点密钥 -- `Authorization` (string, required): `Bearer ` - -**Curl 示例:** - -```bash -curl -X GET "http://localhost:3030/kv/your-device-uuid/_info" \ - -H "X-Site-Key: your-site-key" \ - -H "Authorization: Bearer your-read-token" -``` - -**响应示例 (200 OK):** - -```json -{ - "uuid": "your-device-uuid", - "name": "My Device", - "accessType": "PROTECTED", - "hasPassword": true -} -``` - -## 2. 检查设备状态 (Check Device Status) - -- **GET** `/:namespace/_check` - -检查设备是否存在及基本信息。 - -**路径参数:** - -- `namespace` (string, required): 设备UUID - -**Headers:** - -- `X-Site-Key` (string, required): 站点密钥 - -**Curl 示例:** - -```bash -curl -X GET "http://localhost:3030/kv/your-device-uuid/_check" \ - -H "X-Site-Key: your-site-key" -``` - -**响应示例 (200 OK):** - -```json -{ - "status": "success", - "uuid": "your-device-uuid", - "name": "My Device", - "accessType": "PROTECTED", - "hasPassword": true -} -``` - -## 3. 校验设备密码 (Check Device Password) - -- **POST** `/:namespace/_checkpassword` - -校验设备密码是否正确。 - -**路径参数:** - -- `namespace` (string, required): 设备UUID - -**Headers:** - -- `X-Site-Key` (string, required): 站点密钥 - -**请求体:** - -```json -{ - "password": "your-device-password" -} -``` - -**Curl 示例:** - -```bash -curl -X POST "http://localhost:3030/kv/your-device-uuid/_checkpassword" \ - -H "X-Site-Key: your-site-key" \ - -H "Content-Type: application/json" \ - -d '{"password": "your-device-password"}' -``` - -**响应示例 (200 OK):** - -```json -{ - "status": "success", - "uuid": "your-device-uuid", - "name": "My Device", - "accessType": "PROTECTED", - "hasPassword": true -} -``` - -## 4. 获取密码提示 (Get Password Hint) - -- **GET** `/:namespace/_hint` - -获取设备的密码提示。 - -**路径参数:** - -- `namespace` (string, required): 设备UUID - -**Headers:** - -- `X-Site-Key` (string, required): 站点密钥 - -**Curl 示例:** - -```bash -curl -X GET "http://localhost:3030/kv/your-device-uuid/_hint" \ - -H "X-Site-Key: your-site-key" -``` - -**响应示例 (200 OK):** - -```json -{ - "passwordHint": "My favorite pet's name" -} -``` - -## 5. 更新密码提示 (Update Password Hint) - -- **PUT** `/:namespace/_hint` - -更新设备的密码提示。 - -**路径参数:** - -- `namespace` (string, required): 设备UUID - -**Headers:** - -- `X-Site-Key` (string, required): 站点密钥 -- `Authorization` (string, required): `Bearer ` - -**请求体:** - -```json -{ - "hint": "New password hint" -} -``` - -**Curl 示例:** - -```bash -curl -X PUT "http://localhost:3030/kv/your-device-uuid/_hint" \ - -H "X-Site-Key: your-site-key" \ - -H "Authorization: Bearer your-write-token" \ - -H "Content-Type: application/json" \ - -d '{"hint": "New password hint"}' -``` - -**响应示例 (200 OK):** - -```json -{ - "message": "密码提示已更新", - "passwordHint": "New password hint" -} -``` - -## 6. 更新设备信息 (Update Device Info) - -- **PUT** `/:namespace/_info` - -更新设备名称或访问类型。 - -**路径参数:** - -- `namespace` (string, required): 设备UUID - -**Headers:** - -- `X-Site-Key` (string, required): 站点密钥 -- `Authorization` (string, required): `Bearer ` - -**请求体:** - -```json -{ - "name": "New Device Name", - "accessType": "PRIVATE" -} -``` - -**Curl 示例:** - -```bash -curl -X PUT "http://localhost:3030/kv/your-device-uuid/_info" \ - -H "X-Site-Key: your-site-key" \ - -H "Authorization: Bearer your-write-token" \ - -H "Content-Type: application/json" \ - -d '{"name": "New Device Name", "accessType": "PRIVATE"}' -``` - -**响应示例 (200 OK):** - -```json -{ - "uuid": "your-device-uuid", - "name": "New Device Name", - "accessType": "PRIVATE", - "hasPassword": true -} -``` - -## 7. 移除设备密码 (Remove Device Password) - -- **DELETE** `/:namespace/_password` - -移除设备的密码。 - -**路径参数:** - -- `namespace` (string, required): 设备UUID - -**Headers:** - -- `X-Site-Key` (string, required): 站点密钥 -- `Authorization` (string, required): `Bearer ` - -**请求体:** - -```json -{ - "password": "current-password" -} -``` - -**Curl 示例:** - -```bash -curl -X DELETE "http://localhost:3030/kv/your-device-uuid/_password" \ - -H "X-Site-Key: your-site-key" \ - -H "Authorization: Bearer your-write-token" \ - -H "Content-Type: application/json" \ - -d '{"password": "current-password"}' -``` - -**响应示例 (200 OK):** - -```json -{ - "message": "密码已成功移除" -} -``` - -## 8. 获取键名列表 (List Keys) - -- **GET** `/:namespace/_keys` - -获取指定命名空间下的键名列表(不包含值)。 - -**路径参数:** - -- `namespace` (string, required): 设备UUID - -**查询参数:** - -- `sortBy` (string, optional, default: `key`): 排序字段 -- `sortDir` (string, optional, default: `asc`): 排序方向 -- `limit` (integer, optional, default: 100): 每页数量 -- `skip` (integer, optional, default: 0): 跳过数量 - -**Headers:** - -- `X-Site-Key` (string, required): 站点密钥 -- `Authorization` (string, required): `Bearer ` - -**Curl 示例:** - -```bash -curl -X GET "http://localhost:3030/kv/your-device-uuid/_keys?limit=50&sortDir=desc" \ - -H "X-Site-Key: your-site-key" \ - -H "Authorization: Bearer your-read-token" -``` - -**响应示例 (200 OK):** - -```json -{ - "keys": [ - "key3", - "key2", - "key1" - ], - "total_rows": 3, - "current_page": { - "limit": 50, - "skip": 0, - "count": 3 - } -} -``` - -## 9. 获取所有键值对 (List All Key-Value Pairs) - -- **GET** `/:namespace` - -获取指定命名空间下的所有键值对及元数据。 - -**路径参数:** - -- `namespace` (string, required): 设备UUID - -**查询参数:** - -- `sortBy` (string, optional, default: `key`): 排序字段 -- `sortDir` (string, optional, default: `asc`): 排序方向 -- `limit` (integer, optional, default: 100): 每页数量 -- `skip` (integer, optional, default: 0): 跳过数量 - -**Headers:** - -- `X-Site-Key` (string, required): 站点密钥 -- `Authorization` (string, required): `Bearer ` - -**Curl 示例:** - -```bash -curl -X GET "http://localhost:3030/kv/your-device-uuid?limit=1" \ - -H "X-Site-Key: your-site-key" \ - -H "Authorization: Bearer your-read-token" -``` - -**响应示例 (200 OK):** - -```json -{ - "items": [ - { - "key": "key1", - "value": {"data": "some value"}, - "createdAt": "2023-10-27T10:00:00.000Z", - "updatedAt": "2023-10-27T10:00:00.000Z", - "creatorIp": "::1" - } - ], - "total_rows": 1, - "load_more": "/api/kv/your-device-uuid?sortBy=key&sortDir=asc&limit=1&skip=1" -} -``` - -## 10. 获取单个键值 (Get Value by Key) - -- **GET** `/:namespace/:key` - -通过键名获取单个键值。 - -**路径参数:** - -- `namespace` (string, required): 设备UUID -- `key` (string, required): 键名 - -**Headers:** - -- `X-Site-Key` (string, required): 站点密钥 -- `Authorization` (string, required): `Bearer ` - -**Curl 示例:** - -```bash -curl -X GET "http://localhost:3030/kv/your-device-uuid/my-key" \ - -H "X-Site-Key: your-site-key" \ - -H "Authorization: Bearer your-read-token" -``` - -**响应示例 (200 OK):** - -```json -{ - "some_data": "value", - "nested": { - "is_supported": true - } -} -``` - -## 11. 获取键的元数据 (Get Key Metadata) - -- **GET** `/:namespace/:key/metadata` - -获取单个键的元数据。 - -**路径参数:** - -- `namespace` (string, required): 设备UUID -- `key` (string, required): 键名 - -**Headers:** - -- `X-Site-Key` (string, required): 站点密钥 -- `Authorization` (string, required): `Bearer ` - -**Curl 示例:** - -```bash -curl -X GET "http://localhost:3030/kv/your-device-uuid/my-key/metadata" \ - -H "X-Site-Key: your-site-key" \ - -H "Authorization: Bearer your-read-token" -``` - -**响应示例 (200 OK):** - -```json -{ - "key": "my-key", - "createdAt": "2023-10-27T10:00:00.000Z", - "updatedAt": "2023-10-27T10:00:00.000Z", - "creatorIp": "::1" -} -``` - -## 12. 批量导入键值对 (Batch Import Key-Value Pairs) - -- **POST** `/:namespace/_batchimport` - -批量导入多个键值对。 - -**路径参数:** - -- `namespace` (string, required): 设备UUID - -**Headers:** - -- `X-Site-Key` (string, required): 站点密钥 -- `Authorization` (string, required): `Bearer ` - -**请求体:** - -```json -{ - "key1": {"data": "value1"}, - "key2": {"data": "value2"} -} -``` - -**Curl 示例:** - -```bash -curl -X POST "http://localhost:3030/kv/your-device-uuid/_batchimport" \ - -H "X-Site-Key: your-site-key" \ - -H "Authorization: Bearer your-write-token" \ - -H "Content-Type: application/json" \ - -d '{"key1": {"data": "value1"}, "key2": {"data": "value2"}}' -``` - -**响应示例 (200 OK):** - -```json -{ - "namespace": "your-device-uuid", - "total": 2, - "successful": 2, - "failed": 0, - "results": [ - { - "key": "key1", - "created": true - }, - { - "key": "key2", - "created": true - } - ] -} -``` - -## 13. 创建或更新键值对 (Create/Update Key-Value Pair) - -- **POST** `/:namespace/:key` - -创建或更新单个键值对。 - -**路径参数:** - -- `namespace` (string, required): 设备UUID -- `key` (string, required): 键名 - -**Headers:** - -- `X-Site-Key` (string, required): 站点密钥 -- `Authorization` (string, required): `Bearer ` - -**请求体:** - -```json -{ - "new_data": "is here" -} -``` - -**Curl 示例:** - -```bash -curl -X POST "http://localhost:3030/kv/your-device-uuid/my-key" \ - -H "X-Site-Key: your-site-key" \ - -H "Authorization: Bearer your-write-token" \ - -H "Content-Type: application/json" \ - -d '{"new_data": "is here"}' -``` - -**响应示例 (200 OK):** - -```json -{ - "namespace": "your-device-uuid", - "key": "my-key", - "created": false, - "updatedAt": "2023-10-27T11:00:00.000Z" -} -``` - -## 14. 删除命名空间 (Delete Namespace) - -- **DELETE** `/:namespace` - -删除整个命名空间及其所有数据。 - -**路径参数:** - -- `namespace` (string, required): 设备UUID - -**Headers:** - -- `X-Site-Key` (string, required): 站点密钥 -- `Authorization` (string, required): `Bearer ` - -**Curl 示例:** - -```bash -curl -X DELETE "http://localhost:3030/kv/your-device-uuid" \ - -H "X-Site-Key: your-site-key" \ - -H "Authorization: Bearer your-write-token" -``` - -**响应 (204 No Content):** - -无响应体。 - -## 15. 删除键 (Delete Key) - -- **DELETE** `/:namespace/:key` - -删除单个键值对。 - -**路径参数:** - -- `namespace` (string, required): 设备UUID -- `key` (string, required): 键名 - -**Headers:** - -- `X-Site-Key` (string, required): 站点密钥 -- `Authorization` (string, required): `Bearer ` - -**Curl 示例:** - -```bash -curl -X DELETE "http://localhost:3030/kv/your-device-uuid/my-key" \ - -H "X-Site-Key: your-site-key" \ - -H "Authorization: Bearer your-write-token" -``` - -**响应 (204 No Content):** - -无响应体。 - -## 16. 生成 UUID (Generate UUID) - -- **GET** `/uuid` - -生成一个新的 UUID,可用作命名空间。 - -**Headers:** - -- `X-Site-Key` (string, required): 站点密钥 - -**Curl 示例:** - -```bash -curl -X GET "http://localhost:3030/kv/uuid" \ - -H "X-Site-Key: your-site-key" -``` - -**响应示例 (200 OK):** - -```json -{ - "namespace": "a-newly-generated-uuid" -} -``` \ No newline at end of file diff --git a/docs/middleware.md b/docs/middleware.md deleted file mode 100644 index 2743a33..0000000 --- a/docs/middleware.md +++ /dev/null @@ -1,479 +0,0 @@ -# 中间件系统文档 - -## 概述 - -本项目使用中间件系统来处理设备信息获取、权限验证和Token认证。所有与UUID相关的操作都通过统一的中间件处理。 - -## 中间件架构 - -### 1. 设备信息中间件 (`deviceMiddleware`) - -**文件位置**: `middleware/device.js` - -**功能**: 统一处理设备UUID,自动获取或创建设备 - -**使用场景**: -- 所有需要设备信息的接口 -- 不需要密码验证的读操作 -- 需要在后续中间件中访问设备信息的场景 - -**工作流程**: -1. 从 `req.params.deviceUuid`、`req.params.namespace` 或 `req.body.deviceUuid` 获取UUID -2. 在数据库中查找设备 -3. 如果设备不存在,自动创建新设备 -4. 将设备信息存储到 `res.locals.device` - -**代码示例**: -```javascript -import { deviceMiddleware } from './middleware/device.js'; - -// 基本用法 -router.get('/device/:deviceUuid/info', deviceMiddleware, (req, res) => { - // 设备信息可从 res.locals.device 访问 - res.json(res.locals.device); -}); - -// 从body获取UUID -router.post('/device/create', deviceMiddleware, (req, res) => { - // req.body.deviceUuid 会被自动处理 - res.json({ message: '设备已创建', device: res.locals.device }); -}); -``` - -**数据访问**: -```javascript -const device = res.locals.device; -// device: { -// id: 1, -// uuid: 'device-uuid-123', -// name: 'My Device', -// password: 'hashed-password', -// passwordHint: '提示信息', -// accountId: null, -// createdAt: Date, -// updatedAt: Date -// } -``` - ---- - -### 2. 写权限验证中间件 (`requireWriteAuth`) - -**文件位置**: `middleware/tokenAuth.js` - -**功能**: 验证设备密码,控制写权限 - -**依赖**: 必须在 `deviceMiddleware` 之后使用 - -**使用场景**: -- 所有需要修改数据的操作(POST、PUT、DELETE) -- 需要验证设备密码的操作 - -**工作流程**: -1. 从 `res.locals.device` 获取设备信息 -2. 如果设备没有设置密码,直接允许操作 -3. 如果设备设置了密码: - - 从 `req.body.password` 或 `req.query.password` 获取密码 - - 验证密码是否正确 - - 密码正确:继续执行 - - 密码错误或未提供:返回 401 错误 - -**代码示例**: -```javascript -import { deviceMiddleware } from './middleware/device.js'; -import { requireWriteAuth } from './middleware/tokenAuth.js'; - -// 写操作需要密码验证 -router.post('/device/:deviceUuid/data', - deviceMiddleware, // 第一步:获取设备信息 - requireWriteAuth, // 第二步:验证写权限 - (req, res) => { - // 验证通过,执行写操作 - res.json({ message: '数据已更新' }); - } -); - -// 读操作不需要密码 -router.get('/device/:deviceUuid/data', - deviceMiddleware, // 只需要设备信息 - (req, res) => { - res.json({ data: 'some data' }); - } -); -``` - -**密码提供方式**: -```javascript -// 方式1: 通过请求体 -fetch('/device/uuid-123/data', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - password: 'device-password', - data: 'new value' - }) -}); - -// 方式2: 通过查询参数 -fetch('/device/uuid-123/data?password=device-password', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ data: 'new value' }) -}); -``` - -**错误响应**: -```json -// 需要密码但未提供 -{ - "statusCode": 401, - "message": "此操作需要密码", - "passwordHint": "提示信息" -} - -// 密码错误 -{ - "statusCode": 401, - "message": "密码错误" -} -``` - ---- - -### 3. Token认证中间件 (`tokenAuth`) - -**文件位置**: `middleware/tokenAuth.js` - -**功能**: 基于应用安装Token进行认证 - -**使用场景**: -- 应用访问KV数据 -- 需要应用级别认证的接口 -- 不依赖设备UUID的操作 - -**工作流程**: -1. 从 Header、Query 或 Body 中获取 token -2. 在数据库中查找对应的应用安装记录 -3. 验证 token 是否有效 -4. 将应用、设备信息存储到 `res.locals` - -**Token提供方式**: -1. **Authorization Header** (推荐): - ```javascript - headers: { - 'Authorization': 'Bearer ' - } - ``` - -2. **Query参数**: - ```javascript - ?token= - ``` - -3. **Request Body**: - ```javascript - { - "token": "", - "data": "..." - } - ``` - -**代码示例**: -```javascript -import { tokenAuth } from './middleware/tokenAuth.js'; - -// Token认证的接口 -router.get('/kv/:key', tokenAuth, (req, res) => { - // 可访问: - // - res.locals.appInstall (应用安装记录) - // - res.locals.app (应用信息) - // - res.locals.device (设备信息) - // - res.locals.deviceId (设备ID) - - res.json({ - key: req.params.key, - device: res.locals.device.uuid, - app: res.locals.app.name - }); -}); -``` - -**数据访问**: -```javascript -const appInstall = res.locals.appInstall; -// appInstall: { -// id: 'cuid', -// deviceId: 1, -// appId: 1, -// token: 'unique-token', -// note: '备注', -// installedAt: Date, -// updatedAt: Date, -// app: { ... }, -// device: { ... } -// } - -const app = res.locals.app; -// app: { id, name, description, developerName, ... } - -const device = res.locals.device; -// device: { id, uuid, name, password, ... } -``` - ---- - -## 中间件组合使用 - -### 场景1: 基于UUID的读操作(无需密码) -```javascript -router.get('/device/:deviceUuid/data', - deviceMiddleware, - (req, res) => { - const device = res.locals.device; - res.json({ device, data: '...' }); - } -); -``` - -### 场景2: 基于UUID的写操作(需要密码) -```javascript -router.post('/device/:deviceUuid/data', - deviceMiddleware, // 获取设备信息 - requireWriteAuth, // 验证密码 - (req, res) => { - // 执行写操作 - res.json({ message: '成功' }); - } -); -``` - -### 场景3: 基于Token的操作 -```javascript -router.get('/kv/:key', - tokenAuth, // Token认证,自动获取设备信息 - (req, res) => { - const device = res.locals.device; - const app = res.locals.app; - res.json({ device, app, data: '...' }); - } -); -``` - -### 场景4: 批量路由保护 -```javascript -const router = express.Router(); - -// 所有该路由下的接口都需要设备信息 -router.use(deviceMiddleware); - -// 具体接口 -router.get('/info', (req, res) => { - res.json(res.locals.device); -}); - -router.post('/update', requireWriteAuth, (req, res) => { - res.json({ message: '更新成功' }); -}); -``` - ---- - -## 最佳实践 - -### 1. 中间件顺序很重要 -```javascript -// ✅ 正确:先获取设备信息,再验证权限 -router.post('/data', deviceMiddleware, requireWriteAuth, handler); - -// ❌ 错误:requireWriteAuth 依赖 deviceMiddleware -router.post('/data', requireWriteAuth, deviceMiddleware, handler); -``` - -### 2. 选择合适的认证方式 -```javascript -// 用户直接操作设备 → 使用 deviceMiddleware + requireWriteAuth -router.post('/device/:deviceUuid/config', deviceMiddleware, requireWriteAuth, handler); - -// 应用代表用户操作 → 使用 tokenAuth -router.post('/kv/:key', tokenAuth, handler); -``` - -### 3. 读操作不需要密码 -```javascript -// ✅ 读操作只需要设备信息 -router.get('/device/:deviceUuid/data', deviceMiddleware, handler); - -// ❌ 读操作不需要密码验证 -router.get('/device/:deviceUuid/data', deviceMiddleware, requireWriteAuth, handler); -``` - -### 4. 错误处理 -```javascript -router.post('/data', deviceMiddleware, requireWriteAuth, - async (req, res, next) => { - try { - // 业务逻辑 - const device = res.locals.device; - // ... - res.json({ success: true }); - } catch (error) { - next(error); // 传递给全局错误处理器 - } - } -); -``` - -### 5. 密码提示信息 -```javascript -// 设置设备时提供密码提示 -await prisma.device.update({ - where: { uuid: deviceUuid }, - data: { - password: hashedPassword, - passwordHint: '您的生日(8位数字)' // 提供友好的提示 - } -}); -``` - ---- - -## 常见问题 - -### Q1: 为什么设备不存在时会自动创建? -**A**: 这是为了简化客户端逻辑。客户端只需要生成UUID并使用,无需先调用创建接口。首次访问时会自动创建设备记录。 - -### Q2: 读操作为什么不需要密码? -**A**: 根据项目需求,只有写操作需要密码保护。读操作允许任何知道UUID的人访问。如果需要保护读操作,可以在路由中添加 `requireWriteAuth` 中间件。 - -### Q3: deviceMiddleware 和 tokenAuth 有什么区别? -**A**: -- `deviceMiddleware`: 基于UUID获取设备信息,适合用户直接操作 -- `tokenAuth`: 基于应用Token认证,适合应用代表用户操作,包含应用级别的权限控制 - -### Q4: 如何撤销某个设备的访问权限? -**A**: -1. 基于UUID的访问:修改设备密码 -2. 基于Token的访问:删除对应的 `AppInstall` 记录 - -### Q5: 密码错误但操作不需要密码是否可以继续? -**A**: 不可以。`requireWriteAuth` 中间件会检查: -- 如果设备没有密码 → 直接通过 -- 如果设备有密码但未提供 → 拒绝 -- 如果设备有密码但错误 → 拒绝 - -如果操作不需要密码,不要使用 `requireWriteAuth` 中间件。 - ---- - -## 迁移指南 - -### 从旧的认证系统迁移 - -**旧代码**: -```javascript -router.post('/kv/:namespace/:key', authMiddleware, handler); -``` - -**新代码**: -```javascript -// 选项1: 使用 deviceMiddleware (如果通过URL传递UUID) -router.post('/device/:deviceUuid/kv/:key', - deviceMiddleware, - requireWriteAuth, - handler -); - -// 选项2: 使用 tokenAuth (推荐,更安全) -router.post('/kv/:key', tokenAuth, handler); -``` - -### 客户端更新 - -**旧方式**: -```javascript -// UUID + 密码 -fetch('/kv/device-uuid/mykey', { - method: 'POST', - headers: { - 'x-namespace-password': 'password' - }, - body: JSON.stringify({ data: 'value' }) -}); -``` - -**新方式(选项1 - UUID)**: -```javascript -fetch('/device/device-uuid/kv/mykey', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - password: 'password', - data: 'value' - }) -}); -``` - -**新方式(选项2 - Token,推荐)**: -```javascript -// 先获取token -const authResponse = await fetch('/apps/1/authorize', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - deviceUuid: 'device-uuid', - password: 'password' - }) -}); -const { token } = await authResponse.json(); - -// 使用token操作 -fetch('/kv/mykey', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ data: 'value' }) -}); -``` - ---- - -## 技术细节 - -### 密码存储 -密码使用 `bcrypt` 进行哈希处理,存储在 `Device.password` 字段。 - -**加密函数** (`utils/crypto.js`): -```javascript -import bcrypt from 'bcryptjs'; - -export async function hashDevicePassword(password) { - return await bcrypt.hash(password, 10); -} - -export async function verifyDevicePassword(password, hash) { - return await bcrypt.compare(password, hash); -} -``` - -### 性能优化 -- 使用整数ID (`deviceId`) 作为外键,查询效率高于字符串UUID -- 设备信息查询结果缓存在 `res.locals`,避免重复查询 -- 密码验证使用 bcrypt 的异步方法,不阻塞事件循环 - -### 安全考虑 -1. 密码使用 bcrypt 加密存储 -2. Token 使用 `cuid` 生成,具有高随机性 -3. 支持密码提示功能,不暴露实际密码 -4. 写操作强制密码验证(如果设置了密码) -5. 所有中间件使用 `errors.catchAsync` 包装,统一错误处理 - ---- - -## 参考 - -- [API重构文档](./API_REFACTOR.md) -- [Token认证示例](./token-auth-examples.md) -- [KV存储文档](./kv.md) -- [应用管理文档](./apps.md) \ No newline at end of file diff --git a/docs/token-auth-examples.md b/docs/token-auth-examples.md deleted file mode 100644 index b32a569..0000000 --- a/docs/token-auth-examples.md +++ /dev/null @@ -1,214 +0,0 @@ -# Token认证系统使用示例 - -本文档展示了如何使用重构后的基于Token的认证系统。 - -## 1. 基本Token认证 - -### 路由配置示例 - -```javascript -import express from 'express'; -import { - tokenOnlyAuthMiddleware, - tokenOnlyReadAuthMiddleware, - tokenOnlyWriteAuthMiddleware -} from './middleware/auth.js'; - -const router = express.Router(); - -// 需要完整认证的接口 -router.use('/secure', tokenOnlyAuthMiddleware); -router.get('/secure/profile', (req, res) => { - // res.locals.device, res.locals.appInstall, res.locals.app 已可用 - res.json({ - device: res.locals.device, - app: res.locals.app - }); -}); - -// 只读接口 -router.get('/data/:key', tokenOnlyReadAuthMiddleware, (req, res) => { - // 处理读取逻辑 - res.json({ key: req.params.key, value: 'some-value' }); -}); - -// 写入接口 -router.post('/data/:key', tokenOnlyWriteAuthMiddleware, (req, res) => { - // 处理写入逻辑 - res.json({ success: true, key: req.params.key }); -}); -``` - -## 2. 客户端请求示例 - -### 通过HTTP Header传递Token - -```javascript -// 使用fetch -fetch('/api/secure/profile', { - headers: { - 'x-app-token': 'your-app-token-here', - 'Content-Type': 'application/json' - } -}) -.then(response => response.json()) -.then(data => console.log(data)); - -// 使用axios -axios.get('/api/secure/profile', { - headers: { - 'x-app-token': 'your-app-token-here' - } -}); -``` - -### 通过查询参数传递Token - -```javascript -// GET请求 -fetch('/api/data/mykey?apptoken=your-app-token-here') - .then(response => response.json()) - .then(data => console.log(data)); -``` - -### 通过请求体传递Token - -```javascript -// POST请求 -fetch('/api/data/mykey', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - apptoken: 'your-app-token-here', - value: 'new-value' - }) -}); -``` - -## 3. 错误处理 - -### 常见错误响应 - -```json -// 缺少Token -{ - "statusCode": 401, - "message": "缺少应用访问令牌,请提供有效的token" -} - -// 无效Token -{ - "statusCode": 401, - "message": "无效的应用访问令牌" -} - -// 权限不足 -{ - "statusCode": 403, - "message": "应用令牌无权访问此命名空间" -} -``` - -### 客户端错误处理示例 - -```javascript -async function apiRequest(url, options = {}) { - try { - const response = await fetch(url, { - ...options, - headers: { - 'x-app-token': 'your-app-token-here', - 'Content-Type': 'application/json', - ...options.headers - } - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(`API错误 ${error.statusCode}: ${error.message}`); - } - - return await response.json(); - } catch (error) { - console.error('API请求失败:', error.message); - throw error; - } -} - -// 使用示例 -try { - const data = await apiRequest('/api/secure/profile'); - console.log('用户数据:', data); -} catch (error) { - // 处理认证错误 - if (error.message.includes('401')) { - // 重新获取token或跳转到登录页 - } -} -``` - -## 4. 迁移指南 - -### 从UUID认证迁移到Token认证 - -```javascript -// 旧的UUID认证方式(已弃用) -router.get('/data/:namespace/:key', authMiddleware, (req, res) => { - // 使用req.params.namespace作为设备标识 -}); - -// 新的Token认证方式(推荐) -router.get('/data/:key', tokenOnlyReadAuthMiddleware, (req, res) => { - // 设备信息通过token自动获取,存储在res.locals.device中 - const deviceUuid = res.locals.device.uuid; -}); -``` - -### 客户端迁移 - -```javascript -// 旧方式:使用UUID和密码 -fetch('/api/data/device-uuid-123/mykey', { - headers: { - 'x-namespace-password': 'device-password' - } -}); - -// 新方式:使用Token -fetch('/api/data/mykey', { - headers: { - 'x-app-token': 'app-token-from-installation' - } -}); -``` - -## 5. 最佳实践 - -1. **优先使用Token认证**:新项目应该直接使用`tokenOnlyAuthMiddleware`等纯Token认证中间件 - -2. **安全存储Token**:在客户端安全存储应用Token,避免在URL中暴露 - -3. **错误处理**:实现完善的错误处理机制,特别是认证失败的情况 - -4. **Token刷新**:实现Token过期和刷新机制(如果需要) - -5. **日志记录**:记录认证相关的操作日志,便于调试和安全审计 - -## 6. 权限前缀系统 - -Token认证系统支持基于前缀的权限控制: - -```javascript -// 应用只能访问以特定前缀开头的键 -// 例如:app.permissionPrefix = "myapp" -// 则只能访问 "myapp.config", "myapp.data" 等键 - -// 使用appReadAuthMiddleware自动进行前缀检查 -router.get('/kv/:key', appReadAuthMiddleware, (req, res) => { - // 自动检查req.params.key是否符合权限前缀 -}); -``` - -这个系统提供了更安全、更灵活的认证机制,建议所有新项目都采用Token认证方式。 \ No newline at end of file diff --git a/kv-admin/index.html b/kv-admin/index.html index 2584da2..98d8148 100644 --- a/kv-admin/index.html +++ b/kv-admin/index.html @@ -4,7 +4,7 @@ - KV 服务授权管理 + Classworks KV
diff --git a/kv-admin/package.json b/kv-admin/package.json index 2261cca..e1863aa 100644 --- a/kv-admin/package.json +++ b/kv-admin/package.json @@ -20,6 +20,7 @@ "lucide-react": "^0.544.0", "lucide-vue-next": "^0.544.0", "marked": "^16.3.0", + "pinia": "^3.0.3", "radix-vue": "^1.9.17", "reka-ui": "^2.5.1", "tailwind-merge": "^3.3.1", diff --git a/kv-admin/pnpm-lock.yaml b/kv-admin/pnpm-lock.yaml index 6153697..b47b14d 100644 --- a/kv-admin/pnpm-lock.yaml +++ b/kv-admin/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: marked: specifier: ^16.3.0 version: 16.3.0 + pinia: + specifier: ^3.0.3 + version: 3.0.3(vue@3.5.22) radix-vue: specifier: ^1.9.17 version: 1.9.17(vue@3.5.22) @@ -1573,6 +1576,15 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pinia@3.0.3: + resolution: {integrity: sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -3460,6 +3472,11 @@ snapshots: picomatch@4.0.3: {} + pinia@3.0.3(vue@3.5.22): + dependencies: + '@vue/devtools-api': 7.7.7 + vue: 3.5.22 + pkg-types@1.3.1: dependencies: confbox: 0.1.8 diff --git a/kv-admin/src/App.vue b/kv-admin/src/App.vue index a78fb6f..0270f57 100644 --- a/kv-admin/src/App.vue +++ b/kv-admin/src/App.vue @@ -1,7 +1,10 @@ diff --git a/kv-admin/src/components/AppCard.vue b/kv-admin/src/components/AppCard.vue index 0d5256e..472c5f6 100644 --- a/kv-admin/src/components/AppCard.vue +++ b/kv-admin/src/components/AppCard.vue @@ -61,7 +61,7 @@ const renderedReadme = computed(() => { // 获取应用信息 const fetchApp = async () => { try { - app.value = await axios.get(`/apps/${props.appId}`); + app.value = await axios.get(`/apps/info/${props.appId}`); if (app.value.repositoryUrl) { await fetchReadme(); diff --git a/kv-admin/src/components/DeviceRegisterDialog.vue b/kv-admin/src/components/DeviceRegisterDialog.vue new file mode 100644 index 0000000..be9e136 --- /dev/null +++ b/kv-admin/src/components/DeviceRegisterDialog.vue @@ -0,0 +1,400 @@ + + + diff --git a/kv-admin/src/components/EditDeviceNameDialog.vue b/kv-admin/src/components/EditDeviceNameDialog.vue new file mode 100644 index 0000000..76d34e4 --- /dev/null +++ b/kv-admin/src/components/EditDeviceNameDialog.vue @@ -0,0 +1,136 @@ + + + diff --git a/kv-admin/src/components/LoginDialog.vue b/kv-admin/src/components/LoginDialog.vue new file mode 100644 index 0000000..b106ed1 --- /dev/null +++ b/kv-admin/src/components/LoginDialog.vue @@ -0,0 +1,220 @@ + + + \ No newline at end of file diff --git a/kv-admin/src/components/PasswordInput.vue b/kv-admin/src/components/PasswordInput.vue new file mode 100644 index 0000000..55e5657 --- /dev/null +++ b/kv-admin/src/components/PasswordInput.vue @@ -0,0 +1,262 @@ + + + + + \ No newline at end of file diff --git a/kv-admin/src/components/ResetDevicePasswordDialog.vue b/kv-admin/src/components/ResetDevicePasswordDialog.vue new file mode 100644 index 0000000..e7ef977 --- /dev/null +++ b/kv-admin/src/components/ResetDevicePasswordDialog.vue @@ -0,0 +1,274 @@ + + + diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialog.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialog.vue new file mode 100644 index 0000000..266f5c7 --- /dev/null +++ b/kv-admin/src/components/ui/alert-dialog/AlertDialog.vue @@ -0,0 +1,17 @@ + + + diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialogAction.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialogAction.vue new file mode 100644 index 0000000..98526b9 --- /dev/null +++ b/kv-admin/src/components/ui/alert-dialog/AlertDialogAction.vue @@ -0,0 +1,23 @@ + + + diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialogCancel.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialogCancel.vue new file mode 100644 index 0000000..1c7fd6d --- /dev/null +++ b/kv-admin/src/components/ui/alert-dialog/AlertDialogCancel.vue @@ -0,0 +1,25 @@ + + + diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialogContent.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialogContent.vue new file mode 100644 index 0000000..54237cf --- /dev/null +++ b/kv-admin/src/components/ui/alert-dialog/AlertDialogContent.vue @@ -0,0 +1,51 @@ + + + diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialogDescription.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialogDescription.vue new file mode 100644 index 0000000..6617af9 --- /dev/null +++ b/kv-admin/src/components/ui/alert-dialog/AlertDialogDescription.vue @@ -0,0 +1,23 @@ + + + diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialogFooter.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialogFooter.vue new file mode 100644 index 0000000..c216b25 --- /dev/null +++ b/kv-admin/src/components/ui/alert-dialog/AlertDialogFooter.vue @@ -0,0 +1,18 @@ + + + diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialogHeader.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialogHeader.vue new file mode 100644 index 0000000..00c5aaf --- /dev/null +++ b/kv-admin/src/components/ui/alert-dialog/AlertDialogHeader.vue @@ -0,0 +1,16 @@ + + + diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialogTitle.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialogTitle.vue new file mode 100644 index 0000000..5c48c2d --- /dev/null +++ b/kv-admin/src/components/ui/alert-dialog/AlertDialogTitle.vue @@ -0,0 +1,23 @@ + + + diff --git a/kv-admin/src/components/ui/alert-dialog/AlertDialogTrigger.vue b/kv-admin/src/components/ui/alert-dialog/AlertDialogTrigger.vue new file mode 100644 index 0000000..2d546bd --- /dev/null +++ b/kv-admin/src/components/ui/alert-dialog/AlertDialogTrigger.vue @@ -0,0 +1,14 @@ + + + diff --git a/kv-admin/src/components/ui/alert-dialog/index.js b/kv-admin/src/components/ui/alert-dialog/index.js new file mode 100644 index 0000000..c48e47b --- /dev/null +++ b/kv-admin/src/components/ui/alert-dialog/index.js @@ -0,0 +1,9 @@ +export { default as AlertDialog } from "./AlertDialog.vue"; +export { default as AlertDialogAction } from "./AlertDialogAction.vue"; +export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"; +export { default as AlertDialogContent } from "./AlertDialogContent.vue"; +export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"; +export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"; +export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"; +export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"; +export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"; diff --git a/kv-admin/src/components/ui/checkbox/Checkbox.vue b/kv-admin/src/components/ui/checkbox/Checkbox.vue new file mode 100644 index 0000000..32519de --- /dev/null +++ b/kv-admin/src/components/ui/checkbox/Checkbox.vue @@ -0,0 +1,46 @@ + + + diff --git a/kv-admin/src/components/ui/checkbox/index.js b/kv-admin/src/components/ui/checkbox/index.js new file mode 100644 index 0000000..75be342 --- /dev/null +++ b/kv-admin/src/components/ui/checkbox/index.js @@ -0,0 +1 @@ +export { default as Checkbox } from "./Checkbox.vue"; diff --git a/kv-admin/src/components/ui/dropdown-menu/DropdownItem.vue b/kv-admin/src/components/ui/dropdown-menu/DropdownItem.vue new file mode 100644 index 0000000..48e8abf --- /dev/null +++ b/kv-admin/src/components/ui/dropdown-menu/DropdownItem.vue @@ -0,0 +1,22 @@ + + + \ No newline at end of file diff --git a/kv-admin/src/components/ui/dropdown-menu/DropdownMenu.vue b/kv-admin/src/components/ui/dropdown-menu/DropdownMenu.vue new file mode 100644 index 0000000..86333fb --- /dev/null +++ b/kv-admin/src/components/ui/dropdown-menu/DropdownMenu.vue @@ -0,0 +1,62 @@ + + + \ No newline at end of file diff --git a/kv-admin/src/components/ui/separator/Separator.vue b/kv-admin/src/components/ui/separator/Separator.vue new file mode 100644 index 0000000..680a1d2 --- /dev/null +++ b/kv-admin/src/components/ui/separator/Separator.vue @@ -0,0 +1,28 @@ + + + diff --git a/kv-admin/src/components/ui/separator/index.js b/kv-admin/src/components/ui/separator/index.js new file mode 100644 index 0000000..aae7f1a --- /dev/null +++ b/kv-admin/src/components/ui/separator/index.js @@ -0,0 +1 @@ +export { default as Separator } from "./Separator.vue"; diff --git a/kv-admin/src/components/ui/sonner/Sonner.vue b/kv-admin/src/components/ui/sonner/Sonner.vue new file mode 100644 index 0000000..8802e9a --- /dev/null +++ b/kv-admin/src/components/ui/sonner/Sonner.vue @@ -0,0 +1,39 @@ + + + diff --git a/kv-admin/src/components/ui/sonner/index.js b/kv-admin/src/components/ui/sonner/index.js new file mode 100644 index 0000000..39a59dd --- /dev/null +++ b/kv-admin/src/components/ui/sonner/index.js @@ -0,0 +1 @@ +export { default as Toaster } from "./Sonner.vue"; diff --git a/kv-admin/src/components/ui/tabs/Tabs.vue b/kv-admin/src/components/ui/tabs/Tabs.vue new file mode 100644 index 0000000..31ba139 --- /dev/null +++ b/kv-admin/src/components/ui/tabs/Tabs.vue @@ -0,0 +1,31 @@ + + + diff --git a/kv-admin/src/components/ui/tabs/TabsContent.vue b/kv-admin/src/components/ui/tabs/TabsContent.vue new file mode 100644 index 0000000..b9331fd --- /dev/null +++ b/kv-admin/src/components/ui/tabs/TabsContent.vue @@ -0,0 +1,25 @@ + + + diff --git a/kv-admin/src/components/ui/tabs/TabsList.vue b/kv-admin/src/components/ui/tabs/TabsList.vue new file mode 100644 index 0000000..791ab3d --- /dev/null +++ b/kv-admin/src/components/ui/tabs/TabsList.vue @@ -0,0 +1,29 @@ + + + diff --git a/kv-admin/src/components/ui/tabs/TabsTrigger.vue b/kv-admin/src/components/ui/tabs/TabsTrigger.vue new file mode 100644 index 0000000..03e10df --- /dev/null +++ b/kv-admin/src/components/ui/tabs/TabsTrigger.vue @@ -0,0 +1,32 @@ + + + diff --git a/kv-admin/src/components/ui/tabs/index.js b/kv-admin/src/components/ui/tabs/index.js new file mode 100644 index 0000000..3b3741b --- /dev/null +++ b/kv-admin/src/components/ui/tabs/index.js @@ -0,0 +1,4 @@ +export { default as Tabs } from "./Tabs.vue"; +export { default as TabsContent } from "./TabsContent.vue"; +export { default as TabsList } from "./TabsList.vue"; +export { default as TabsTrigger } from "./TabsTrigger.vue"; diff --git a/kv-admin/src/composables/useOAuthCallback.js b/kv-admin/src/composables/useOAuthCallback.js new file mode 100644 index 0000000..f01d598 --- /dev/null +++ b/kv-admin/src/composables/useOAuthCallback.js @@ -0,0 +1,126 @@ +import { onMounted, onUnmounted } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { useAccountStore } from '@/stores/account' +import { toast } from 'vue-sonner' + +/** + * 处理OAuth回调 + * 检查URL参数中是否有OAuth回调信息 + */ +export function useOAuthCallback() { + const route = useRoute() + const router = useRouter() + const accountStore = useAccountStore() + + const handleOAuthCallback = async () => { + const { token, provider, success, error } = route.query + + // 检查是否是OAuth回调 + if (!success && !error) { + return + } + + // 处理成功回调 + if (success === 'true' && token) { + try { + // 保存token到localStorage + localStorage.setItem('auth_token', token) + localStorage.setItem('auth_provider', provider) + + // 登录到store + await accountStore.login(token) + + // 显示成功提示 + toast.success('登录成功', { + description: `已通过 ${provider} 登录` + }) + + // 清除URL参数 + router.replace({ query: {} }) + + // 触发storage事件,通知其他窗口 + window.dispatchEvent(new StorageEvent('storage', { + key: 'auth_token', + newValue: token, + url: window.location.href + })) + + // 如果是在新窗口中打开的OAuth回调,自动关闭窗口 + if (window.opener) { + // 通知父窗口登录成功 + window.opener.postMessage({ + type: 'oauth_success', + token, + provider + }, window.location.origin) + + // 延迟关闭窗口,确保消息已发送 + setTimeout(() => { + window.close() + }, 1000) + } + + } catch (err) { + toast.error('登录失败', { + description: err.message || '处理登录信息时出错' + }) + } + } + + // 处理错误回调 + if (success === 'false' || error) { + const errorMessages = { + 'invalid_state': 'State验证失败,可能存在安全风险', + 'access_denied': '用户拒绝了授权请求', + 'temporarily_unavailable': '服务暂时不可用,请稍后重试' + } + + const errorMsg = errorMessages[error] || error || '登录过程中出现错误' + + toast.error('登录失败', { + description: errorMsg + }) + + // 清除URL参数 + router.replace({ query: {} }) + + // 如果是在新窗口中打开的OAuth回调,自动关闭窗口 + if (window.opener) { + // 通知父窗口登录失败 + window.opener.postMessage({ + type: 'oauth_error', + error: errorMsg + }, window.location.origin) + + // 延迟关闭窗口 + setTimeout(() => { + window.close() + }, 1000) + } + } + } + + onMounted(() => { + handleOAuthCallback() + }) + + // 监听storage事件,处理其他标签页的登录 + const handleStorageChange = (e) => { + if (e.key === 'auth_token' && e.newValue) { + // 其他标签页已登录,刷新当前页面的状态 + accountStore.login(e.newValue) + } + } + + onMounted(() => { + window.addEventListener('storage', handleStorageChange) + }) + + onUnmounted(() => { + window.removeEventListener('storage', handleStorageChange) + }) + + return { + handleOAuthCallback + } +} \ No newline at end of file diff --git a/kv-admin/src/lib/api.js b/kv-admin/src/lib/api.js index 4e553c7..cc03606 100644 --- a/kv-admin/src/lib/api.js +++ b/kv-admin/src/lib/api.js @@ -31,6 +31,23 @@ class ApiClient { return response.json() } + // 带认证的fetch + async authenticatedFetch(endpoint, options = {}, token = null) { + const headers = { + ...options.headers, + } + + // 如果提供了token,添加Authorization头 + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + + return this.fetch(endpoint, { + ...options, + headers, + }) + } + // 应用相关 API async getApps(params = {}) { const query = new URLSearchParams(params).toString() @@ -38,53 +55,177 @@ class ApiClient { } async getApp(appId) { - return this.fetch(`/apps/${appId}`) + return this.fetch(`/apps/info/${appId}`) } - async getAppInstallations(appId, params = {}) { + async getAppInstallations(appId, deviceUuid, params = {}) { const query = new URLSearchParams(params).toString() - return this.fetch(`/apps/${appId}/installations${query ? `?${query}` : ''}`) - } - - // 授权相关 API - async authorizeApp(appId, data) { - return this.fetch(`/apps/${appId}/authorize`, { - method: 'POST', - body: JSON.stringify(data), + return this.fetch(`/apps/info/${appId}/device-installations${query ? `?${query}` : ''}`, { + headers: { + 'x-device-uuid': deviceUuid, + }, }) } // Token 管理 API - async getDeviceTokens(deviceUuid) { - return this.fetch(`/apps/devices/${deviceUuid}/tokens`) + async getDeviceTokens(deviceUuid, options = {}) { + const params = new URLSearchParams({ + uuid: deviceUuid, + }); + + return this.fetch(`/apps/tokens?${params}`); } - async revokeToken(token) { - return this.fetch(`/apps/tokens/${token}`, { + async revokeToken(targetToken, authOptions = {}) { + const { deviceUuid, password, usePathParam = true, bearerToken } = authOptions; + + if (usePathParam) { + // 使用路径参数方式 (推荐) + const headers = {}; + + if (bearerToken) { + headers['Authorization'] = `Bearer ${bearerToken}`; + } else if (deviceUuid) { + headers['x-device-uuid'] = deviceUuid; + if (password) { + headers['x-device-password'] = password; + } + } + + return this.fetch(`/apps/tokens/${targetToken}`, { + method: 'DELETE', + headers, + }); + } else { + // 使用查询参数方式 (向后兼容) + const params = new URLSearchParams({ token: targetToken }); + const headers = {}; + + if (bearerToken) { + headers['Authorization'] = `Bearer ${bearerToken}`; + } else if (deviceUuid) { + headers['x-device-uuid'] = deviceUuid; + if (password) { + headers['x-device-password'] = password; + } + } + + return this.fetch(`/apps/tokens?${params}`, { + method: 'DELETE', + headers, + }); + } + } + + // 应用安装接口 (对应后端的 /apps/devices/:uuid/install/:appId) + async authorizeApp(appId, deviceUuid, options = {}) { + const { password, note, token } = options; + + const headers = { + 'x-device-uuid': deviceUuid, + }; + + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + // 使用新的安装接口 + return this.fetch(`/apps/devices/${deviceUuid}/install/${appId}?password=${password}`, { + method: 'POST', + headers, + body: JSON.stringify({ note: note || '应用授权' }), + }); + } + + // 设备级别的应用卸载,使用新的 uninstall 接口 + async revokeDeviceToken(deviceUuid, installId, password = null, token = null) { + const params = new URLSearchParams({ uuid: deviceUuid }); + const headers = {}; + + if (password) { + params.set('password', password); + } + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + return this.fetch(`/apps/devices/${deviceUuid}/uninstall/${installId}?${params}`, { method: 'DELETE', - }) + headers, + }); } // 设备密码管理 API - async setDevicePassword(deviceUuid, data) { - return this.fetch(`/apps/devices/${deviceUuid}/password`, { - method: 'PUT', - body: JSON.stringify(data), - }) + async setDevicePassword(deviceUuid, data, token = null) { + const { newPassword, currentPassword, passwordHint } = data; + + // 检查设备是否已设置密码 + const deviceInfo = await this.getDeviceInfo(deviceUuid); + const hasPassword = deviceInfo.hasPassword; + + if (hasPassword) { + // 使用PUT修改密码 + const params = new URLSearchParams(); + params.set('uuid', deviceUuid); + params.set('newPassword', newPassword); + if (currentPassword) { + params.set('currentPassword', currentPassword); + } + if (passwordHint !== undefined) { + params.set('passwordHint', passwordHint); + } + + const headers = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + return this.fetch(`/devices/${deviceUuid}/password?${params}`, { + method: 'PUT', + headers, + }); + } else { + // 使用POST初次设置密码 + const params = new URLSearchParams(); + params.set('newPassword', newPassword); + if (passwordHint !== undefined) { + params.set('passwordHint', passwordHint); + } + + return this.fetch(`/devices/${deviceUuid}/password?${params}`, { + method: 'POST', + }); + } } - async deleteDevicePassword(deviceUuid, password) { - return this.fetch(`/apps/devices/${deviceUuid}/password`, { + async deleteDevicePassword(deviceUuid, password, token = null) { + const params = new URLSearchParams({ uuid: deviceUuid }); + const headers = {}; + + // 如果提供了账户token,使用JWT认证 + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } else if (password) { + params.set('password', password); + } + + return this.fetch(`/devices/${deviceUuid}/password?${params}`, { method: 'DELETE', - body: JSON.stringify({ password }), - }) + headers, + }); } - async verifyDevicePassword(deviceUuid, password) { - return this.fetch(`/apps/devices/${deviceUuid}/password/verify`, { - method: 'POST', - body: JSON.stringify({ password }), - }) + async setDevicePasswordHint(deviceUuid, hint, password = null, token = null) { + return this.authenticatedFetch(`/devices/${deviceUuid}/password-hint`, { + method: 'PUT', + body: JSON.stringify({ hint, password }), + }, token) + } + + async getDevicePasswordHint(deviceUuid) { + return this.fetch(`/devices/${deviceUuid}/password-hint`) } // 设备授权相关 API @@ -98,6 +239,295 @@ class ApiClient { async getDeviceCodeStatus(deviceCode) { return this.fetch(`/auth/device/status?device_code=${deviceCode}`) } + + // KV 存储管理 API + async listKVItems(token, params = {}) { + const query = new URLSearchParams(params).toString() + return this.fetch(`/kv${query ? `?${query}` : ''}`, { + headers: { 'x-app-token': token } + }) + } + + async getKVItem(token, key) { + return this.fetch(`/kv/${encodeURIComponent(key)}`, { + headers: { 'x-app-token': token } + }) + } + + async setKVItem(token, key, value) { + return this.fetch(`/kv/${encodeURIComponent(key)}`, { + method: 'POST', + headers: { 'x-app-token': token }, + body: JSON.stringify(value), + }) + } + + async deleteKVItem(token, key) { + return this.fetch(`/kv/${encodeURIComponent(key)}`, { + method: 'DELETE', + headers: { 'x-app-token': token } + }) + } + + async getKVKeys(token, pattern = '*') { + return this.fetch(`/kv/_keys?pattern=${encodeURIComponent(pattern)}`, { + headers: { 'x-app-token': token } + }) + } + + // 设备信息 API + async getDeviceInfo(deviceUuid) { + return this.fetch(`/devices/${deviceUuid}`) + } + + // 获取设备应用列表 API (公开接口,无需认证) + async getDeviceApps(deviceUuid) { + return this.fetch(`/apps/devices/${deviceUuid}/apps`) + } + + // 密码提示管理 API + async getPasswordHint(deviceUuid) { + try { + const response = await this.fetch(`/devices/${deviceUuid}`) + return { hint: response.device?.passwordHint || '' } + } catch (error) { + // 如果接口不存在,返回空提示 + return { hint: '' } + } + } + + async setPasswordHint(deviceUuid, hint, password) { + try { + return await this.fetch(`/devices/${deviceUuid}/password-hint?password=${encodeURIComponent(password)}`, { + method: 'PUT', + headers: { + 'x-device-uuid': deviceUuid, + }, + body: JSON.stringify({ passwordHint: hint }), + }) + } catch (error) { + // 如果接口不存在,忽略错误 + console.log('Password hint API not available') + return { success: false } + } + } + + // 账户相关 API + async getOAuthProviders() { + return this.fetch('/accounts/oauth/providers') + } + + async getAccountProfile(token) { + return this.fetch('/accounts/profile', { + headers: { 'Authorization': `Bearer ${token}` } + }) + } + + async getAccountDevices(token) { + return this.fetch('/accounts/devices', { + headers: { 'Authorization': `Bearer ${token}` } + }) + } + + async bindDevice(token, deviceUuid) { + return this.fetch('/accounts/devices/bind', { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ uuid: deviceUuid }), + }) + } + + async unbindDevice(token, deviceUuid) { + return this.fetch('/accounts/devices/unbind', { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ uuid: deviceUuid }), + }) + } + + async getDeviceAccount(deviceUuid) { + return this.fetch(`/accounts/device/${deviceUuid}/account`) + } + + // 绑定设备到当前账户 + async bindDeviceToAccount(token, deviceUuid) { + return this.authenticatedFetch('/accounts/devices/bind', { + method: 'POST', + body: JSON.stringify({ uuid: deviceUuid }), + }, token) + } + + // 解绑设备 + async unbindDeviceFromAccount(token, deviceUuid) { + return this.authenticatedFetch('/accounts/devices/unbind', { + method: 'POST', + body: JSON.stringify({ uuid: deviceUuid }), + }, token) + } + + // 批量解绑设备 + async batchUnbindDevices(token, deviceUuids) { + return this.authenticatedFetch('/accounts/devices/unbind', { + method: 'POST', + body: JSON.stringify({ uuids: deviceUuids }), + }, token) + } + + // 设备名称管理 API + async setDeviceName(deviceUuid, name, password = null, token = null) { + const headers = { + 'x-device-uuid': deviceUuid, + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + if (password) { + headers['x-device-password'] = password; + } + + return this.fetch(`/devices/${deviceUuid}/name`, { + method: 'PUT', + headers, + body: JSON.stringify({ name }), + }); + } + + // 修改设备密码 API + async updateDevicePassword(deviceUuid, currentPassword, newPassword, passwordHint = null, token = null) { + const headers = { + 'x-device-uuid': deviceUuid, + }; + + // 如果提供了账户token,使用JWT认证(账户拥有者无需当前密码) + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } else if (currentPassword) { + headers['x-device-password'] = currentPassword; + } + + const body = { newPassword, passwordHint }; + // 只有在非账户拥有者时才需要发送当前密码 + if (!token && currentPassword) { + body.currentPassword = currentPassword; + } + + return this.fetch(`/devices/${deviceUuid}/password`, { + method: 'PUT', + headers, + body: JSON.stringify(body), + }); + } + + // 验证设备密码 API + async verifyDevicePassword(deviceUuid, password) { + return this.fetch(`/devices/${deviceUuid}`, { + method: 'GET', + headers: { + 'x-device-uuid': deviceUuid, + 'x-device-password': password, + }, + }); + } + + // 设备注册 API + async registerDevice(uuid, deviceName, token = null) { + return this.authenticatedFetch('/devices', { + method: 'POST', + body: JSON.stringify({ uuid, deviceName }), + }, token) + } + + // 账户拥有者重置设备密码 API + async resetDevicePasswordAsOwner(deviceUuid, newPassword, passwordHint = null, token) { + return this.fetch(`/devices/${deviceUuid}/password`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'x-device-uuid': deviceUuid, + }, + body: JSON.stringify({ newPassword, passwordHint }), + }); + } + + // 兼容性方法 - 保持旧的API调用方式 + async getTokens(deviceUuid, options = {}) { + return this.getDeviceTokens(deviceUuid, options); + } + + async deleteToken(targetToken, deviceUuid = null) { + // 向后兼容的删除方法 + return this.revokeToken(targetToken, { deviceUuid, usePathParam: true }); + } + + // 便捷方法:使用设备UUID和密码删除token + async revokeTokenByDevice(targetToken, deviceUuid, password = null) { + return this.revokeToken(targetToken, { + deviceUuid, + password, + usePathParam: true + }); + } + + // 便捷方法:使用账户token删除token + async revokeTokenByAccount(targetToken, bearerToken) { + return this.revokeToken(targetToken, { + bearerToken, + usePathParam: true + }); + } + + // 便捷方法:应用自撤销 + async revokeOwnToken(targetToken) { + return this.fetch(`/apps/tokens/${targetToken}`, { + method: 'DELETE', + headers: { + 'x-app-token': targetToken, + }, + }); + } + + // 新的便捷方法 + async getTokensWithAuth(authType, authValue, options = {}) { + const headers = {}; + const params = new URLSearchParams(options); + + switch (authType) { + case 'uuid': + headers['x-device-uuid'] = authValue; + params.set('uuid', authValue); + break; + case 'token': + headers['x-app-token'] = authValue; + break; + case 'bearer': + headers['Authorization'] = `Bearer ${authValue}`; + break; + } + + return this.fetch(`/apps/tokens?${params}`, { headers }); + } + + async revokeTokenWithAuth(targetToken, authType, authValue) { + const headers = {}; + const params = new URLSearchParams({ token: targetToken }); + + switch (authType) { + case 'uuid': + headers['x-device-uuid'] = authValue; + break; + case 'token': + headers['x-app-token'] = authValue; + break; + case 'bearer': + headers['Authorization'] = `Bearer ${authValue}`; + break; + } + + return this.fetch(`/apps/tokens?${params}`, { + method: 'DELETE', + headers, + }); + } } export const apiClient = new ApiClient(API_BASE_URL, SITE_KEY) diff --git a/kv-admin/src/lib/deviceStore.js b/kv-admin/src/lib/deviceStore.js index 3ad3308..7877e02 100644 --- a/kv-admin/src/lib/deviceStore.js +++ b/kv-admin/src/lib/deviceStore.js @@ -7,16 +7,64 @@ export function generateUUID() { }) } -// 设备 UUID 管理 +// 设备 UUID 管理 - 使用多种缓存策略确保UUID不丢失 export const deviceStore = { - // 获取当前设备 UUID + // 存储键名 + STORAGE_KEY: 'device_uuid', + BACKUP_KEY: 'device_uuid_backup', + SESSION_KEY: 'device_uuid_session', + + // 获取当前设备 UUID(从多个存储位置尝试读取) getDeviceUuid() { - return localStorage.getItem('device_uuid') + // 1. 首先从 localStorage 获取 + let uuid = localStorage.getItem(this.STORAGE_KEY) + + // 2. 如果没有,尝试从备份位置获取 + if (!uuid) { + uuid = localStorage.getItem(this.BACKUP_KEY) + if (uuid) { + // 恢复到主存储位置 + this.setDeviceUuid(uuid) + } + } + + // 3. 如果还没有,尝试从 sessionStorage 获取 + if (!uuid) { + uuid = sessionStorage.getItem(this.SESSION_KEY) + if (uuid) { + // 恢复到主存储位置 + this.setDeviceUuid(uuid) + } + } + + // 4. 如果还没有,尝试从 cookie 获取(如果有的话) + if (!uuid) { + uuid = this.getFromCookie() + if (uuid) { + // 恢复到所有存储位置 + this.setDeviceUuid(uuid) + } + } + + return uuid }, - // 设置设备 UUID + // 设置设备 UUID(同时存储到多个位置) setDeviceUuid(uuid) { - localStorage.setItem('device_uuid', uuid) + // 1. 存储到 localStorage 主位置 + localStorage.setItem(this.STORAGE_KEY, uuid) + + // 2. 存储到备份位置 + localStorage.setItem(this.BACKUP_KEY, uuid) + + // 3. 存储到 sessionStorage + sessionStorage.setItem(this.SESSION_KEY, uuid) + + // 4. 存储到 cookie(设置较长的过期时间) + this.saveToCookie(uuid) + + // 5. 尝试存储到 IndexedDB(异步) + this.saveToIndexedDB(uuid) }, // 生成并保存新的设备 UUID @@ -31,26 +79,113 @@ export const deviceStore = { let uuid = this.getDeviceUuid() if (!uuid) { uuid = this.generateAndSave() + } else { + // 确保UUID被保存到所有位置 + this.setDeviceUuid(uuid) } return uuid }, - // 清除设备 UUID + // 清除设备 UUID(从所有存储位置清除) clear() { - localStorage.removeItem('device_uuid') - localStorage.removeItem('device_password') + localStorage.removeItem(this.STORAGE_KEY) + localStorage.removeItem(this.BACKUP_KEY) + sessionStorage.removeItem(this.SESSION_KEY) + this.clearCookie() + this.clearIndexedDB() }, - // 设备密码管理 - hasPassword() { - return localStorage.getItem('device_password') === 'true' + // Cookie 操作 + saveToCookie(uuid) { + try { + const expires = new Date() + expires.setFullYear(expires.getFullYear() + 10) // 10年过期 + document.cookie = `device_uuid=${uuid}; expires=${expires.toUTCString()}; path=/; SameSite=Strict` + } catch (e) { + console.log('Failed to save UUID to cookie:', e) + } }, - setHasPassword(hasPassword) { - if (hasPassword) { - localStorage.setItem('device_password', 'true') - } else { - localStorage.removeItem('device_password') + getFromCookie() { + try { + const match = document.cookie.match(/(?:^|; )device_uuid=([^;]*)/) + return match ? match[1] : null + } catch (e) { + console.log('Failed to get UUID from cookie:', e) + return null + } + }, + + clearCookie() { + try { + document.cookie = 'device_uuid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;' + } catch (e) { + console.log('Failed to clear UUID cookie:', e) + } + }, + + // IndexedDB 操作(异步,作为额外的备份) + async saveToIndexedDB(uuid) { + try { + const db = await this.openDB() + const transaction = db.transaction(['device'], 'readwrite') + const store = transaction.objectStore('device') + await store.put({ id: 'uuid', value: uuid }) + } catch (e) { + console.log('Failed to save UUID to IndexedDB:', e) + } + }, + + async getFromIndexedDB() { + try { + const db = await this.openDB() + const transaction = db.transaction(['device'], 'readonly') + const store = transaction.objectStore('device') + const result = await store.get('uuid') + return result?.value || null + } catch (e) { + console.log('Failed to get UUID from IndexedDB:', e) + return null + } + }, + + async clearIndexedDB() { + try { + const db = await this.openDB() + const transaction = db.transaction(['device'], 'readwrite') + const store = transaction.objectStore('device') + await store.delete('uuid') + } catch (e) { + console.log('Failed to clear UUID from IndexedDB:', e) + } + }, + + openDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open('ClassworksKV', 1) + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve(request.result) + + request.onupgradeneeded = (event) => { + const db = event.target.result + if (!db.objectStoreNames.contains('device')) { + db.createObjectStore('device', { keyPath: 'id' }) + } + } + }) + }, + + // 尝试从 IndexedDB 恢复 UUID(在初始化时调用) + async tryRestoreFromIndexedDB() { + const uuid = await this.getFromIndexedDB() + if (uuid && !this.getDeviceUuid()) { + this.setDeviceUuid(uuid) } } } + +// 在页面加载时尝试从 IndexedDB 恢复 +if (typeof window !== 'undefined') { + deviceStore.tryRestoreFromIndexedDB() +} diff --git a/kv-admin/src/main.js b/kv-admin/src/main.js index 8b4ea5e..7389876 100644 --- a/kv-admin/src/main.js +++ b/kv-admin/src/main.js @@ -1,11 +1,26 @@ import { createApp } from 'vue' import { createRouter, createWebHistory } from 'vue-router' +import { createPinia } from 'pinia' import { routes } from 'vue-router/auto-routes' import { tokenStore } from './lib/tokenStore' +import { deviceStore } from './lib/deviceStore' import './style.css' import App from './App.vue' +// 检查 URL 参数中的 uuid 并设置到本地存储 +const urlParams = new URLSearchParams(window.location.search) +const urlUuid = urlParams.get('uuid') +if (urlUuid) { + deviceStore.setDeviceUuid(urlUuid) + // 清除 URL 中的 uuid 参数 + urlParams.delete('uuid') + const newUrl = urlParams.toString() + ? `${window.location.pathname}?${urlParams.toString()}` + : window.location.pathname + window.history.replaceState({}, '', newUrl) +} +const pinia = createPinia() const router = createRouter({ history: createWebHistory(), @@ -24,4 +39,7 @@ router.beforeEach((to, _from, next) => { } }) -createApp(App).use(router).mount('#app') +const app = createApp(App) +app.use(pinia) +app.use(router) +app.mount('#app') diff --git a/kv-admin/src/pages/authorize.vue b/kv-admin/src/pages/authorize.vue index e1c3379..02ba687 100644 --- a/kv-admin/src/pages/authorize.vue +++ b/kv-admin/src/pages/authorize.vue @@ -3,16 +3,21 @@ import { ref, computed, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { apiClient } from '@/lib/api' import { deviceStore } from '@/lib/deviceStore' +import { useAccountStore } from '@/stores/account' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' -import { CheckCircle2, XCircle, Loader2, Shield, Key, AlertCircle } from 'lucide-vue-next' +import { CheckCircle2, XCircle, Loader2, Shield, Key, AlertCircle, User, Plus, Check } from 'lucide-vue-next' import AppCard from '@/components/AppCard.vue' +import PasswordInput from '@/components/PasswordInput.vue' +import LoginDialog from '@/components/LoginDialog.vue' +import { toast } from 'vue-sonner' const route = useRoute() const router = useRouter() +const accountStore = useAccountStore() // URL 参数 const appId = ref(route.query.app_id || '') @@ -24,7 +29,14 @@ const callbackUrl = ref(route.query.callback_url || '') const step = ref('input') // 'input' | 'loading' | 'success' | 'error' const errorMessage = ref('') const deviceUuid = ref('') -const hasPassword = ref(false) +const deviceInfo = ref(null) +const deviceAccount = ref(null) +const showLoginDialog = ref(false) +const showDeviceList = ref(false) +const customDeviceUuid = ref('') + +// 计算属性获取是否有密码 +const hasPassword = computed(() => deviceInfo.value?.hasPassword || false) // 表单数据 const inputDeviceCode = ref('') @@ -50,6 +62,73 @@ const loadAppInfo = async () => { } } +// 加载设备账户信息 +const loadDeviceAccount = async () => { + if (!deviceUuid.value) return + + try { + const response = await apiClient.getDeviceAccount(deviceUuid.value) + deviceAccount.value = response.data + } catch (error) { + console.log('Failed to load device account:', error) + deviceAccount.value = null + } +} + +// 选择设备UUID +const selectDevice = async (uuid) => { + deviceUuid.value = uuid + deviceStore.setUuid(uuid) + showDeviceList.value = false + customDeviceUuid.value = '' + + // 重新加载设备信息 + await loadDeviceInfo() + await loadDeviceAccount() +} + +// 使用自定义UUID +const useCustomUuid = () => { + if (!customDeviceUuid.value) return + + selectDevice(customDeviceUuid.value) +} + +// 一键绑定当前设备 +const bindCurrentDevice = async () => { + if (!accountStore.isAuthenticated || !deviceUuid.value) return + + try { + await accountStore.bindDevice(deviceUuid.value) + await loadDeviceAccount() + toast.success('绑定成功', { + description: '设备已绑定到您的账户' + }) + } catch (error) { + toast.error('绑定失败', { + description: error.message || '无法绑定设备' + }) + } +} + +// 登录成功回调 +const handleLoginSuccess = async (token) => { + showLoginDialog.value = false + await accountStore.login(token) + await loadDeviceAccount() + + // 如果当前设备未绑定,提示是否绑定 + if (!deviceAccount.value) { + toast('登录成功', { + description: '您可以将当前设备绑定到账户', + action: { + label: '立即绑定', + onClick: bindCurrentDevice, + }, + }) + } +} + // 授权应用并绑定到设备代码 const authorizeWithDeviceCode = async () => { if (!currentDeviceCode.value || !deviceUuid.value) return @@ -60,7 +139,6 @@ const authorizeWithDeviceCode = async () => { try { // 1. 授权应用并获取 token const authData = { - deviceUuid: deviceUuid.value, note: authNote.value || '设备代码授权', } @@ -68,7 +146,7 @@ const authorizeWithDeviceCode = async () => { authData.password = authPassword.value } - const authResult = await apiClient.authorizeApp(appId.value, authData) + const authResult = await apiClient.authorizeApp(appId.value, deviceUuid.value, authData) const token = authResult.token // 2. 绑定 token 到设备代码 @@ -90,7 +168,6 @@ const authorizeWithCallback = async () => { try { const authData = { - deviceUuid: deviceUuid.value, note: authNote.value || '回调授权', } @@ -98,7 +175,7 @@ const authorizeWithCallback = async () => { authData.password = authPassword.value } - const authResult = await apiClient.authorizeApp(appId.value, authData) + const authResult = await apiClient.authorizeApp(appId.value, deviceUuid.value, authData) const token = authResult.token // 如果有回调 URL,跳转并携带 token @@ -136,10 +213,33 @@ const retry = () => { authPassword.value = '' } -onMounted(() => { +// 加载设备信息 +const loadDeviceInfo = async () => { + try { + const info = await apiClient.getDeviceInfo(deviceUuid.value) + deviceInfo.value = info + } catch (error) { + console.log('Failed to load device info:', error) + deviceInfo.value = null + } +} + +onMounted(async () => { deviceUuid.value = deviceStore.getOrGenerate() - hasPassword.value = deviceStore.hasPassword() - loadAppInfo() + + // 加载设备信息 + await loadDeviceInfo() + + // 加载设备账户信息 + await loadDeviceAccount() + + // 加载应用信息 + await loadAppInfo() + + // 如果已登录,加载设备列表 + if (accountStore.isAuthenticated) { + await accountStore.loadDevices() + } // 如果是 devicecode 模式且已有设备代码,自动填充 if (isDeviceCodeMode.value && deviceCode.value) { @@ -179,8 +279,15 @@ onMounted(() => {
- -
+
+ + +
+ + + + +
{{ deviceUuid }} @@ -189,6 +296,23 @@ onMounted(() => { 已保护
+ + +
+ + 已绑定至: {{ deviceAccount.name }} +
+
+ +
@@ -231,14 +355,17 @@ onMounted(() => { />
- -
- - +
+
@@ -337,5 +464,8 @@ onMounted(() => {
+ + + diff --git a/kv-admin/src/pages/dashboard.vue b/kv-admin/src/pages/dashboard.vue deleted file mode 100644 index 3375509..0000000 --- a/kv-admin/src/pages/dashboard.vue +++ /dev/null @@ -1,422 +0,0 @@ - - - + + + + diff --git a/kv-admin/src/pages/password-manager.vue b/kv-admin/src/pages/password-manager.vue new file mode 100644 index 0000000..e19e2d2 --- /dev/null +++ b/kv-admin/src/pages/password-manager.vue @@ -0,0 +1,591 @@ + + + \ No newline at end of file diff --git a/kv-admin/src/stores/account.js b/kv-admin/src/stores/account.js new file mode 100644 index 0000000..3aed301 --- /dev/null +++ b/kv-admin/src/stores/account.js @@ -0,0 +1,116 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { apiClient } from '@/lib/api' + +export const useAccountStore = defineStore('account', () => { + // 状态 + const token = ref(localStorage.getItem('auth_token') || null) + const profile = ref(null) + const devices = ref([]) + const loading = ref(false) + + // 计算属性 + const isAuthenticated = computed(() => !!token.value) + const userName = computed(() => profile.value?.name || '') + const userAvatar = computed(() => profile.value?.avatarUrl || '') + + // 方法 + const setToken = (newToken) => { + token.value = newToken + if (newToken) { + localStorage.setItem('auth_token', newToken) + } else { + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_provider') + } + } + + const loadProfile = async () => { + if (!token.value) return + + loading.value = true + try { + const response = await apiClient.getAccountProfile(token.value) + profile.value = response.data + } catch (error) { + console.error('Failed to load profile:', error) + // Token可能无效,清除 + if (error.message.includes('401')) { + logout() + } + } finally { + loading.value = false + } + } + + const loadDevices = async () => { + if (!token.value) return + + try { + const response = await apiClient.getAccountDevices(token.value) + devices.value = response.data || [] + return devices.value + } catch (error) { + console.error('Failed to load devices:', error) + return [] + } + } + + const bindDevice = async (deviceUuid) => { + if (!token.value) throw new Error('未登录') + + const response = await apiClient.bindDevice(token.value, deviceUuid) + // 重新加载设备列表 + await loadDevices() + return response + } + + const unbindDevice = async (deviceUuid) => { + if (!token.value) throw new Error('未登录') + + const response = await apiClient.unbindDevice(token.value, deviceUuid) + // 重新加载设备列表 + await loadDevices() + return response + } + + const login = async (authToken) => { + setToken(authToken) + await loadProfile() + await loadDevices() + } + + const logout = () => { + token.value = null + profile.value = null + devices.value = [] + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_provider') + } + + // 初始化时加载用户信息 + if (token.value) { + loadProfile() + loadDevices() + } + + return { + // 状态 + token, + profile, + devices, + loading, + // 计算属性 + isAuthenticated, + userName, + userAvatar, + // 方法 + setToken, + loadProfile, + loadDevices, + bindDevice, + unbindDevice, + login, + logout, + } +}) \ No newline at end of file diff --git a/middleware/auth.js b/middleware/auth.js deleted file mode 100644 index f35e1c1..0000000 --- a/middleware/auth.js +++ /dev/null @@ -1,521 +0,0 @@ -/** - * Token认证中间件系统 - * - * 本系统完全基于Token进行认证,不再支持UUID+密码的认证方式。 - * - * ## 推荐使用的认证中间件: - * - * ### 1. 纯Token认证中间件(推荐) - * - `tokenOnlyAuthMiddleware`: 完整的Token认证,要求设备匹配 - * - `tokenOnlyReadAuthMiddleware`: Token读取权限认证 - * - `tokenOnlyWriteAuthMiddleware`: Token写入权限认证 - * - `appTokenAuthMiddleware`: 应用Token认证,不要求设备匹配 - * - * ### 2. 应用权限认证中间件 - * - `appReadAuthMiddleware`: 应用读取权限(Token + 权限前缀检查) - * - `appWriteAuthMiddleware`: 应用写入权限(Token + 权限前缀检查) - * - `appListAuthMiddleware`: 应用列表权限(Token + 键过滤) - * - * ## Token获取方式: - * Token可通过以下三种方式提供: - * 1. HTTP Header: `x-app-token: your-token` - * 2. 查询参数: `?apptoken=your-token` - * 3. 请求体: `{\"apptoken\": \"your-token\"}` - * - * ## 认证成功响应: - * 认证成功后,中间件会在 `res.locals` 中设置: - * - `device`: 设备信息 - * - `appInstall`: 应用安装信息 - * - `app`: 应用信息 - * - `filterKeys`: 键过滤函数(仅限应用权限中间件) - * - * ## 认证失败响应: - * - 401: Token无效或不存在 - * - 403: 权限不足或设备不匹配 - * - 404: 设备或应用不存在 - */ - -import { PrismaClient } from "@prisma/client"; -import errors from "../utils/errors.js"; - -const prisma = new PrismaClient(); - -// 全局可读键列表 -const GLOBAL_READABLE_KEYS = [ - "_info", - "_check", - "_hint", - "_keys", -]; - -/** - * 检查站点密钥 - */ -export const checkSiteKey = (req, res, next) => { - const siteKey = req.headers["x-site-key"] || req.query.sitekey || req.body?.sitekey; - const expectedSiteKey = process.env.SITE_KEY; - - if (expectedSiteKey && siteKey !== expectedSiteKey) { - return res.status(401).json({ - statusCode: 401, - message: "无效的站点密钥", - }); - } - - next(); -}; - -/** - * 通过Token获取设备信息 - * @param {string} token - 应用安装Token - * @returns {Promise} 设备信息或null - */ -export const getDeviceByToken = async (token) => { - if (!token) { - return null; - } - - try { - const appInstall = await prisma.appInstall.findUnique({ - where: { token }, - include: { - device: true, - app: true, - }, - }); - - return appInstall; - } catch (error) { - console.error("获取设备信息时出错:", error); - return null; - } -}; - -/** - * 从请求中提取Token - * @param {Object} req - Express请求对象 - * @returns {string|null} Token或null - */ -const extractToken = (req) => { - // 优先级:Header > Query > Body - return ( - req.headers["x-app-token"] || - req.query.apptoken || - req.body?.apptoken || - null - ); -}; - -/** - * 设备信息中间件(仅检查设备存在性,不进行认证) - */ -export const deviceInfoMiddleware = async (req, res, next) => { - try { - const { deviceUuid,namespace } = req.params; - - if (!deviceUuid&&!namespace) { - return res.status(400).json({ - statusCode: 400, - message: "缺少命名空间参数", - }); - } - - // 查找设备 - const device = await prisma.device.findUnique({ - where: { uuid: deviceUuid||namespace }, - }); - - if (!device) { - return res.status(404).json({ - statusCode: 404, - message: "设备不存在", - }); - } - - res.locals.device = device; - next(); - } catch (error) { - console.error("设备信息中间件错误:", error); - return res.status(500).json({ - statusCode: 500, - message: "服务器内部错误", - }); - } -}; - -/** - * 纯Token认证中间件(推荐使用) - * 要求Token存在且对应的设备与请求的命名空间匹配 - */ -export const tokenOnlyAuthMiddleware = async (req, res, next) => { - try { - const token = extractToken(req); - const { namespace } = req.params; - - if (!token) { - return res.status(401).json({ - statusCode: 401, - message: "缺少认证Token", - }); - } - - const appInstall = await getDeviceByToken(token); - if (!appInstall) { - return res.status(401).json({ - statusCode: 401, - message: "无效的Token", - }); - } - - // 验证设备匹配 - if (namespace && appInstall.device.uuid !== namespace) { - return res.status(403).json({ - statusCode: 403, - message: "Token对应的设备与请求的命名空间不匹配", - }); - } - - res.locals.device = appInstall.device; - res.locals.appInstall = appInstall; - res.locals.app = appInstall.app; - next(); - } catch (error) { - console.error("Token认证中间件错误:", error); - return res.status(500).json({ - statusCode: 500, - message: "服务器内部错误", - }); - } -}; - -/** - * 纯Token读取认证中间件 - */ -export const tokenOnlyReadAuthMiddleware = async (req, res, next) => { - try { - const token = extractToken(req); - const { namespace } = req.params; - - if (!token) { - return res.status(401).json({ - statusCode: 401, - message: "缺少认证Token", - }); - } - - const appInstall = await getDeviceByToken(token); - if (!appInstall) { - return res.status(401).json({ - statusCode: 401, - message: "无效的Token", - }); - } - - // 验证设备匹配 - if (namespace && appInstall.device.uuid !== namespace) { - return res.status(403).json({ - statusCode: 403, - message: "Token对应的设备与请求的命名空间不匹配", - }); - } - - // 检查读取权限 - if (!appInstall.permissions?.read) { - return res.status(403).json({ - statusCode: 403, - message: "无读取权限", - }); - } - - res.locals.device = appInstall.device; - res.locals.appInstall = appInstall; - res.locals.app = appInstall.app; - next(); - } catch (error) { - console.error("Token读取认证中间件错误:", error); - return res.status(500).json({ - statusCode: 500, - message: "服务器内部错误", - }); - } -}; - -/** - * 纯Token写入认证中间件 - */ -export const tokenOnlyWriteAuthMiddleware = async (req, res, next) => { - try { - const token = extractToken(req); - const { namespace } = req.params; - - if (!token) { - return res.status(401).json({ - statusCode: 401, - message: "缺少认证Token", - }); - } - - const appInstall = await getDeviceByToken(token); - if (!appInstall) { - return res.status(401).json({ - statusCode: 401, - message: "无效的Token", - }); - } - - // 验证设备匹配 - if (namespace && appInstall.device.uuid !== namespace) { - return res.status(403).json({ - statusCode: 403, - message: "Token对应的设备与请求的命名空间不匹配", - }); - } - - // 检查写入权限 - if (!appInstall.permissions?.write) { - return res.status(403).json({ - statusCode: 403, - message: "无写入权限", - }); - } - - res.locals.device = appInstall.device; - res.locals.appInstall = appInstall; - res.locals.app = appInstall.app; - next(); - } catch (error) { - console.error("Token写入认证中间件错误:", error); - return res.status(500).json({ - statusCode: 500, - message: "服务器内部错误", - }); - } -}; - -/** - * 应用Token认证中间件 - * 不要求设备匹配,适用于应用级别的操作 - */ -export const appTokenAuthMiddleware = async (req, res, next) => { - try { - const token = extractToken(req); - - if (!token) { - return res.status(401).json({ - statusCode: 401, - message: "缺少应用Token", - }); - } - - const appInstall = await getDeviceByToken(token); - if (!appInstall) { - return res.status(401).json({ - statusCode: 401, - message: "无效的应用Token", - }); - } - - res.locals.device = appInstall.device; - res.locals.appInstall = appInstall; - res.locals.app = appInstall.app; - next(); - } catch (error) { - console.error("应用Token认证中间件错误:", error); - return res.status(500).json({ - statusCode: 500, - message: "服务器内部错误", - }); - } -}; - -/** - * 应用权限前缀检查中间件 - */ -export const appPrefixAuthMiddleware = (req, res, next) => { - const { key } = req.params; - const app = res.locals.app; - const appInstall = res.locals.appInstall; - - if (!app || !appInstall) { - return res.status(401).json({ - statusCode: 401, - message: "未认证的应用", - }); - } - - // 检查是否为全局可读键 - if (GLOBAL_READABLE_KEYS.includes(key)) { - return next(); - } - - // 检查权限前缀 - const permissionPrefix = app.permissionPrefix; - if (!key.startsWith(permissionPrefix + ".")) { - // 检查特殊权限 - const specialPermissions = appInstall.specialPermissions || []; - const hasSpecialPermission = specialPermissions.some(permission => - key.startsWith(permission + ".") || key === permission - ); - - if (!hasSpecialPermission) { - return res.status(403).json({ - statusCode: 403, - message: `无权限访问键 '${key}'。需要权限前缀 '${permissionPrefix}.' 或特殊权限。`, - }); - } - } - - next(); -}; - -/** - * 应用读取权限中间件 - * 结合Token认证和权限前缀检查 - */ -export const appReadAuthMiddleware = async (req, res, next) => { - // 先进行Token认证 - await new Promise((resolve, reject) => { - tokenOnlyReadAuthMiddleware(req, res, (err) => { - if (err) reject(err); - else resolve(); - }); - }).catch(() => { - return; // 错误已经在tokenOnlyReadAuthMiddleware中处理 - }); - - // 如果Token认证失败,直接返回 - if (res.headersSent) { - return; - } - - // 进行权限前缀检查 - appPrefixAuthMiddleware(req, res, next); -}; - -/** - * 应用写入权限中间件 - * 结合Token认证和权限前缀检查 - */ -export const appWriteAuthMiddleware = async (req, res, next) => { - // 先进行Token认证 - await new Promise((resolve, reject) => { - tokenOnlyWriteAuthMiddleware(req, res, (err) => { - if (err) reject(err); - else resolve(); - }); - }).catch(() => { - return; // 错误已经在tokenOnlyWriteAuthMiddleware中处理 - }); - - // 如果Token认证失败,直接返回 - if (res.headersSent) { - return; - } - - // 进行权限前缀检查 - appPrefixAuthMiddleware(req, res, next); -}; - -/** - * 应用列表权限中间件 - * 用于过滤键列表,只显示应用有权限访问的键 - */ -export const appListAuthMiddleware = async (req, res, next) => { - // 先进行Token认证 - await new Promise((resolve, reject) => { - tokenOnlyReadAuthMiddleware(req, res, (err) => { - if (err) reject(err); - else resolve(); - }); - }).catch(() => { - return; // 错误已经在tokenOnlyReadAuthMiddleware中处理 - }); - - // 如果Token认证失败,直接返回 - if (res.headersSent) { - return; - } - - const app = res.locals.app; - const appInstall = res.locals.appInstall; - - if (app && appInstall) { - // 设置键过滤函数 - res.locals.filterKeys = (keys) => { - const permissionPrefix = app.permissionPrefix; - const specialPermissions = appInstall.specialPermissions || []; - - return keys.filter(key => { - // 全局可读键 - if (GLOBAL_READABLE_KEYS.includes(key)) { - return true; - } - - // 权限前缀匹配 - if (key.startsWith(permissionPrefix + ".")) { - return true; - } - - // 特殊权限匹配 - return specialPermissions.some(permission => - key.startsWith(permission + ".") || key === permission - ); - }); - }; - } - - next(); -}; - -/** - * Token认证中间件,并将设备UUID注入为命名空间 - * - * 这个中间件专门用于处理那些URL中不包含 `:namespace` 参数的路由。 - * 它会从Token中解析出设备信息,然后将设备的UUID(即命名空间) - * 注入到 `req.params.namespace` 中。 - * - * 这使得后续的中间件(如权限检查中间件)和路由处理器可以统一 - * 从 `req.params.namespace` 获取命名空间,而无需关心它最初是 - * 来自URL还是来自Token。 - * - * 认证成功后,除了注入 `req.params.namespace`,还会在 `res.locals` 中设置: - * - `device`: 设备信息 - * - `appInstall`: 应用安装信息 - * - `app`: 应用信息 - */ -export const tokenAuthMiddleware = async (req, res, next) => { - try { - const token = extractToken(req); - - if (!token) { - return res.status(401).json({ - statusCode: 401, - message: "缺少认证Token", - }); - } - - const appInstall = await getDeviceByToken(token); - if (!appInstall) { - return res.status(401).json({ - statusCode: 401, - message: "无效的Token", - }); - } - - // 核心逻辑:将设备UUID注入req.params.namespace - req.params.namespace = appInstall.device.uuid; - - // 存储认证信息以供后续使用 - res.locals.device = appInstall.device; - res.locals.appInstall = appInstall; - res.locals.app = appInstall.app; - - next(); - } catch (error) { - console.error("Token认证与命名空间注入中间件错误:", error); - return res.status(500).json({ - statusCode: 500, - message: "服务器内部错误", - }); - } -}; diff --git a/middleware/device.js b/middleware/device.js index f3c80fc..1623d4e 100644 --- a/middleware/device.js +++ b/middleware/device.js @@ -92,6 +92,8 @@ export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) => * 从req.body.password获取密码 * 如果设备有密码但未提供或密码错误,则返回401错误 * + * 特殊规则:如果设备绑定了账户,且req.account存在且匹配,则跳过密码验证 + * * 使用方式: * router.post('/path', deviceMiddleware, passwordMiddleware, handler) */ @@ -103,6 +105,11 @@ export const passwordMiddleware = errors.catchAsync(async (req, res, next) => { return next(errors.createError(500, "设备信息未加载,请先使用deviceMiddleware")); } + // 如果设备绑定了账户,且请求中有账户信息且匹配,则跳过密码验证 + if (device.accountId && req.account && req.account.id === device.accountId) { + return next(); + } + // 如果设备有密码,验证密码 if (device.password) { if (!password) { diff --git a/middleware/jwt-auth.js b/middleware/jwt-auth.js new file mode 100644 index 0000000..646fbef --- /dev/null +++ b/middleware/jwt-auth.js @@ -0,0 +1,54 @@ +/** + * 纯账户JWT认证中间件 + * + * 只验证账户JWT是否正确,不需要设备上下文 + * 适用于只需要账户验证的接口 + */ + +import { verifyToken } from "../utils/jwt.js"; +import { PrismaClient } from "@prisma/client"; +import errors from "../utils/errors.js"; + +const prisma = new PrismaClient(); + +/** + * 纯JWT认证中间件 + * 只验证Bearer token并将账户信息存储到res.locals + */ +export const jwtAuth = async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return next(errors.createError(401, "需要提供有效的JWT token")); + } + + const token = authHeader.substring(7); + + // 验证JWT token + const decoded = verifyToken(token); + + // 从数据库获取账户信息 + const account = await prisma.account.findUnique({ + where: { id: decoded.accountId }, + }); + + if (!account) { + return next(errors.createError(401, "账户不存在")); + } + + // 将账户信息存储到res.locals + res.locals.account = account; + next(); + } catch (error) { + if (error.name === 'JsonWebTokenError') { + return next(errors.createError(401, "无效的JWT token")); + } + + if (error.name === 'TokenExpiredError') { + return next(errors.createError(401, "JWT token已过期")); + } + + return next(errors.createError(500, "认证过程出错")); + } +}; \ No newline at end of file diff --git a/middleware/kvTokenAuth.js b/middleware/kvTokenAuth.js new file mode 100644 index 0000000..d1721d7 --- /dev/null +++ b/middleware/kvTokenAuth.js @@ -0,0 +1,66 @@ +/** + * KV接口专用Token认证中间件 + * + * 仅验证app token,设置设备和应用信息到res.locals + * 适用于所有KV相关的接口 + */ + +import { PrismaClient } from "@prisma/client"; +import errors from "../utils/errors.js"; + +const prisma = new PrismaClient(); + +/** + * KV Token认证中间件 + * 从请求中提取token(支持多种方式),验证后将设备和应用信息注入到res.locals + */ +export const kvTokenAuth = async (req, res, next) => { + try { + // 从多种途径获取token + const token = extractToken(req); + + if (!token) { + return next(errors.createError(401, "需要提供有效的token")); + } + + // 查找token对应的应用安装信息 + const appInstall = await prisma.appInstall.findUnique({ + where: { token }, + include: { + app: true, + device: true, + }, + }); + + if (!appInstall) { + return next(errors.createError(401, "无效的token")); + } + + // 将信息存储到res.locals供后续使用 + res.locals.device = appInstall.device; + res.locals.app = appInstall.app; + res.locals.appInstall = appInstall; + res.locals.deviceId = appInstall.device.id; + + next(); + } catch (error) { + next(error); + } +}; + +/** + * 从请求中提取token + * 支持的方式: + * 1. Header: x-app-token + * 2. Query: token 或 apptoken + * 3. Body: token 或 apptoken + */ +function extractToken(req) { + return ( + req.headers["x-app-token"] || + req.query.token || + req.query.apptoken || + (req.body && req.body.token) || + (req.body && req.body.apptoken) + ); +} \ No newline at end of file diff --git a/middleware/rateLimiter.js b/middleware/rateLimiter.js index 0d096eb..19a1d65 100644 --- a/middleware/rateLimiter.js +++ b/middleware/rateLimiter.js @@ -45,7 +45,7 @@ export const globalLimiter = rateLimit({ // API限速器 export const apiLimiter = rateLimit({ windowMs: 1 * 60 * 1000, // 1分钟 - limit: 50, // 每个IP在windowMs时间内最多允许50个请求 + limit: 100, // 每个IP在windowMs时间内最多允许100个请求 standardHeaders: "draft-7", legacyHeaders: false, message: "API请求过于频繁,请稍后再试", diff --git a/middleware/tokenAuth.js b/middleware/tokenAuth.js deleted file mode 100644 index fc5f0ab..0000000 --- a/middleware/tokenAuth.js +++ /dev/null @@ -1,112 +0,0 @@ -import { PrismaClient } from "@prisma/client"; -import { verifyDevicePassword } from "../utils/crypto.js"; -import errors from "../utils/errors.js"; - -const prisma = new PrismaClient(); - -/** - * Token认证中间件 - * - * 从请求中提取token,验证后将设备信息注入到res.locals - * 同时将deviceId注入到req.params,以便后续路由使用 - * - * Token可通过以下方式提供: - * 1. Authorization header: Bearer - * 2. Query参数: ?token= - * 3. Body: {"token": ""} - */ -export const tokenAuth = errors.catchAsync(async (req, res, next) => { - let token; - - // 尝试从 headers, query, body 中获取 token - if (req.headers.authorization && req.headers.authorization.startsWith("Bearer ")) { - token = req.headers.authorization.split(" ")[1]; - } else if (req.query.token) { - token = req.query.token; - } else if (req.body.token) { - token = req.body.token; - } - - if (!token) { - return next(errors.createError(401, "未提供身份验证令牌")); - } - - const appInstall = await prisma.appInstall.findUnique({ - where: { token }, - include: { - app: true, - device: true, - }, - }); - - if (!appInstall) { - return next(errors.createError(401, "无效的身份验证令牌")); - } - - // 将认证信息存储到res.locals - res.locals.appInstall = appInstall; - res.locals.app = appInstall.app; - res.locals.device = appInstall.device; - res.locals.deviceId = appInstall.device.id; - - // 将deviceId注入到req.params(向后兼容,某些路由可能需要namespace参数) - req.params.namespace = appInstall.device.uuid; - req.params.deviceId = appInstall.device.id; - - next(); -}); - -/** - * 写权限验证中间件 - * - * 依赖于deviceMiddleware,必须在其后使用 - * 验证设备密码和写权限 - * - * 逻辑: - * 1. 如果设备没有设置密码,直接允许写入 - * 2. 如果设备设置了密码: - * - 验证提供的密码是否正确 - * - 密码正确则允许写入 - * - 密码错误或未提供则拒绝写入 - * - * 使用方式: - * router.post('/path', deviceMiddleware, requireWriteAuth, handler) - * router.put('/path', deviceMiddleware, requireWriteAuth, handler) - * router.delete('/path', deviceMiddleware, requireWriteAuth, handler) - */ -export const requireWriteAuth = errors.catchAsync(async (req, res, next) => { - const device = res.locals.device; - - if (!device) { - return next(errors.createError(500, "设备信息未加载,请确保使用了deviceMiddleware")); - } - - // 如果设备没有设置密码,直接通过 - if (!device.password) { - return next(); - } - - // 设备有密码,需要验证 - const providedPassword = req.body.password || req.query.password; - - if (!providedPassword) { - return res.status(401).json({ - statusCode: 401, - message: "此操作需要密码", - passwordHint: device.passwordHint, - }); - } - - // 验证密码 - const isValid = await verifyDevicePassword(providedPassword, device.password); - - if (!isValid) { - return res.status(401).json({ - statusCode: 401, - message: "密码错误", - }); - } - - // 密码正确,继续 - next(); -}); \ No newline at end of file diff --git a/middleware/uuidAuth.js b/middleware/uuidAuth.js new file mode 100644 index 0000000..bc6aef5 --- /dev/null +++ b/middleware/uuidAuth.js @@ -0,0 +1,131 @@ +/** + * UUID+密码/JWT混合认证中间件 + * + * 1. 必须提供UUID,读取设备信息并存储到res.locals + * 2. 验证密码或账户JWT(二选一) + * 3. 适用于需要设备上下文的接口 + */ + +import { PrismaClient } from "@prisma/client"; +import errors from "../utils/errors.js"; +import { verifyToken as verifyAccountJWT } from "../utils/jwt.js"; +import { verifyDevicePassword } from "../utils/crypto.js"; + +const prisma = new PrismaClient(); + +/** + * UUID+密码/JWT混合认证中间件 + */ +export const uuidAuth = async (req, res, next) => { + try { + // 1. 获取UUID(必需) + const uuid = extractUuid(req); + if (!uuid) { + return next(errors.createError(400, "需要提供设备UUID")); + } + + // 2. 查找设备并存储到locals + const device = await prisma.device.findUnique({ + where: { uuid }, + }); + + if (!device) { + return next(errors.createError(404, "设备不存在")); + } + + // 存储设备信息到locals + res.locals.device = device; + res.locals.deviceId = device.id; + + // 3. 验证密码或JWT(二选一) + const password = extractPassword(req); + const jwt = extractJWT(req); + + if (jwt) { + // 验证账户JWT + try { + const accountPayload = await verifyAccountJWT(jwt); + const account = await prisma.account.findUnique({ + where: { id: accountPayload.accountId }, + include: { + devices: { + where: { uuid }, + select: { id: true } + } + } + }); + + if (!account) { + return next(errors.createError(401, "账户不存在")); + } + + // 检查设备是否绑定到此账户 + if (account.devices.length === 0) { + return next(errors.createError(403, "设备未绑定到此账户")); + } + + res.locals.account = account; + res.locals.isAccountOwner = true; // 标记为账户拥有者 + return next(); + } catch (error) { + return next(errors.createError(401, "无效的JWT token")); + } + } else if (password) { + // 验证设备密码 + if (!device.password) { + return next(); // 如果设备未设置密码,允许无密码访问 + } + + const isValid = await verifyDevicePassword(password, device.password); + if (!isValid) { + return next(errors.createError(401, "密码错误")); + } + + return next(); + } else { + // 如果设备未设置密码,允许无密码访问 + if (!device.password) { + return next(); + } + return next(errors.createError(401, "需要提供密码或JWT token")); + } + } catch (error) { + next(error); + } +}; + +/** + * 从请求中提取UUID + */ +function extractUuid(req) { + return ( + req.headers["x-device-uuid"] || + req.query.uuid || + req.params.uuid || + req.params.deviceUuid || + (req.body && req.body.uuid) || + (req.body && req.body.deviceUuid) + ); +} + +/** + * 从请求中提取密码 + */ +function extractPassword(req) { + return ( + req.headers["x-device-password"] || + req.query.password || + req.query.currentPassword + ); +} + +/** + * 从请求中提取JWT + */ +function extractJWT(req) { + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + return null; +} \ No newline at end of file diff --git a/package.json b/package.json index af2c370..430b891 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "express-rate-limit": "^7.5.0", "http-errors": "~2.0.0", "js-base64": "^3.7.7", + "jsonwebtoken": "^9.0.2", "morgan": "~1.10.0", "uuid": "^11.1.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb51069..946659b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: js-base64: specifier: ^3.7.7 version: 3.7.7 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 morgan: specifier: ~1.10.0 version: 1.10.0 @@ -1377,6 +1380,9 @@ packages: brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -1535,6 +1541,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -1794,6 +1803,16 @@ packages: engines: {node: '>=6'} hasBin: true + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -1865,6 +1884,27 @@ packages: lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -2176,6 +2216,11 @@ packages: scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.0: resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} @@ -3834,6 +3879,8 @@ snapshots: dependencies: balanced-match: 1.0.2 + buffer-equal-constant-time@1.0.1: {} + bytes@3.1.2: {} c12@3.1.0: @@ -3966,6 +4013,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} effect@3.16.12: @@ -4261,6 +4312,30 @@ snapshots: json5@2.2.3: {} + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.2 + + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + lightningcss-darwin-arm64@1.30.1: optional: true @@ -4314,6 +4389,20 @@ snapshots: lodash.camelcase@4.3.0: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + long@5.3.2: {} lucide-react@0.544.0(react@19.2.0): @@ -4646,6 +4735,8 @@ snapshots: scule@1.3.0: {} + semver@7.7.2: {} + send@1.2.0: dependencies: debug: 4.4.1 diff --git a/prisma/migrations/20251002074755_update/migration.sql b/prisma/migrations/20251002074755_update/migration.sql new file mode 100644 index 0000000..0321915 --- /dev/null +++ b/prisma/migrations/20251002074755_update/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE `Account` ( + `id` VARCHAR(191) NOT NULL, + `provider` VARCHAR(191) NOT NULL, + `providerId` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NULL, + `name` VARCHAR(191) NULL, + `avatarUrl` VARCHAR(191) NULL, + `providerData` JSON NULL, + `accessToken` VARCHAR(191) NOT NULL, + `refreshToken` VARCHAR(191) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `Account_accessToken_key`(`accessToken`), + UNIQUE INDEX `Account_provider_providerId_key`(`provider`, `providerId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Device` ADD CONSTRAINT `Device_accountId_fkey` FOREIGN KEY (`accountId`) REFERENCES `Account`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2e56c9a..b4c51e4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,17 +21,37 @@ model KVStore { @@id([deviceId, key]) } +model Account { + id String @id @default(cuid()) + provider String // OAuth提供者 (例如: google, github, gitlab等) + providerId String // 提供者返回的用户唯一ID + email String? // 用户邮箱 + name String? // 用户名称 + avatarUrl String? // 用户头像URL + providerData Json? // OAuth提供者返回的完整信息 + accessToken String @unique // 账户访问令牌 + refreshToken String? // OAuth refresh token (如果提供者支持) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // 关联的设备 + devices Device[] + + @@unique([provider, providerId]) // 确保同一提供者的用户ID唯一 +} + model Device { id Int @id @default(autoincrement()) uuid String @unique // 设备的唯一标识符 name String? - accountId String? // 关联的社区账户ID,暂不添加相关代码 + accountId String? // 关联的账户ID createdAt DateTime @default(now()) updatedAt DateTime @updatedAt password String? passwordHint String? - // 关联的应用安装记录 + // 关联关系 + account Account? @relation(fields: [accountId], references: [id], onDelete: SetNull) appInstalls AppInstall[] kvStore KVStore[] // 设备相关的KV存储 } diff --git a/public/auth-error.html b/public/auth-error.html new file mode 100644 index 0000000..484c20a --- /dev/null +++ b/public/auth-error.html @@ -0,0 +1,166 @@ + + + + + + 登录失败 + + + +
+
+ + + +
+ +

登录失败

+ +
+
认证过程中出现错误
+
+
+ + 返回重试 + + +
+ 如果问题持续存在,请检查:
+ • OAuth应用配置是否正确
+ • 回调URL是否已添加到OAuth应用中
+ • 环境变量是否配置正确 +
+
+ + + + \ No newline at end of file diff --git a/public/auth-success.html b/public/auth-success.html new file mode 100644 index 0000000..43edce7 --- /dev/null +++ b/public/auth-success.html @@ -0,0 +1,254 @@ + + + + + + 登录成功 + + + +
+
+ + + +
+ +

登录成功

+

OAuth Provider

+ +
+
访问令牌
+
加载中...
+
+ + + +
+ 窗口将在 10 秒后自动关闭 +
+
+ + + + \ No newline at end of file diff --git a/routes/accounts.js b/routes/accounts.js new file mode 100644 index 0000000..2628fe2 --- /dev/null +++ b/routes/accounts.js @@ -0,0 +1,548 @@ +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 { jwtAuth } from "../middleware/jwt-auth.js"; + +const router = Router(); +const prisma = new PrismaClient(); + +// 存储OAuth state,防止CSRF攻击(生产环境应使用Redis等) +const oauthStates = new Map(); + +/** + * 生成安全的访问令牌 + */ +function generateAccessToken() { + return crypto.randomBytes(32).toString("hex"); +} + +/** + * 获取支持的OAuth提供者列表 + * GET /accounts/oauth/providers + */ +router.get("/oauth/providers", (req, res) => { + const providers = []; + + for (const [key, config] of Object.entries(oauthProviders)) { + // 只返回已配置的提供者 + if (config.clientId && config.clientSecret) { + providers.push({ + id: key, + name: config.name, + icon: config.icon, + color: config.color, + description: config.description, + authUrl: `/accounts/oauth/${key}`, // 前端用于发起认证的URL + }); + } + } + + res.json({ + success: true, + data: providers, + }); +}); + +/** + * 发起OAuth认证 + * GET /accounts/oauth/:provider + * + * Query参数: + * - redirect_uri: 前端回调地址(可选) + */ +router.get("/oauth/:provider", (req, res) => { + const { provider } = req.params; + const { redirect_uri } = req.query; + + const providerConfig = oauthProviders[provider]; + if (!providerConfig) { + return res.status(400).json({ + success: false, + message: `不支持的OAuth提供者: ${provider}`, + }); + } + + if (!providerConfig.clientId || !providerConfig.clientSecret) { + return res.status(500).json({ + success: false, + message: `OAuth提供者 ${provider} 未配置`, + }); + } + + // 生成state参数 + const state = generateState(); + + // 保存state和redirect_uri(5分钟过期) + oauthStates.set(state, { + provider, + redirect_uri, + timestamp: Date.now(), + }); + + // 清理过期的state(超过5分钟) + for (const [key, value] of oauthStates.entries()) { + if (Date.now() - value.timestamp > 5 * 60 * 1000) { + oauthStates.delete(key); + } + } + + // 构建授权URL + const params = new URLSearchParams({ + client_id: providerConfig.clientId, + redirect_uri: getCallbackURL(provider), + scope: providerConfig.scope, + state: state, + response_type: "code", + }); + + // Google需要额外的参数 + if (provider === "google") { + params.append("access_type", "offline"); + params.append("prompt", "consent"); + } + + const authUrl = `${providerConfig.authorizationURL}?${params.toString()}`; + + // 重定向到OAuth提供者 + res.redirect(authUrl); +}); + +/** + * OAuth回调处理 + * GET /accounts/oauth/:provider/callback + */ +router.get("/oauth/:provider/callback", async (req, res) => { + const { provider } = req.params; + const { code, state, error } = req.query; + + // 如果OAuth提供者返回错误 + if (error) { + const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + const errorUrl = new URL(frontendBaseUrl); + errorUrl.searchParams.append("error", error); + errorUrl.searchParams.append("provider", provider); + errorUrl.searchParams.append("success", "false"); + return res.redirect(errorUrl.toString()); + } + + // 验证state + const stateData = oauthStates.get(state); + if (!stateData || stateData.provider !== provider) { + const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + const errorUrl = new URL(frontendBaseUrl); + errorUrl.searchParams.append("error", "invalid_state"); + errorUrl.searchParams.append("provider", provider); + errorUrl.searchParams.append("success", "false"); + return res.redirect(errorUrl.toString()); + } + + // 删除已使用的state + oauthStates.delete(state); + + const providerConfig = oauthProviders[provider]; + + try { + // 1. 使用授权码换取访问令牌 + const tokenResponse = await fetch(providerConfig.tokenURL, { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: providerConfig.clientId, + client_secret: providerConfig.clientSecret, + code: code, + grant_type: "authorization_code", + redirect_uri: getCallbackURL(provider), + }), + }); + + const tokenData = await tokenResponse.json(); + + if (!tokenData.access_token) { + throw new Error("获取访问令牌失败"); + } + + // 2. 使用访问令牌获取用户信息 + const userResponse = await fetch(providerConfig.userInfoURL, { + headers: { + "Authorization": `Bearer ${tokenData.access_token}`, + "Accept": "application/json", + }, + }); + + const userData = await userResponse.json(); + + // 3. 标准化用户数据(不同提供者返回的字段不同) + let normalizedUser = {}; + + if (provider === "github") { + normalizedUser = { + providerId: String(userData.id), + email: userData.email, + name: userData.name || userData.login, + avatarUrl: userData.avatar_url, + }; + } else if (provider === "zerocat") { + normalizedUser = { + providerId: userData.openid, + email: userData.email_verified ? userData.email : null, + name: userData.nickname || userData.username, + avatarUrl: userData.avatar, + }; + } + + // 4. 查找或创建账户 + let account = await prisma.account.findUnique({ + where: { + provider_providerId: { + provider, + providerId: normalizedUser.providerId, + }, + }, + }); + + if (account) { + // 更新账户信息 + account = await prisma.account.update({ + where: { id: account.id }, + data: { + email: normalizedUser.email || account.email, + name: normalizedUser.name || account.name, + avatarUrl: normalizedUser.avatarUrl || account.avatarUrl, + providerData: userData, + refreshToken: tokenData.refresh_token || account.refreshToken, + updatedAt: new Date(), + }, + }); + } else { + // 创建新账户 + const accessToken = generateAccessToken(); + account = await prisma.account.create({ + data: { + provider, + providerId: normalizedUser.providerId, + email: normalizedUser.email, + name: normalizedUser.name, + avatarUrl: normalizedUser.avatarUrl, + providerData: userData, + accessToken, + refreshToken: tokenData.refresh_token, + }, + }); + } + + // 5. 生成JWT token + const jwtToken = generateAccountToken(account); + + // 6. 重定向到前端根路径,携带JWT token + const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + const callbackUrl = new URL(frontendBaseUrl); + callbackUrl.searchParams.append("token", jwtToken); + callbackUrl.searchParams.append("provider", provider); + callbackUrl.searchParams.append("success", "true"); + + res.redirect(callbackUrl.toString()); + + } catch (error) { + console.error(`OAuth回调处理失败 [${provider}]:`, error); + + // 重定向到前端根路径,携带错误信息 + const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + const errorUrl = new URL(frontendBaseUrl); + errorUrl.searchParams.append("error", error.message); + errorUrl.searchParams.append("provider", provider); + errorUrl.searchParams.append("success", "false"); + + res.redirect(errorUrl.toString()); + } +}); + +/** + * 获取账户信息 + * GET /api/accounts/profile + * + * Headers: + * Authorization: Bearer + */ +router.get("/profile", jwtAuth, async (req, res, next) => { + try { + const accountContext = res.locals.account; + + const account = await prisma.account.findUnique({ + where: { id: accountContext.id }, + include: { + devices: { + select: { + id: true, + uuid: true, + name: true, + createdAt: true, + }, + }, + }, + }); + + res.json({ + success: true, + data: { + id: account.id, + provider: account.provider, + email: account.email, + name: account.name, + avatarUrl: account.avatarUrl, + devices: account.devices, + createdAt: account.createdAt, + }, + }); + } catch (error) { + next(error); + } +}); + +/** + * 绑定设备到账户 + * POST /api/accounts/devices/bind + * + * Headers: + * Authorization: Bearer + * + * Body: + * { + * uuid: string // 设备UUID + * } + */ +router.post("/devices/bind", jwtAuth, async (req, res, next) => { + try { + const accountContext = res.locals.account; + const { uuid } = req.body; + + if (!uuid) { + return res.status(400).json({ + success: false, + message: "缺少设备UUID", + }); + } + + // 查找设备 + const device = await prisma.device.findUnique({ + where: { uuid }, + }); + + if (!device) { + return res.status(404).json({ + success: false, + message: "设备不存在 #1", + }); + } + + // 检查设备是否已绑定其他账户 + if (device.accountId && device.accountId !== accountContext.id) { + return res.status(400).json({ + success: false, + message: "设备已绑定其他账户", + }); + } + + // 绑定设备到账户 + const updatedDevice = await prisma.device.update({ + where: { uuid }, + data: { + accountId: accountContext.id, + }, + }); + + res.json({ + success: true, + message: "设备绑定成功", + data: { + deviceId: updatedDevice.id, + uuid: updatedDevice.uuid, + name: updatedDevice.name, + }, + }); + } catch (error) { + next(error); + } +}); + +/** + * 解绑设备 + * POST /api/accounts/devices/unbind + * + * Headers: + * Authorization: Bearer + * + * Body: + * { + * uuid: string // 设备UUID(单个解绑) + * uuids: string[] // 设备UUID数组(批量解绑) + * } + */ +router.post("/devices/unbind", jwtAuth, async (req, res, next) => { + try { + const accountContext = res.locals.account; + const { uuid, uuids } = req.body; + + // 支持单个解绑或批量解绑 + const uuidsToUnbind = uuids || (uuid ? [uuid] : []); + + if (uuidsToUnbind.length === 0) { + return res.status(400).json({ + success: false, + message: "请提供要解绑的设备UUID", + }); + } + + // 查找所有设备并验证所有权 + const devices = await prisma.device.findMany({ + where: { + uuid: { in: uuidsToUnbind }, + }, + }); + + // 检查是否有不存在的设备 + if (devices.length !== uuidsToUnbind.length) { + const foundUuids = devices.map(d => d.uuid); + const notFoundUuids = uuidsToUnbind.filter(u => !foundUuids.includes(u)); + return res.status(404).json({ + success: false, + message: `以下设备不存在: ${notFoundUuids.join(', ')}`, + }); + } + + // 检查所有权 + const unauthorizedDevices = devices.filter(d => d.accountId !== accountContext.id); + if (unauthorizedDevices.length > 0) { + return res.status(403).json({ + success: false, + message: `您没有权限解绑以下设备: ${unauthorizedDevices.map(d => d.uuid).join(', ')}`, + }); + } + + // 批量解绑设备 + await prisma.device.updateMany({ + where: { + uuid: { in: uuidsToUnbind }, + accountId: accountContext.id, + }, + data: { + accountId: null, + }, + }); + + res.json({ + success: true, + message: uuidsToUnbind.length === 1 ? "设备解绑成功" : `成功解绑 ${uuidsToUnbind.length} 个设备`, + unboundCount: uuidsToUnbind.length, + }); + } catch (error) { + next(error); + } +}); + +/** + * 获取账户绑定的设备列表 + * GET /api/accounts/devices + * + * Headers: + * Authorization: Bearer + */ +router.get("/devices", jwtAuth, async (req, res, next) => { + try { + const accountContext = res.locals.account; + // 获取账户的设备列表 + const account = await prisma.account.findUnique({ + where: { id: accountContext.id }, + include: { + devices: { + select: { + id: true, + uuid: true, + name: true, + createdAt: true, + updatedAt: true, + appInstalls: { + include: { + app: { + select: { + id: true, + name: true, + iconHash: true, + }, + }, + }, + }, + }, + }, + }, + }); + + res.json({ + success: true, + data: account.devices, + }); + } catch (error) { + next(error); + } +}); + +/** + * 根据设备UUID获取账户公开信息 + * GET /accounts/device/:uuid/account + * + * 无需认证,返回公开信息 + */ +router.get("/device/:uuid/account", async (req, res, next) => { + try { + const { uuid } = req.params; + + // 查找设备及其关联的账户 + const device = await prisma.device.findUnique({ + where: { uuid }, + include: { + account: { + select: { + id: true, + provider: true, + name: true, + avatarUrl: true, + createdAt: true, + }, + }, + }, + }); + + if (!device) { + return res.status(404).json({ + success: false, + message: "设备不存在 #2", + }); + } + + if (!device.account) { + return res.json({ + success: true, + data: null, // 设备未绑定账户 + }); + } + + res.json({ + success: true, + data: { + id: device.account.id, + provider: device.account.provider, + name: device.account.name, + avatarUrl: device.account.avatarUrl, + bindTime: device.updatedAt, // 绑定时间 + }, + }); + } catch (error) { + next(error); + } +}); + +export default router; \ No newline at end of file diff --git a/routes/apps.js b/routes/apps.js index aced87c..cbf408f 100644 --- a/routes/apps.js +++ b/routes/apps.js @@ -1,156 +1,176 @@ import { Router } from "express"; const router = Router(); -import { - deviceMiddleware, - passwordMiddleware, - deviceInfoMiddleware, -} from "../middleware/device.js"; -import { checkSiteKey } from "../middleware/auth.js"; +import { uuidAuth } from "../middleware/uuidAuth.js"; +import { jwtAuth } from "../middleware/jwt-auth.js"; import { PrismaClient } from "@prisma/client"; import crypto from "crypto"; import errors from "../utils/errors.js"; -import { hashPassword, verifyDevicePassword } from "../utils/crypto.js"; const prisma = new PrismaClient(); -router.use(checkSiteKey); +/** + * GET /apps/devices/:uuid/apps + * 获取设备安装的应用列表 (公开接口,无需认证) + */ +router.get( + "/devices/:uuid/apps", + errors.catchAsync(async (req, res, next) => { + const { uuid } = req.params; + + // 查找设备 + const device = await prisma.device.findUnique({ + where: { uuid }, + }); + + if (!device) { + return next(errors.createError(404, "设备不存在")); + } + + const installations = await prisma.appInstall.findMany({ + where: { deviceId: device.id }, + include: { app: true }, + }); + + const apps = installations.map(install => ({ + id: install.app.id, + name: install.app.name, + description: install.app.description, + token: install.token, + installedAt: install.createdAt, + })); + + return res.json({ + success: true, + apps, + }); + }) +); + +/** + * POST /apps/devices/:uuid/install/:appId + * 为设备安装应用 (需要UUID认证) + */ +router.post( + "/devices/:uuid/install/:appId", + uuidAuth, + errors.catchAsync(async (req, res, next) => { + const device = res.locals.device; + const { appId } = req.params; + const { note } = req.body; + + // 检查应用是否存在 + const app = await prisma.app.findUnique({ + where: { id: parseInt(appId) }, + }); + + if (!app) { + return next(errors.createError(404, "应用不存在")); + } + + + // 生成token + const token = crypto.randomBytes(32).toString("hex"); + + // 创建安装记录 + const installation = await prisma.appInstall.create({ + data: { + deviceId: device.id, + appId: app.id, + token, + note: note || null, + }, + }); + + return res.status(201).json({ + id: installation.id, + appId: app.id, + appName: app.name, + token: installation.token, + note: installation.note, + installedAt: installation.createdAt, + }); + }) +); + +/** + * DELETE /apps/devices/:uuid/uninstall/:installId + * 卸载设备应用 (需要UUID认证) + */ +router.delete( + "/devices/:uuid/uninstall/:installId", + uuidAuth, + errors.catchAsync(async (req, res, next) => { + const device = res.locals.device; + const { installId } = req.params; + + const installation = await prisma.appInstall.findUnique({ + where: { id: installId }, + }); + + if (!installation) { + return next(errors.createError(404, "应用未安装")); + } + + // 确保安装记录属于当前设备 + if (installation.deviceId !== device.id) { + return next(errors.createError(403, "无权操作此安装记录")); + } + + await prisma.appInstall.delete({ + where: { id: installation.id }, + }); + + return res.status(204).end(); + }) +); /** * GET /apps - * 获取应用列表 + * 获取所有可用应用列表 */ router.get( "/", errors.catchAsync(async (req, res) => { - const { limit = 20, skip = 0, search } = req.query; - - const where = search - ? { - OR: [ - { name: { contains: search } }, - { description: { contains: search } }, - { developerName: { contains: search } }, - ], - } - : {}; - - const [apps, total] = await Promise.all([ - prisma.app.findMany({ - where, - take: parseInt(limit), - skip: parseInt(skip), - orderBy: { createdAt: "desc" }, - }), - prisma.app.count({ where }), - ]); - - res.json({ - apps, - total, - limit: parseInt(limit), - skip: parseInt(skip), - }); - }) -); - -/** - * GET /apps/:id - * 获取单个应用详情 - */ -router.get( - "/:id", - errors.catchAsync(async (req, res) => { - const { id } = req.params; - - const app = await prisma.app.findUnique({ - where: { id: parseInt(id) }, - }); - - if (!app) { - return res.status(404).json({ - statusCode: 404, - message: "应用不存在", - }); - } - - res.json(app); - }) -); - -/** - * POST /apps/:id/authorize - * 为应用授权获取token - * - * 使用统一的设备中间件: - * 1. deviceMiddleware - 自动获取或创建设备 - * 2. passwordMiddleware - 验证密码(如果设备有密码) - * - * 请求体: - * { - * "deviceUuid": "设备UUID", - * "password": "设备密码(如果设备有密码则必须提供)", - * "note": "备注信息" // 可选 - * } - */ -router.post( - "/:id/authorize", - deviceMiddleware, - passwordMiddleware, - errors.catchAsync(async (req, res) => { - const { id: appId } = req.params; - const { note } = req.body; - const device = res.locals.device; - - // 检查应用是否存在 - const app = await prisma.app.findUnique({ - where: { id: Number(appId) }, - }); - - if (!app) { - return res.status(404).json({ - statusCode: 404, - message: "应用不存在", - }); - } - - // 生成token - const randomBytes = crypto.randomBytes(32); - const tokenData = `${appId}-${device.uuid}-${Date.now()}-${randomBytes.toString('hex')}`; - const token = crypto.createHash("sha256").update(tokenData).digest("hex"); - - // 创建应用安装记录 - const appInstall = await prisma.appInstall.create({ - data: { - deviceId: device.id, - appId: Number(appId), - token, - note: note || "授权访问", + const apps = await prisma.app.findMany({ + select: { + id: true, + name: true, + description: true, + createdAt: true, }, }); - res.status(200).json({ - token: appInstall.token, - appId: Number(appId), - appName: app.name, - deviceUuid: device.uuid, - deviceName: device.name, - note: appInstall.note, - authorizedAt: appInstall.installedAt, - + return res.json({ + success: true, + apps, }); }) ); + /** - * GET /apps/devices/:deviceUuid/tokens - * 获取设备上的所有授权token + * GET /apps/tokens + * 获取设备的token列表 (需要设备UUID) */ router.get( - "/devices/:deviceUuid/tokens", - deviceInfoMiddleware, - errors.catchAsync(async (req, res) => { - const device = res.locals.device; + "/tokens", + errors.catchAsync(async (req, res, next) => { + const { uuid } = req.query; + if (!uuid) { + return next(errors.createError(400, "需要提供设备UUID")); + } + + // 查找设备 + const device = await prisma.device.findUnique({ + where: { uuid }, + }); + + if (!device) { + return next(errors.createError(404, "设备不存在")); + } + + // 获取该设备的所有应用安装记录(即token) const installations = await prisma.appInstall.findMany({ where: { deviceId: device.id }, include: { @@ -159,236 +179,39 @@ router.get( id: true, name: true, description: true, - developerName: true, - iconHash: true, - repositoryUrl: true, - createdAt: true, - updatedAt: true, }, }, }, - orderBy: { installedAt: "desc" }, + orderBy: { installedAt: 'desc' }, }); - res.json({ - deviceUuid: device.uuid, - deviceName: device.name, - tokens: installations.map(install => ({ - id: install.id, - token: install.token, - app: install.app, - note: install.note, - installedAt: install.installedAt, - updatedAt: install.updatedAt, - createdAt: install.createdAt, - repositoryUrl: install.app.repositoryUrl, - })), - total: installations.length, - }); - }) -); + const tokens = installations.map(install => ({ + id: install.id, // 安装记录ID + token: install.token, + appId: install.app.id, + appName: install.app.name, + appDescription: install.app.description, + installedAt: install.installedAt, + note: install.note, + })); -/** - * DELETE /apps/tokens/:token - * 撤销特定token - */ -router.delete( - "/tokens/:token", - errors.catchAsync(async (req, res) => { - const { token } = req.params; - - const result = await prisma.appInstall.deleteMany({ - where: { token }, - }); - - if (result.count === 0) { - return res.status(404).json({ - statusCode: 404, - message: "Token不存在", - }); - } - - res.status(204).end(); - }) -); - -/** - * GET /apps/:id/installations - * 获取应用的所有安装记录 - */ -router.get( - "/:id/installations", - errors.catchAsync(async (req, res) => { - const { id: appId } = req.params; - const { limit = 20, skip = 0 } = req.query; - - const [installations, total] = await Promise.all([ - prisma.appInstall.findMany({ - where: { appId: Number(appId) }, - include: { - device: { - select: { - uuid: true, - name: true, - }, - }, - }, - take: parseInt(limit), - skip: parseInt(skip), - orderBy: { installedAt: "desc" }, - }), - prisma.appInstall.count({ where: { appId: Number(appId) } }), - ]); - - res.json({ - appId: Number(appId), - installations: installations.map(install => ({ - id: install.id, - token: install.token, - device: install.device, - note: install.note, - installedAt: install.installedAt, - updatedAt: install.updatedAt, - })), - total, - limit: parseInt(limit), - skip: parseInt(skip), - }); - }) -); - -/** - * PUT /apps/devices/:deviceUuid/password - * 设置或更新设备密码 - * - * Request Body: - * { - * "newPassword": "新密码", - * "passwordHint": "密码提示(可选)", - * "currentPassword": "当前密码(如果已设置密码则必须提供)" - * } - */ -router.put( - "/devices/:deviceUuid/password", - deviceInfoMiddleware, - errors.catchAsync(async (req, res, next) => { - const { newPassword, passwordHint, currentPassword } = req.body; - const device = res.locals.device; - - if (!newPassword) { - return next(errors.createError(400, "请提供新密码")); - } - - // 如果设备已有密码,必须先验证当前密码 - if (device.password) { - if (!currentPassword) { - return next(errors.createError(401, "设备已设置密码,请提供当前密码")); - } - - const isValid = await verifyDevicePassword(currentPassword, device.password); - if (!isValid) { - return next(errors.createError(401, "当前密码错误")); - } - } - - // 哈希新密码 - const hashedPassword = await hashPassword(newPassword); - - // 更新设备密码 - await prisma.device.update({ - where: { id: device.id }, - data: { - password: hashedPassword, - passwordHint: passwordHint || device.passwordHint, - }, - }); - - res.json({ + return res.json({ success: true, - message: device.password ? "密码已更新" : "密码已设置", - deviceUuid: device.uuid, + tokens, + deviceUuid: uuid, }); }) ); - -/** - * DELETE /apps/devices/:deviceUuid/password - * 删除设备密码 - * - * Request Body: - * { - * "password": "当前密码(必须)" - * } - */ -router.delete( - "/devices/:deviceUuid/password", - deviceInfoMiddleware, +router.get("/info/:appid", errors.catchAsync(async (req, res, next) => { - const { password } = req.body; - const device = res.locals.device; - - if (!device.password) { - return next(errors.createError(400, "设备未设置密码")); - } - - if (!password) { - return next(errors.createError(401, "请提供当前密码")); - } - - // 验证密码 - const isValid = await verifyDevicePassword(password, device.password); - if (!isValid) { - return next(errors.createError(401, "密码错误")); - } - - // 删除密码 - await prisma.device.update({ - where: { id: device.id }, - data: { - password: null, - passwordHint: null, - }, - }); - - res.json({ - success: true, - message: "密码已删除", - deviceUuid: device.uuid, + const { appid } = req.params; + const app = await prisma.app.findUnique({ + where: { id: parseInt(appid) }, }); + if (!app) { + return next(errors.createError(404, "应用不存在")); + } + return res.json(app); }) ); - -/** - * POST /apps/devices/:deviceUuid/password/verify - * 验证设备密码 - * - * Request Body: - * { - * "password": "待验证的密码" - * } - */ -router.post( - "/devices/:deviceUuid/password/verify", - deviceInfoMiddleware, - errors.catchAsync(async (req, res, next) => { - const { password } = req.body; - const device = res.locals.device; - - if (!device.password) { - return next(errors.createError(400, "设备未设置密码")); - } - - if (!password) { - return next(errors.createError(400, "请提供密码")); - } - - // 验证密码 - const isValid = await verifyDevicePassword(password, device.password); - - res.json({ - valid: isValid, - }); - }) -); - export default router; \ No newline at end of file diff --git a/routes/device-auth.js b/routes/device-auth.js index cc959b0..66f90a6 100644 --- a/routes/device-auth.js +++ b/routes/device-auth.js @@ -1,14 +1,12 @@ import { Router } from "express"; import deviceCodeStore from "../utils/deviceCodeStore.js"; -import { checkSiteKey } from "../middleware/auth.js"; import errors from "../utils/errors.js"; import { PrismaClient } from "@prisma/client"; const router = Router(); const prisma = new PrismaClient(); -// 应用站点密钥验证 -router.use(checkSiteKey); + /** * POST /device/code diff --git a/routes/device.js b/routes/device.js new file mode 100644 index 0000000..f4f4c1d --- /dev/null +++ b/routes/device.js @@ -0,0 +1,327 @@ +import { Router } from "express"; +const router = Router(); +import { uuidAuth } from "../middleware/uuidAuth.js"; +import { PrismaClient } from "@prisma/client"; +import crypto from "crypto"; +import errors from "../utils/errors.js"; +import { hashPassword, verifyDevicePassword } from "../utils/crypto.js"; + +const prisma = new PrismaClient(); + +/** + * POST /devices + * 注册新设备 + */ +router.post( + "/", + errors.catchAsync(async (req, res, next) => { + const { uuid, deviceName } = req.body; + + if (!uuid) { + return next(errors.createError(400, "设备UUID是必需的")); + } + + if (!deviceName) { + return next(errors.createError(400, "设备名称是必需的")); + } + + // 检查UUID是否已存在 + const existingDevice = await prisma.device.findUnique({ + where: { uuid }, + }); + + if (existingDevice) { + return next(errors.createError(409, "设备UUID已存在")); + } + + // 创建设备 + const device = await prisma.device.create({ + data: { + uuid, + name: deviceName, + }, + }); + + return res.status(201).json({ + success: true, + device: { + id: device.id, + uuid: device.uuid, + name: device.name, + createdAt: device.createdAt, + }, + }); + }) +); + +/** + * GET /devices/:uuid + * 获取设备信息 (公开接口,无需认证) + */ +router.get( + "/:uuid", + errors.catchAsync(async (req, res, next) => { + const { uuid } = req.params; + + // 查找设备,包含绑定的账户信息 + const device = await prisma.device.findUnique({ + where: { uuid }, + include: { + account: { + select: { + id: true, + name: true, + email: true, + avatarUrl: true, + }, + }, + }, + }); + + if (!device) { + return next(errors.createError(404, "设备不存在")); + } + + return res.json({ + id: device.id, + uuid: device.uuid, + name: device.name, + hasPassword: !!device.password, + passwordHint: device.passwordHint, + createdAt: device.createdAt, + account: device.account ? { + id: device.account.id, + name: device.account.name, + email: device.account.email, + avatarUrl: device.account.avatarUrl, + } : null, + isBoundToAccount: !!device.account, + }); + }) +);/** + * PUT /devices/:uuid/name + * 设置设备名称 (需要UUID认证) + */ +router.put( + "/:uuid/name", + uuidAuth, + errors.catchAsync(async (req, res, next) => { + const { name } = req.body; + const device = res.locals.device; + + if (!name) { + return next(errors.createError(400, "设备名称是必需的")); + } + + const updatedDevice = await prisma.device.update({ + where: { id: device.id }, + data: { name }, + }); + + return res.json({ + success: true, + device: { + id: updatedDevice.id, + uuid: updatedDevice.uuid, + name: updatedDevice.name, + hasPassword: !!updatedDevice.password, + passwordHint: updatedDevice.passwordHint, + }, + }); + }) +); + +/** + * POST /devices/:uuid/password + * 初次设置设备密码 (无需认证,仅当设备未设置密码时) + */ +router.post( + "/:uuid/password", + errors.catchAsync(async (req, res, next) => { + const { uuid } = req.params; + const newPassword = req.query.newPassword || req.body.newPassword; + const passwordHint = req.query.passwordHint || req.body.passwordHint; + + if (!newPassword) { + return next(errors.createError(400, "新密码是必需的")); + } + + // 查找设备 + const device = await prisma.device.findUnique({ + where: { uuid }, + }); + + if (!device) { + return next(errors.createError(404, "设备不存在")); + } + + // 只有在设备未设置密码时才允许无认证设置 + if (device.password) { + return next(errors.createError(403, "设备已设置密码,请使用修改密码接口")); + } + + const hashedPassword = await hashPassword(newPassword); + + await prisma.device.update({ + where: { id: device.id }, + data: { + password: hashedPassword, + passwordHint: passwordHint || null, + }, + }); + + return res.json({ + success: true, + message: "密码设置成功", + }); + }) +); + +/** + * PUT /devices/:uuid/password + * 修改设备密码 (需要UUID认证和当前密码验证,账户拥有者除外) + */ +router.put( + "/:uuid/password", + uuidAuth, + errors.catchAsync(async (req, res, next) => { + const currentPassword = req.query.currentPassword; + const newPassword = req.query.newPassword || req.body.newPassword; + const passwordHint = req.query.passwordHint || req.body.passwordHint; + const device = res.locals.device; + const isAccountOwner = res.locals.isAccountOwner; + + if (!newPassword) { + return next(errors.createError(400, "新密码是必需的")); + } + + // 如果是账户拥有者,无需验证当前密码 + if (!isAccountOwner) { + if (!device.password) { + return next(errors.createError(400, "设备未设置密码,请使用设置密码接口")); + } + + if (!currentPassword) { + return next(errors.createError(400, "当前密码是必需的")); + } + + // 验证当前密码 + const isCurrentPasswordValid = await verifyDevicePassword(currentPassword, device.password); + if (!isCurrentPasswordValid) { + return next(errors.createError(401, "当前密码错误")); + } + } + + const hashedNewPassword = await hashPassword(newPassword); + + await prisma.device.update({ + where: { id: device.id }, + data: { + password: hashedNewPassword, + passwordHint: passwordHint !== undefined ? passwordHint : device.passwordHint, + }, + }); + + return res.json({ + success: true, + message: "密码修改成功", + }); + }) +); + +/** + * PUT /devices/:uuid/password-hint + * 设置密码提示 (需要UUID认证) + */ +router.put( + "/:uuid/password-hint", + uuidAuth, + errors.catchAsync(async (req, res, next) => { + const { passwordHint } = req.body; + const device = res.locals.device; + + await prisma.device.update({ + where: { id: device.id }, + data: { passwordHint: passwordHint || null }, + }); + + return res.json({ + success: true, + message: "密码提示设置成功", + passwordHint: passwordHint || null, + }); + }) +); + +/** + * GET /devices/:uuid/password-hint + * 获取设备密码提示 (无需认证) + */ +router.get( + "/:uuid/password-hint", + errors.catchAsync(async (req, res, next) => { + const { uuid } = req.params; + + const device = await prisma.device.findUnique({ + where: { uuid }, + select: { + passwordHint: true, + }, + }); + + if (!device) { + return next(errors.createError(404, "设备不存在")); + } + + return res.json({ + success: true, + passwordHint: device.passwordHint || null, + }); + }) +); + +/** + * DELETE /devices/:uuid/password + * 删除设备密码 (需要UUID认证和密码验证,账户拥有者除外) + */ +router.delete( + "/:uuid/password", + uuidAuth, + errors.catchAsync(async (req, res, next) => { + const password = req.query.password; + const device = res.locals.device; + const isAccountOwner = res.locals.isAccountOwner; + + if (!device.password) { + return next(errors.createError(400, "设备未设置密码")); + } + + // 如果不是账户拥有者,需要验证密码 + if (!isAccountOwner) { + if (!password) { + return next(errors.createError(400, "密码是必需的")); + } + + // 验证密码 + const isPasswordValid = await verifyDevicePassword(password, device.password); + if (!isPasswordValid) { + return next(errors.createError(401, "密码错误")); + } + } + + await prisma.device.update({ + where: { id: device.id }, + data: { + password: null, + passwordHint: null, + }, + }); + + return res.json({ + success: true, + message: "密码删除成功", + }); + }) +); + +export default router; \ No newline at end of file diff --git a/routes/kv-token.js b/routes/kv-token.js index 4852abd..8bd9902 100644 --- a/routes/kv-token.js +++ b/routes/kv-token.js @@ -1,13 +1,11 @@ import { Router } from "express"; const router = Router(); import kvStore from "../utils/kvStore.js"; -import { tokenAuth } from "../middleware/tokenAuth.js"; +import { kvTokenAuth } from "../middleware/kvTokenAuth.js"; import errors from "../utils/errors.js"; -import { checkSiteKey } from "../middleware/auth.js"; -// 应用站点密钥验证和token认证 -router.use(checkSiteKey); -router.use(tokenAuth); +// 使用KV专用token认证 +router.use(kvTokenAuth); /** * GET /_keys diff --git a/test/docker/docker-compose.yml b/test/docker/docker-compose.yml deleted file mode 100644 index 383f836..0000000 --- a/test/docker/docker-compose.yml +++ /dev/null @@ -1,69 +0,0 @@ -version: '3.8' - -services: - mysql: - image: mysql:8.0 - container_name: classworks_mysql - restart: unless-stopped - environment: - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-classworks} - MYSQL_DATABASE: ${MYSQL_DATABASE:-classworks} - MYSQL_USER: ${MYSQL_USER:-classworks} - MYSQL_PASSWORD: ${MYSQL_PASSWORD:-classworks} - TZ: Asia/Shanghai - ports: - - "3306:3306" - volumes: - - mysql_data:/var/lib/mysql - - ./mysql/conf.d:/etc/mysql/conf.d:ro - - ./mysql/initdb.d:/docker-entrypoint-initdb.d:ro - command: - - --character-set-server=utf8mb4 - - --collation-server=utf8mb4_unicode_ci - - --default-authentication-plugin=mysql_native_password - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u$$MYSQL_USER", "-p$$MYSQL_PASSWORD"] - interval: 10s - timeout: 5s - retries: 5 - networks: - - classworks_net - - postgres: - image: postgres:15-alpine - container_name: classworks_postgres - restart: unless-stopped - environment: - POSTGRES_DB: ${POSTGRES_DB:-classworks} - POSTGRES_USER: ${POSTGRES_USER:-classworks} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-classworks} - TZ: Asia/Shanghai - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - - ./postgres/initdb.d:/docker-entrypoint-initdb.d:ro - command: - - "postgres" - - "-c" - - "max_connections=100" - - "-c" - - "shared_buffers=128MB" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] - interval: 10s - timeout: 5s - retries: 5 - networks: - - classworks_net - -volumes: - mysql_data: - name: classworks_mysql_data - postgres_data: - name: classworks_postgres_data - -networks: - classworks_net: - name: classworks_network - driver: bridge \ No newline at end of file diff --git a/test/docker/mysql/conf.d/my.cnf b/test/docker/mysql/conf.d/my.cnf deleted file mode 100644 index 0384d42..0000000 --- a/test/docker/mysql/conf.d/my.cnf +++ /dev/null @@ -1,33 +0,0 @@ -[mysqld] -# 字符集设置 -character-set-server=utf8mb4 -collation-server=utf8mb4_unicode_ci - -# 连接设置 -max_connections=100 -max_allowed_packet=64M - -# InnoDB设置 -innodb_buffer_pool_size=256M -innodb_log_file_size=64M -innodb_flush_log_at_trx_commit=2 -innodb_flush_method=O_DIRECT - -# 优化设置 -query_cache_type=1 -query_cache_size=32M -sort_buffer_size=4M -read_buffer_size=2M -read_rnd_buffer_size=4M -join_buffer_size=2M - -# 日志设置 -slow_query_log=1 -slow_query_log_file=/var/log/mysql/slow.log -long_query_time=2 - -[client] -default-character-set=utf8mb4 - -[mysql] -default-character-set=utf8mb4 \ No newline at end of file diff --git a/test/docker/mysql/initdb.d/init.sql b/test/docker/mysql/initdb.d/init.sql deleted file mode 100644 index f96ed98..0000000 --- a/test/docker/mysql/initdb.d/init.sql +++ /dev/null @@ -1,12 +0,0 @@ --- 设置时区 -SET GLOBAL time_zone = '+8:00'; -SET time_zone = '+8:00'; - --- 创建数据库(如果不存在) -CREATE DATABASE IF NOT EXISTS classworks - CHARACTER SET utf8mb4 - COLLATE utf8mb4_unicode_ci; - --- 设置权限 -GRANT ALL PRIVILEGES ON classworks.* TO 'classworks'@'%'; -FLUSH PRIVILEGES; \ No newline at end of file diff --git a/test/docker/postgres/initdb.d/init.sql b/test/docker/postgres/initdb.d/init.sql deleted file mode 100644 index 2507d7a..0000000 --- a/test/docker/postgres/initdb.d/init.sql +++ /dev/null @@ -1,10 +0,0 @@ --- 创建扩展 -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE EXTENSION IF NOT EXISTS "pg_trgm"; - --- 设置时区 -SET timezone = 'Asia/Shanghai'; - --- 设置默认权限 -ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO classworks; -ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO classworks; \ No newline at end of file diff --git a/utils/jwt.js b/utils/jwt.js new file mode 100644 index 0000000..70eb47b --- /dev/null +++ b/utils/jwt.js @@ -0,0 +1,40 @@ +import jwt from 'jsonwebtoken'; + +// JWT密钥 - 生产环境应该从环境变量读取 +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this-in-production'; +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; // 默认7天过期 + +/** + * 签发JWT token + * @param {Object} payload - 要编码的数据 + * @returns {string} JWT token + */ +export function signToken(payload) { + return jwt.sign(payload, JWT_SECRET, { + expiresIn: JWT_EXPIRES_IN, + }); +} + +/** + * 验证JWT token + * @param {string} token - JWT token + * @returns {Object} 解码后的payload + */ +export function verifyToken(token) { + return jwt.verify(token, JWT_SECRET); +} + +/** + * 为账户生成JWT token + * @param {Object} account - 账户对象 + * @returns {string} JWT token + */ +export function generateAccountToken(account) { + return signToken({ + accountId: account.id, + provider: account.provider, + email: account.email, + name: account.name, + avatarUrl: account.avatarUrl, + }); +} \ No newline at end of file