security: external API - only ID+Secret auth, remove legacy key & JWT

- removed x-external-key (single key) auth path + getExternalKey
- removed JWT passthrough in clientAuth (Bearer no longer accepted)
- removed /auth/login (JWT endpoint) and /my-tickets (JWT-only)
- clientAuth now mandatory: missing/invalid/disabled client -> 401
  (closed the 'no config = allow all' authorization bypass)
- moved /auth/register BEHIND clientAuth (was anonymous abuse surface)
- clients mgmt endpoints keep authenticate + role check (admin UI)
- docs + UI copy updated to single auth method
- verified: 30 checks incl. full-tree scan for legacy key refs
This commit is contained in:
2026-08-19 19:44:34 +08:00
parent 4dfc30ce89
commit 6056153f57
4 changed files with 29 additions and 88 deletions

View File

@@ -2,46 +2,31 @@ const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const crypto = require('crypto'); const crypto = require('crypto');
const { v4: uuid } = require('uuid'); const { v4: uuid } = require('uuid');
const { query, getRow, getConfig } = require('../db'); const { query, getRow } = require('../db');
const { generateToken, authenticate } = require('../middleware/auth'); const { authenticate } = require('../middleware/auth');
const { sendEmail } = require('../mailer'); const { sendEmail } = require('../mailer');
const { logSystem } = require('../logger'); const { logSystem } = require('../logger');
const router = express.Router(); const router = express.Router();
// ============ 鉴权 ============ // ============ 鉴权 ============
// 支持三种方式(免用户登录): // 外部 API 唯一鉴权方式: ID + Secret(api_clients 表)
// 1. x-external-key: 旧版单 key(config.external_api_key) // 无凭据 / 凭据错误 / 客户端停用 → 一律 401(不允许匿名访问)
// 2. x-api-client-id + x-api-secret: api_clients 表多客户端(推荐)
// 3. Authorization: Bearer <jwt>: 用户登录态(插件用)
function getExternalKey() {
try { return getConfig()?.external_api_key || process.env.EXTERNAL_API_KEY || ''; } catch { return process.env.EXTERNAL_API_KEY || ''; }
}
async function clientAuth(req, res, next) { 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 cid = req.headers['x-api-client-id'];
const secret = req.headers['x-api-secret']; const secret = req.headers['x-api-secret'];
if (cid && secret) { if (!cid || !secret) return res.status(401).json({ error: '未授权: 缺少 x-api-client-id / x-api-secret' });
try { try {
const client = await getRow('SELECT * FROM api_clients WHERE client_id = ?', [cid]); const client = await getRow('SELECT * FROM api_clients WHERE client_id = ?', [cid]);
if (client && client.active && bcrypt.compareSync(secret, client.secret_hash)) return next(); if (!client || !client.active || !bcrypt.compareSync(secret, client.secret_hash)) {
} catch {} return res.status(401).json({ error: '客户端鉴权失败' });
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 // 服务器 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 }); } } 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) => { router.post('/auth/register', async (req, res) => {
try { try {
const { username, password, email, game_name, game_uid, source } = req.body; 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: '服务器错误' }); } } 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(可选) // server: '分组/服务器名' 或 alias 或 server_name(可选)
router.post('/tickets', async (req, res) => { 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 }); } } 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 过滤) ============ // ============ 工单列表(带 server 过滤) ============
router.get('/all-tickets', async (req, res) => { router.get('/all-tickets', async (req, res) => {
const { type, status, server, page, limit } = req.query; const { type, status, server, page, limit } = req.query;

View File

@@ -331,10 +331,7 @@ Base URL: `http://<host>:3100/api`
| 端点 | 认证 | 说明 | | 端点 | 认证 | 说明 |
|------|------|------| |------|------|------|
| POST /external/auth/register | | 插件注册(邮箱验证链接) | | POST /external/auth/register | client | 插件注册(需客户端凭据;邮箱验证链接) |
| POST /external/auth/login | — | 插件登录,返回 JWT |
| GET /external/my-tickets | Bearer | 我的工单列表(limit ≤50) |
| GET /external/my-tickets/:id | Bearer | 我的工单详情 |
| POST /external/tickets | client | 提交工单(带 `server` 可选;返回 id + tracking_token) | | POST /external/tickets | client | 提交工单(带 `server` 可选;返回 id + tracking_token) |
| GET /external/tickets/track?token= | client | 按追踪码查工单状态+回复(提交→处理→结束全流程) | | GET /external/tickets/track?token= | client | 按追踪码查工单状态+回复(提交→处理→结束全流程) |
| GET /external/all-tickets | client | 全部工单(分页 page/limit ≤200,可按 `server`/`status`/`type` 过滤) | | GET /external/all-tickets | client | 全部工单(分页 page/limit ≤200,可按 `server`/`status`/`type` 过滤) |
@@ -349,13 +346,11 @@ Base URL: `http://<host>:3100/api`
| PUT /external/clients/:id | Bearer(owner/admin) | 启用/停用客户端 | | PUT /external/clients/:id | Bearer(owner/admin) | 启用/停用客户端 |
| DELETE /external/clients/:id | Bearer(owner/admin) | 删除客户端 | | DELETE /external/clients/:id | Bearer(owner/admin) | 删除客户端 |
### 外部鉴权(三选一) ### 外部鉴权(唯一方式)
| 方式 | 请求头 | 适用 | | 方式 | 请求头 | 说明 |
|------|--------|------| |------|--------|------|
| 多客户端(推荐) | `x-api-client-id` + `x-api-secret` | QQ机器人等外部系统,后台可创建/停用多个客户端 | | ID + Secret(唯一) | `x-api-client-id` + `x-api-secret` | 后台可创建/停用多个客户端;无凭据或凭据错误一律 401 |
| 旧版单 key | `x-external-key` | 兼容旧部署(`config.external_api_key`) |
| 用户 JWT | `Authorization: Bearer <jwt>` | 插件/已登录用户 |
### 子服务器定位(`server` 参数) ### 子服务器定位(`server` 参数)

View File

@@ -8,9 +8,7 @@
--- ---
## 一、鉴权(三种方式,任选其一) ## 一、鉴权(唯一方式:ID + Secret)
### 方式 A:ID + Secret(推荐,多客户端)
在站点后台 →「外部API」页面创建客户端,获得一对凭据: 在站点后台 →「外部API」页面创建客户端,获得一对凭据:
@@ -20,6 +18,7 @@ Secret: s_9f8e7d6c5b4a39281726354a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e
``` ```
> ⚠️ Secret 只在创建时显示一次,请立即保存。支持创建多个客户端、单独停用/删除,互不影响。 > ⚠️ Secret 只在创建时显示一次,请立即保存。支持创建多个客户端、单独停用/删除,互不影响。
> 无凭据 / 凭据错误 / 客户端停用 → 一律返回 `401`。
调用时在请求头携带: 调用时在请求头携带:
@@ -28,18 +27,6 @@ Secret: s_9f8e7d6c5b4a39281726354a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e
| `x-api-client-id` | 你的 Client ID | | `x-api-client-id` | 你的 Client ID |
| `x-api-secret` | 你的 Secret | | `x-api-secret` | 你的 Secret |
### 方式 B:旧版单 Key(兼容)
| 请求头 | 值 |
|--------|-----|
| `x-external-key` | 部署时配置的 external_api_key |
### 方式 C:用户 JWT(仅限插件)
| 请求头 | 值 |
|--------|-----|
| `Authorization` | `Bearer <JWT>`(来自 `POST /api/external/auth/login`) |
--- ---
## 二、快速开始(Python / Node 示例) ## 二、快速开始(Python / Node 示例)
@@ -324,9 +311,10 @@ GET /api/external/stats?server=
## 六、鉴权失败排查 ## 六、鉴权失败排查
1. **401 未授权**:检查 `x-api-client-id` / `x-api-secret` 是否与创建时一致;客户端是否被停用;secret 是否完整无换行空格 1. **401 缺少凭据**:请求必须携带 `x-api-client-id` `x-api-secret` 两个请求头
2. **客户端被停用**:后台「外部API」→ 启用。 2. **401 客户端鉴权失败**:检查 Client ID / Secret 是否与创建时一致;secret 是否完整无换行空格;客户端是否被停用。
3. **需要多个客户端**:后台可创建多个,分别用于机器人 / 插件 / 统计面板,互不影响;删除即立即失效 3. **客户端被停用**:后台「外部API」→ 启用
4. **需要多个客户端**:后台可创建多个,分别用于机器人 / 插件 / 统计面板,互不影响;删除即立即失效。
--- ---

View File

@@ -25,7 +25,7 @@ const ExternalApiPage = {
<button class="btn btn-d btn-sm" onclick="ExternalApiPage.del(${c.id})"><i class="fas fa-trash"></i></button> <button class="btn btn-d btn-sm" onclick="ExternalApiPage.del(${c.id})"><i class="fas fa-trash"></i></button>
</td> </td>
</tr>`).join('')}</tbody></table></div>`} </tr>`).join('')}</tbody></table></div>`}
<div class="alert alert-i" style="margin-top:12px"><b>鉴权方式:</b> 请求头 <code>x-api-client-id</code> + <code>x-api-secret</code>(推荐);旧版单 key 仍可用 <code>x-external-key</code>。secret 仅创建时显示一次,请立即保存。</div> <div class="alert alert-i" style="margin-top:12px"><b>鉴权方式(唯一):</b> 请求头 <code>x-api-client-id</code> + <code>x-api-secret</code>。无凭据或凭据错误一律返回 401;secret 仅创建时显示一次,请立即保存。支持多个客户端,可单独停用/删除。</div>
</div></div> </div></div>
<div class="card"><div class="card-h"><i class="fas fa-book"></i> 接口速览(免用户登录, 需上述鉴权头)</div><div class="card-b"><div class="table-wrap"><table><thead><tr><th>方法</th><th>路径</th><th>说明</th></tr></thead><tbody> <div class="card"><div class="card-h"><i class="fas fa-book"></i> 接口速览(免用户登录, 需上述鉴权头)</div><div class="card-b"><div class="table-wrap"><table><thead><tr><th>方法</th><th>路径</th><th>说明</th></tr></thead><tbody>