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:
@@ -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 <jwt>: 用户登录态(插件用)
|
||||
|
||||
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) {
|
||||
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 next();
|
||||
} catch {}
|
||||
if (!client || !client.active || !bcrypt.compareSync(secret, client.secret_hash)) {
|
||||
return res.status(401).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();
|
||||
req.apiClient = client;
|
||||
next();
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: '鉴权服务异常' });
|
||||
}
|
||||
}
|
||||
|
||||
// 服务器 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;
|
||||
|
||||
13
docs/API.md
13
docs/API.md
@@ -331,10 +331,7 @@ Base URL: `http://<host>: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://<host>: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 <jwt>` | 插件/已登录用户 |
|
||||
| ID + Secret(唯一) | `x-api-client-id` + `x-api-secret` | 后台可创建/停用多个客户端;无凭据或凭据错误一律 401 |
|
||||
|
||||
### 子服务器定位(`server` 参数)
|
||||
|
||||
|
||||
@@ -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 <JWT>`(来自 `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. **需要多个客户端**:后台可创建多个,分别用于机器人 / 插件 / 统计面板,互不影响;删除即立即失效。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
</td>
|
||||
</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 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>
|
||||
|
||||
Reference in New Issue
Block a user