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

@@ -46,7 +46,15 @@ router.post('/register', validateLengths({
if (!username || !password || !email || !game_name) {
return res.status(400).json({ error: '所有字段均为必填' });
}
if (source === 'netease' && !game_uid) return res.status(400).json({ error: '网易端必须填写UID' });
// 来源必须是已启用的动态来源(不设默认)
let srcValid = false;
if (source) {
try {
const srcRow = await getRow('SELECT id FROM sources WHERE code = ? AND enabled = 1', [source]);
srcValid = !!srcRow;
} catch { srcValid = true; } // sources 表不存在时兼容旧部署
}
if (source && !srcValid) return res.status(400).json({ error: '无效的来源' });
if (!code_id || !code) return res.status(400).json({ error: '请先完成邮箱验证' });
const codeRow = await getRow("SELECT * FROM captchas WHERE id = ? AND created_at > NOW() - INTERVAL 10 MINUTE", [code_id]);
@@ -74,24 +82,24 @@ router.post('/register', validateLengths({
if (existingUser || existingEmail) {
return res.status(400).json({ error: '用户名或邮箱已被使用' });
}
if (game_name && (source || 'netease') === 'netease') {
const dup = await getRow('SELECT id FROM users WHERE game_name = ? AND source = ?', [game_name, source || 'netease']);
if (game_name && source) {
const dup = await getRow('SELECT id FROM users WHERE game_name = ? AND source = ?', [game_name, source]);
if (dup) return res.status(400).json({ error: '该游戏名在此来源下已注册' });
}
// 多来源兼容: 该游戏名可能已被其他账号绑定为副身份
try {
const dupIdent = await getRow('SELECT id FROM user_identities WHERE source = ? AND game_name = ?', [source || 'netease', game_name]);
const dupIdent = await getRow('SELECT id FROM user_identities WHERE source = ? AND game_name = ?', [source || '', game_name]);
if (dupIdent) return res.status(400).json({ error: '该游戏名已绑定其他账号' });
} catch {}
const hashed = await bcrypt.hash(password, 10);
const r = await query(
'INSERT INTO users(username, password, email, game_name, game_uid, active, email_verified, source) VALUES (?,?,?,?,?,?,?,?)',
[username, hashed, email, game_name, game_uid||'', 1, 1, source || 'netease']
[username, hashed, email, game_name, game_uid||'', 1, 1, source || '']
);
try {
await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
[r.insertId, source || 'netease', game_name, game_uid || '']);
[r.insertId, source || '', game_name, game_uid || '']);
} catch {}
res.status(201).json({ message: '注册成功,请登录' });
} catch (err) {
@@ -184,18 +192,23 @@ router.get('/identities', authenticate, async (req, res) => {
router.post('/identities', authenticate, async (req, res) => {
try {
const { source, game_name, game_uid } = req.body;
if (!['netease','skin'].includes(source)) return res.status(400).json({ error: '无效的来源' });
if (!game_name) return res.status(400).json({ error: '游戏名不能为空' });
if (source === 'netease' && !game_uid) return res.status(400).json({ error: '网易端必须填写UID' });
// 来源动态校验(启用中的来源; sources 表缺失时兼容)
if (source) {
try {
const srcRow = await getRow('SELECT id FROM sources WHERE code = ? AND enabled = 1', [source]);
if (!srcRow) return res.status(400).json({ error: '无效的来源' });
} catch {}
}
const dup = await getRow('SELECT id FROM user_identities WHERE user_id = ? AND source = ?', [req.user.id, source]);
const dup = await getRow('SELECT id FROM user_identities WHERE user_id = ? AND source = ?', [req.user.id, source || '']);
if (dup) return res.status(400).json({ error: '该来源已绑定,可先删除再重新绑定' });
const dupName = await getRow('SELECT id FROM user_identities WHERE source = ? AND game_name = ?', [source, game_name]);
const dupName = await getRow('SELECT id FROM user_identities WHERE source = ? AND game_name = ?', [source || '', game_name]);
if (dupName) return res.status(400).json({ error: '该游戏名已绑定其他账号' });
const r = await query('INSERT INTO user_identities(user_id, source, game_name, game_uid) VALUES (?,?,?,?)',
[req.user.id, source, game_name, game_uid || '']);
[req.user.id, source || '', game_name, game_uid || '']);
res.status(201).json({ id: r.insertId, message: '绑定成功' });
} catch (err) {
console.error('[auth]', err);