refactor: session-based external auth, dynamic sources, drop netease UID

Auth (external API):
- ID: 16-digit random (non-sequential); Secret: SeaReport- + 32 hex
- POST /auth/session: ID+Secret -> Bearer SESSION (24h, single-session,
  old session invalidated on re-issue, disabled client invalidates)
- clientAuth now validates Bearer SESSION via api_sessions JOIN api_clients

Sources (dynamic, no default, open-source friendly):
- sources table + CRUD route (/api/sources, owner; delete guarded by usage)
- users/user_identities.source ENUM -> VARCHAR, seeded netease/skin
- register/admin create/identity bind: validate against enabled sources
- UI: 来源管理 page; source dropdowns loaded dynamically everywhere
  (register, dashboard identity, users admin, bans), labels dynamic

UID removal:
- game_uid/reporter_game_uid no longer required (db default '', validations
  dropped, frontend fields optional)

Docs: EXTERNAL-API.md session flow + new credential format; API.md updated
Verified: 37 checks (syntax, session logic, source CRUD, UID removal, docs)
This commit is contained in:
2026-08-19 20:08:06 +08:00
parent 6056153f57
commit 65edbaf157
19 changed files with 497 additions and 103 deletions

View File

@@ -10,19 +10,46 @@ const { logSystem } = require('../logger');
const router = express.Router();
// ============ 鉴权 ============
// 外部 API 唯一鉴权方式: ID + Secret(api_clients 表)
// 无凭据 / 凭据错误 / 客户端停用 → 一律 401(不允许匿名访问)
// 流程: 客户端用 ID + Secret 换取 SESSION(短期有效), 之后所有请求用
// Authorization: Bearer <SESSION>
// 无 SESSION / SESSION 无效或过期 / 客户端停用 → 一律 401
const SESSION_TTL_HOURS = 24;
function genClientId() {
// 16 位纯数字, 随机不递增
let id = '';
for (let i = 0; i < 16; i++) id += crypto.randomInt(0, 10);
return id;
}
function genSecret() {
// SeaReport- + 32 位随机字符串
return 'SeaReport-' + crypto.randomBytes(16).toString('hex'); // 16 bytes = 32 hex chars
}
async function createSession(clientId) {
const token = crypto.randomBytes(48).toString('hex');
await query(
'INSERT INTO api_sessions(client_id, session_token, expires_at) VALUES (?,?,DATE_ADD(NOW(), INTERVAL ? HOUR))',
[clientId, token, SESSION_TTL_HOURS]
);
return { session_token: token, expires_in: SESSION_TTL_HOURS * 3600 };
}
async function clientAuth(req, res, next) {
const cid = req.headers['x-api-client-id'];
const secret = req.headers['x-api-secret'];
if (!cid || !secret) return res.status(401).json({ error: '未授权: 缺少 x-api-client-id / x-api-secret' });
const h = req.headers.authorization;
if (!h || !h.startsWith('Bearer ')) return res.status(401).json({ error: '未授权: 缺少 Authorization: Bearer <SESSION>' });
const token = h.split(' ')[1];
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;
const row = await getRow(
`SELECT s.client_id, s.expires_at, c.active AS client_active
FROM api_sessions s JOIN api_clients c ON c.client_id = s.client_id
WHERE s.session_token = ? AND s.expires_at > NOW()`,
[token]
);
if (!row || !row.client_active) return res.status(401).json({ error: 'SESSION 无效或已过期' });
req.apiClient = { client_id: row.client_id };
next();
} catch (e) {
res.status(500).json({ error: '鉴权服务异常' });
@@ -47,8 +74,8 @@ router.post('/clients', authenticate, async (req, res) => {
if (!['owner','admin'].includes(req.user.role)) return res.status(403).json({ error: '无权限' });
const { name } = req.body;
if (!name) return res.status(400).json({ error: '名称必填' });
const clientId = 'c_' + crypto.randomBytes(12).toString('hex');
const secret = 's_' + crypto.randomBytes(24).toString('hex');
const clientId = genClientId();
const secret = genSecret();
await query('INSERT INTO api_clients(name, client_id, secret_hash) VALUES (?,?,?)', [name, clientId, bcrypt.hashSync(secret, 10)]);
await logSystem('info', 'api', `创建外部API客户端: ${name}`, { client_id: clientId });
res.json({ client_id: clientId, secret, message: '请立即保存 secret(仅显示一次)' });
@@ -82,24 +109,43 @@ router.delete('/clients/:id', authenticate, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ---- 以下接口: 外部鉴权(client_id + secret, 唯一方式) ----
// ============ 换取 SESSION(ID + Secret → Bearer SESSION) ============
// 仅此一个接口用 ID+Secret 鉴权, 其余接口一律 Bearer SESSION
router.post('/auth/session', async (req, res) => {
try {
const cid = req.headers['x-api-client-id'];
const secret = req.headers['x-api-secret'];
if (!cid || !secret) return res.status(401).json({ error: '缺少 x-api-client-id / x-api-secret' });
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: '客户端鉴权失败' });
}
// 清理该客户端旧 SESSION(单会话)
await query('DELETE FROM api_sessions WHERE client_id = ?', [cid]);
const sess = await createSession(cid);
await logSystem('info', 'api', `外部API客户端换取SESSION: ${client.name}`, { client_id: cid });
res.json({ ...sess, client_id: cid, message: `SESSION 有效期 ${SESSION_TTL_HOURS} 小时, 请用 Authorization: Bearer <session> 访问其余接口` });
} catch (e) { res.status(500).json({ error: '服务器错误' }); }
});
// ---- 以下接口: 外部鉴权(Bearer SESSION, 唯一方式) ----
router.use(clientAuth);
// ============ 外部注册(需客户端鉴权) ============
router.post('/auth/register', async (req, res) => {
try {
const { username, password, email, game_name, game_uid, source } = req.body;
if (!username || !password || !email || !game_name || !game_uid) return res.status(400).json({ error: '所有字段必填' });
if (!username || !password || !email || !game_name) return res.status(400).json({ error: '所有字段必填' });
if (password.length < 6) return res.status(400).json({ error: '密码至少6位' });
if (await getRow('SELECT id FROM users WHERE username = ?', [username])) return res.status(400).json({ error: '注册失败,请检查输入信息' });
if (await getRow('SELECT id FROM users WHERE email = ?', [email])) return res.status(400).json({ error: '注册失败,请检查输入信息' });
const hashed = bcrypt.hashSync(password, 10);
const verifyToken = uuid();
const r = await query('INSERT INTO users(username,password,email,game_name,game_uid,verify_token,verify_expires,source) VALUES (?,?,?,?,?,?,DATE_ADD(NOW(), INTERVAL 24 HOUR),?)', [username, hashed, email, game_name, game_uid, verifyToken, source || 'skin']);
const r = await query('INSERT INTO users(username,password,email,game_name,game_uid,verify_token,verify_expires,source) VALUES (?,?,?,?,?,?,DATE_ADD(NOW(), INTERVAL 24 HOUR),?)', [username, hashed, email, game_name, game_uid || '', verifyToken, source || '']);
try {
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
[r.insertId, source || 'skin', game_name, game_uid]);
[r.insertId, source || '', game_name, game_uid || '']);
} catch {}
const site = await getRow("SELECT v FROM settings WHERE k='site_url'");
@@ -119,7 +165,7 @@ router.post('/tickets', async (req, res) => {
try {
const { type, title, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description, is_admin_complaint, server } = req.body;
if (!type || !['report','suggestion','appeal'].includes(type)) return res.status(400).json({ error: '类型不正确' });
if (!title || !reporter_game_name || !reporter_game_uid) return res.status(400).json({ error: '必填字段不完整' });
if (!title || !reporter_game_name) return res.status(400).json({ error: '必填字段不完整' });
if (type === 'report' && !reason) return res.status(400).json({ error: '请填写举报原因' });
if (type === 'suggestion' && !description) return res.status(400).json({ error: '建议内容不能为空' });
if (type === 'appeal' && (!reason || !description)) return res.status(400).json({ error: '请填写完整申诉信息' });
@@ -130,8 +176,8 @@ router.post('/tickets', async (req, res) => {
const sv = await resolveServer(server);
const trackingToken = uuid();
const r = await query(`INSERT INTO tickets(type,title,reporter_game_name,reporter_game_uid,target_game_name,target_game_uid,reason,description,tracking_token,is_admin_complaint,server_name) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
[type, title, reporter_game_name, reporter_game_uid, target_game_name||'', target_game_uid||'', reason||'', description||'', trackingToken, is_admin_complaint?1:0, sv.server_name]);
await query("INSERT INTO responses(ticket_id,content,is_staff) VALUES (?,?,0)", [r.insertId, `游戏内提交\n类型: ${type}\n提交人: ${reporter_game_name} (UID: ${reporter_game_uid})${sv.server_name?'\n服务器: '+sv.server_name:''}`]);
[type, title, reporter_game_name, reporter_game_uid||'', target_game_name||'', target_game_uid||'', reason||'', description||'', trackingToken, is_admin_complaint?1:0, sv.server_name]);
await query("INSERT INTO responses(ticket_id,content,is_staff) VALUES (?,?,0)", [r.insertId, `游戏内提交\n类型: ${type}\n提交人: ${reporter_game_name}${reporter_game_uid?' (UID: '+reporter_game_uid+')':''}${sv.server_name?'\n服务器: '+sv.server_name:''}`]);
res.status(201).json({ id: r.insertId, tracking_token: trackingToken, server: sv.server_name });
} catch (e) { res.status(500).json({ error: e.message }); }
});