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 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) {
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;