diff --git a/backend/routes/external.js b/backend/routes/external.js index 01f8c49..d50ea8d 100644 --- a/backend/routes/external.js +++ b/backend/routes/external.js @@ -2,46 +2,31 @@ const express = require('express'); const bcrypt = require('bcryptjs'); const crypto = require('crypto'); const { v4: uuid } = require('uuid'); -const { query, getRow, getConfig } = require('../db'); -const { generateToken, authenticate } = require('../middleware/auth'); +const { query, getRow } = require('../db'); +const { authenticate } = require('../middleware/auth'); const { sendEmail } = require('../mailer'); const { logSystem } = require('../logger'); const router = express.Router(); // ============ 鉴权 ============ -// 支持三种方式(免用户登录): -// 1. x-external-key: 旧版单 key(config.external_api_key) -// 2. x-api-client-id + x-api-secret: api_clients 表多客户端(推荐) -// 3. Authorization: Bearer : 用户登录态(插件用) - -function getExternalKey() { - try { return getConfig()?.external_api_key || process.env.EXTERNAL_API_KEY || ''; } catch { return process.env.EXTERNAL_API_KEY || ''; } -} +// 外部 API 唯一鉴权方式: ID + Secret(api_clients 表) +// 无凭据 / 凭据错误 / 客户端停用 → 一律 401(不允许匿名访问) async function clientAuth(req, res, next) { - // 方式1: 旧版单 key - const legacy = getExternalKey(); - if (legacy && req.headers['x-external-key'] === legacy) return next(); - - // 方式2: client_id + secret const cid = req.headers['x-api-client-id']; const secret = req.headers['x-api-secret']; - if (cid && secret) { - try { - const client = await getRow('SELECT * FROM api_clients WHERE client_id = ?', [cid]); - if (client && client.active && bcrypt.compareSync(secret, client.secret_hash)) return next(); - } catch {} - return res.status(401).json({ error: '客户端鉴权失败' }); + if (!cid || !secret) return res.status(401).json({ error: '未授权: 缺少 x-api-client-id / x-api-secret' }); + try { + const client = await getRow('SELECT * FROM api_clients WHERE client_id = ?', [cid]); + if (!client || !client.active || !bcrypt.compareSync(secret, client.secret_hash)) { + return res.status(401).json({ error: '客户端鉴权失败' }); + } + req.apiClient = client; + next(); + } catch (e) { + res.status(500).json({ error: '鉴权服务异常' }); } - - // 方式3: 用户 JWT(交给 authenticate 处理, 这里放行) - if (req.headers['authorization']?.startsWith('Bearer ')) return next(); - - // 方式1 配置了 key 但没带 → 拒绝(避免裸奔) - if (legacy) return res.status(401).json({ error: '未授权' }); - // 什么都没配置: 允许(兼容旧部署) - return next(); } // 服务器 alias 解析: '分组/服务器' 或 alias 或 server_name @@ -97,7 +82,10 @@ router.delete('/clients/:id', authenticate, async (req, res) => { } catch (e) { res.status(500).json({ error: e.message }); } }); -// ============ Auth (no external key needed) ============ +// ---- 以下接口: 外部鉴权(client_id + secret, 唯一方式) ---- +router.use(clientAuth); + +// ============ 外部注册(需客户端鉴权) ============ router.post('/auth/register', async (req, res) => { try { const { username, password, email, game_name, game_uid, source } = req.body; @@ -125,21 +113,6 @@ router.post('/auth/register', async (req, res) => { } catch (e) { res.status(500).json({ error: '服务器错误' }); } }); -router.post('/auth/login', async (req, res) => { - try { - const { username, password } = req.body; - if (!username || !password) return res.status(400).json({ error: '请输入用户名和密码' }); - const user = await getRow('SELECT * FROM users WHERE username = ?', [username]); - if (!user || !(await bcrypt.compare(password, user.password))) return res.status(401).json({ error: '用户名或密码错误' }); - if (!user.active) return res.status(403).json({ error: '账号未激活' }); - const token = generateToken(user); - res.json({ token, user: { id:user.id, username:user.username, game_name:user.game_name, game_uid:user.game_uid, role:user.role, source:user.source } }); - } catch (e) { res.status(500).json({ error: '服务器错误' }); } -}); - -// ---- 以下接口: 免用户登录(client_id+secret / x-external-key / JWT) ---- -router.use(clientAuth); - // ============ 工单: 提交 ============ // server: '分组/服务器名' 或 alias 或 server_name(可选) router.post('/tickets', async (req, res) => { @@ -177,21 +150,6 @@ router.get('/tickets/track', async (req, res) => { } catch (e) { res.status(500).json({ error: e.message }); } }); -// 我的工单(JWT 用户) -router.get('/my-tickets', authenticate, async (req, res) => { - const { limit } = req.query; - const rows = await query(`SELECT id, type, title, status, priority, server_name, created_at, updated_at FROM tickets WHERE user_id = ? ORDER BY updated_at DESC LIMIT ?`, [req.user.id, Math.min(parseInt(limit)||10, 50)]); - res.json({ list: rows }); -}); - -router.get('/my-tickets/:id', authenticate, async (req, res) => { - const t = await getRow(`SELECT id, type, title, status, priority, reporter_game_name, reporter_game_uid, - target_game_name, target_game_uid, reason, description, assigned_to, claim_note, server_name, created_at, updated_at - FROM tickets WHERE id = ? AND user_id = ?`, [req.params.id, req.user.id]); - if (!t) return res.status(404).json({ error: '工单不存在' }); - res.json(t); -}); - // ============ 工单列表(带 server 过滤) ============ router.get('/all-tickets', async (req, res) => { const { type, status, server, page, limit } = req.query; diff --git a/docs/API.md b/docs/API.md index cc4884d..95ee1d9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -331,10 +331,7 @@ Base URL: `http://:3100/api` | 端点 | 认证 | 说明 | |------|------|------| -| POST /external/auth/register | — | 插件注册(邮箱验证链接) | -| POST /external/auth/login | — | 插件登录,返回 JWT | -| GET /external/my-tickets | Bearer | 我的工单列表(limit ≤50) | -| GET /external/my-tickets/:id | Bearer | 我的工单详情 | +| POST /external/auth/register | client | 插件注册(需客户端凭据;邮箱验证链接) | | POST /external/tickets | client | 提交工单(带 `server` 可选;返回 id + tracking_token) | | GET /external/tickets/track?token= | client | 按追踪码查工单状态+回复(提交→处理→结束全流程) | | GET /external/all-tickets | client | 全部工单(分页 page/limit ≤200,可按 `server`/`status`/`type` 过滤) | @@ -349,13 +346,11 @@ Base URL: `http://:3100/api` | PUT /external/clients/:id | Bearer(owner/admin) | 启用/停用客户端 | | DELETE /external/clients/:id | Bearer(owner/admin) | 删除客户端 | -### 外部鉴权(三选一) +### 外部鉴权(唯一方式) -| 方式 | 请求头 | 适用 | +| 方式 | 请求头 | 说明 | |------|--------|------| -| 多客户端(推荐) | `x-api-client-id` + `x-api-secret` | QQ机器人等外部系统,后台可创建/停用多个客户端 | -| 旧版单 key | `x-external-key` | 兼容旧部署(`config.external_api_key`) | -| 用户 JWT | `Authorization: Bearer ` | 插件/已登录用户 | +| ID + Secret(唯一) | `x-api-client-id` + `x-api-secret` | 后台可创建/停用多个客户端;无凭据或凭据错误一律 401 | ### 子服务器定位(`server` 参数) diff --git a/docs/EXTERNAL-API.md b/docs/EXTERNAL-API.md index 45015ff..857097e 100644 --- a/docs/EXTERNAL-API.md +++ b/docs/EXTERNAL-API.md @@ -8,9 +8,7 @@ --- -## 一、鉴权(三种方式,任选其一) - -### 方式 A:ID + Secret(推荐,多客户端) +## 一、鉴权(唯一方式:ID + Secret) 在站点后台 →「外部API」页面创建客户端,获得一对凭据: @@ -20,6 +18,7 @@ Secret: s_9f8e7d6c5b4a39281726354a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e ``` > ⚠️ Secret 只在创建时显示一次,请立即保存。支持创建多个客户端、单独停用/删除,互不影响。 +> 无凭据 / 凭据错误 / 客户端停用 → 一律返回 `401`。 调用时在请求头携带: @@ -28,18 +27,6 @@ Secret: s_9f8e7d6c5b4a39281726354a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e | `x-api-client-id` | 你的 Client ID | | `x-api-secret` | 你的 Secret | -### 方式 B:旧版单 Key(兼容) - -| 请求头 | 值 | -|--------|-----| -| `x-external-key` | 部署时配置的 external_api_key | - -### 方式 C:用户 JWT(仅限插件) - -| 请求头 | 值 | -|--------|-----| -| `Authorization` | `Bearer `(来自 `POST /api/external/auth/login`) | - --- ## 二、快速开始(Python / Node 示例) @@ -324,9 +311,10 @@ GET /api/external/stats?server= ## 六、鉴权失败排查 -1. **401 未授权**:检查 `x-api-client-id` / `x-api-secret` 是否与创建时一致;客户端是否被停用;secret 是否完整无换行空格。 -2. **客户端被停用**:后台「外部API」→ 启用。 -3. **需要多个客户端**:后台可创建多个,分别用于机器人 / 插件 / 统计面板,互不影响;删除即立即失效。 +1. **401 缺少凭据**:请求必须携带 `x-api-client-id` 与 `x-api-secret` 两个请求头。 +2. **401 客户端鉴权失败**:检查 Client ID / Secret 是否与创建时一致;secret 是否完整无换行空格;客户端是否被停用。 +3. **客户端被停用**:后台「外部API」→ 启用。 +4. **需要多个客户端**:后台可创建多个,分别用于机器人 / 插件 / 统计面板,互不影响;删除即立即失效。 --- diff --git a/public/js/pages/external-api.js b/public/js/pages/external-api.js index bdc9234..8a58cc9 100644 --- a/public/js/pages/external-api.js +++ b/public/js/pages/external-api.js @@ -25,7 +25,7 @@ const ExternalApiPage = { `).join('')}`} -
鉴权方式: 请求头 x-api-client-id + x-api-secret(推荐);旧版单 key 仍可用 x-external-key。secret 仅创建时显示一次,请立即保存。
+
鉴权方式(唯一): 请求头 x-api-client-id + x-api-secret。无凭据或凭据错误一律返回 401;secret 仅创建时显示一次,请立即保存。支持多个客户端,可单独停用/删除。
接口速览(免用户登录, 需上述鉴权头)
方法路径说明