feat: 封禁IP同步Web黑名单(须持游戏内/web token)、清空队列联动清理pending登录请求

This commit is contained in:
ypsdf1
2026-09-27 21:23:31 +08:00
parent 6a74dfee91
commit f2b87325e1
3 changed files with 247 additions and 1 deletions
+39
View File
@@ -20,6 +20,45 @@ header('Access-Control-Allow-Origin: *');
// 加载密钥配置
require_once __DIR__ . '/pay_secrets.php';
// ====================================================================
// ★ IP黑名单拦截(与 core.php 的 enforceIpBlacklist 同源数据)
// 本文件不引 core.php(自成体系),故内联一份同等逻辑:
// 被封IP访问必须携带游戏内 /web 签发的有效 token,否则 403;
// Java 插件的 secret 调用与任何异常一律放行(fail-open)。
// ====================================================================
require_once __DIR__ . '/../config.php'; // 取 SECRET_KEY(require_once,不会重复定义)
function mcAuthIpBlacklisted() {
if (PHP_SAPI === 'cli') return false;
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if ($ip === '') return false;
// 放行通道:Java插件 secret
if (isset($_REQUEST['secret']) && $_REQUEST['secret'] === SECRET_KEY) return false;
try {
$path = dirname(__DIR__) . '/db/web.db';
if (!file_exists($path)) return false;
$w = new PDO('sqlite:' . $path);
$w->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$st = $w->prepare("SELECT 1 FROM web_ip_blacklist WHERE ip = ? AND (expire = 0 OR expire > ?) LIMIT 1");
$st->execute([$ip, time()]);
if (!$st->fetch()) return false; // 未被封 → 放行
// 被封 → 必须持游戏内 /web 签发的有效token
$token = isset($_REQUEST['web_token']) ? (string)$_REQUEST['web_token'] : '';
if ($token === '') return true;
$t = $w->prepare("SELECT created_at, expire_seconds FROM weblogin_tokens WHERE web_token = ? LIMIT 1");
$t->execute([$token]);
$row = $t->fetch(PDO::FETCH_ASSOC);
return !($row && (time() - (int)$row['created_at']) <= (int)$row['expire_seconds']);
} catch (\Throwable $e) {
return false; // 任何异常 fail-open,避免误伤
}
}
if (mcAuthIpBlacklisted()) {
http_response_code(403);
echo json_encode(['success' => false, 'blocked_ip' => true,
'message' => '该IP已被服务器封禁,需在游戏内执行 /web 获取授权token后才能访问'], JSON_UNESCAPED_UNICODE);
exit;
}
// ====================================================================
// 工具函数
// ====================================================================
+106 -1
View File
@@ -169,6 +169,9 @@ switch ($action) {
case 'complete_web_login_request':
completeWebLoginRequest();
break;
case 'clear_pending_web_logins':
clearPendingWebLogins();
break;
case 'push_web_credentials':
pushWebCredentials();
break;
@@ -4514,5 +4517,107 @@ function syncBans() {
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
}
success(['count' => count($bans)]);
// ★ v2:Java封禁IP时顺手把IP写入 web_ip_blacklist(Web端按IP拦截)
// ips 缺省时保持现有黑名单不动(兼容旧版Java端)
$ipCount = -1;
$ipsRaw = getParam('ips');
if ($ipsRaw !== null && $ipsRaw !== '') {
$ips = json_decode($ipsRaw, true);
if (is_array($ips)) {
$ipCount = syncIpBlacklist($db, $ips, $now);
}
}
success(['count' => count($bans), 'ip_blacklist' => $ipCount]);
}
/**
* ★ 把Java推送的IP封禁全量同步到 web_ip_blacklist
* 规则:当前封禁列表里的IP全部入表;表里已不存在于本次推送的IP自动移除(解封即放行)
* @return int 生效中的黑名单IP数
*/
function syncIpBlacklist($db, $ips, $now) {
$db->exec("CREATE TABLE IF NOT EXISTS web_ip_blacklist (
ip TEXT PRIMARY KEY,
reason TEXT DEFAULT '',
source TEXT DEFAULT '',
expire INTEGER DEFAULT 0,
banned_at INTEGER DEFAULT 0
)");
// 已过期的条目直接丢弃
$keep = [];
foreach ($ips as $item) {
$ip = is_array($item) ? ($item['ip'] ?? ($item['target'] ?? '')) : (string)$item;
$ip = trim($ip);
if ($ip === '') continue;
$expire = is_array($item) ? (int)($item['expire'] ?? 0) : 0;
if ($expire > 0 && $expire <= $now) continue; // 临时封禁已到期
$keep[$ip] = [
'reason' => is_array($item) ? (string)($item['reason'] ?? '') : '',
'source' => is_array($item) ? (string)($item['source'] ?? '') : '',
'expire' => $expire,
];
}
// 移除本次推送中已消失的IP(解封)
$existing = [];
$res = $db->query("SELECT ip FROM web_ip_blacklist");
while ($row = $res->fetchArray(SQLITE3_ASSOC)) $existing[$row['ip']] = true;
$del = $db->prepare("DELETE FROM web_ip_blacklist WHERE ip = :ip");
foreach (array_keys($existing) as $ip) {
if (!isset($keep[$ip])) {
$del->bindValue(':ip', $ip, SQLITE3_TEXT);
$del->execute();
}
}
$stmt = $db->prepare("INSERT OR REPLACE INTO web_ip_blacklist (ip, reason, source, expire, banned_at)
VALUES (:ip, :reason, :source, :expire, :banned_at)");
foreach ($keep as $ip => $meta) {
$stmt->bindValue(':ip', $ip, SQLITE3_TEXT);
$stmt->bindValue(':reason', $meta['reason'], SQLITE3_TEXT);
$stmt->bindValue(':source', $meta['source'], SQLITE3_TEXT);
$stmt->bindValue(':expire', (int)$meta['expire'], SQLITE3_INTEGER);
$stmt->bindValue(':banned_at', $now, SQLITE3_INTEGER);
$stmt->execute();
}
debugLog("syncIpBlacklist: 黑名单更新", ['count' => count($keep)]);
return count($keep);
}
// ===== 清空所有待处理的Web登录请求(游戏内「清空队列」联动调用,SECRET_KEY认证)=====
function clearPendingWebLogins() {
$secret = getParam('secret');
if (!$secret || $secret !== SECRET_KEY) {
error('密钥验证失败', 403);
}
$db = getDB();
$db->exec("CREATE TABLE IF NOT EXISTS web_login_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_name TEXT NOT NULL,
password TEXT NOT NULL,
request_time INTEGER NOT NULL,
status TEXT DEFAULT 'pending',
result TEXT DEFAULT '',
result_time INTEGER DEFAULT 0
)");
$before = 0;
try {
$cnt = $db->query("SELECT COUNT(*) AS c FROM web_login_requests WHERE status = 'pending'");
$row = $cnt ? $cnt->fetchArray(SQLITE3_ASSOC) : null;
$before = $row ? (int)$row['c'] : 0;
} catch (\Throwable $e) {}
$now = time();
// 标记为cancelled(保留历史行,便于排查),Web端轮询立即看到已结束
$db->exec("UPDATE web_login_requests SET status = 'cancelled',
result = '{\"success\":false,\"message\":\"已由游戏内清空队列清理\"}',
result_time = " . (int)$now . " WHERE status = 'pending'");
debugLog("clearPendingWebLogins: 清理pending请求", ['cleared' => $before]);
success(['cleared' => $before]);
}
+102
View File
@@ -1840,3 +1840,105 @@ function sendLocationAlertEmail($name, $email, $ip, $location, $lastLoc, $freeze
. "Content-Type: text/html; charset=UTF-8\r\n";
smtpSendEmail(SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, $email, $subject, $html, $headers, SMTP_USE_SSL);
}
// =====================================================================================
// ===== IP黑名单(游戏内封禁IP → Java推送 → Web端拦截) =====
// 数据来源:api/sync.php?action=sync_bans 携带的 ips 负载写入 web_ip_blacklist 表。
// 拦截规则(核心要求):被封IP访问Web【必须】携带游戏内 /web 命令签发的有效token
// (weblogin_tokens 表,未过期),否则一律 403。
// 放行白名单:① Java插件 SECRET_KEY 同步调用 ② 已登录的管理后台/收银台会话
// (防止管理员把自己的IP锁在站外)③ 有效 /web token。
// 任何异常一律 fail-open 放行,避免误伤整站。
// =====================================================================================
/** 该IP是否在Web黑名单中(含过期时间判断) */
function isIpBlacklisted($ip) {
if (!$ip) return false;
try {
$db = getDB();
$chk = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='web_ip_blacklist'");
if (!$chk || !$chk->fetchArray()) return false;
$stmt = $db->prepare("SELECT 1 FROM web_ip_blacklist WHERE ip = :ip AND (expire = 0 OR expire > :now) LIMIT 1");
$stmt->bindValue(':ip', $ip, SQLITE3_TEXT);
$stmt->bindValue(':now', time(), SQLITE3_INTEGER);
$row = $stmt->execute()->fetchArray();
return $row !== false;
} catch (\Throwable $e) {
@error_log("[ip_blacklist] 查询失败(放行): " . $e->getMessage());
return false;
}
}
/** token 是否为游戏内 /web 签发且未过期 */
function hasWebTokenForBlacklist($token) {
if (!$token) return false;
try {
$db = getDB();
$stmt = $db->prepare("SELECT created_at, expire_seconds FROM weblogin_tokens WHERE web_token = :t LIMIT 1");
$stmt->bindValue(':t', $token, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) return false;
return (time() - (int)$row['created_at']) <= (int)$row['expire_seconds'];
} catch (\Throwable $e) {
return false;
}
}
/** 入口拦截:命中黑名单且没有放行凭据 → 403 */
function enforceIpBlacklist() {
if (PHP_SAPI === 'cli') return;
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if ($ip === '' || !isIpBlacklisted($ip)) return;
// ① Java插件同步(SECRET_KEY)
$secret = getParam('secret');
if ($secret && $secret === SECRET_KEY) return;
// ② 已登录的管理后台 / 收银台会话(仅在浏览器带了会话cookie时才查,避免给API请求种session)
if (!empty($_COOKIE[session_name()])) {
try { if (isAdminLoggedIn()) return; } catch (\Throwable $e) {}
try { if (isCashierLoggedIn()) return; } catch (\Throwable $e) {}
}
// ③ 游戏内 /web 签发的有效token
$token = getParam('web_token');
if (hasWebTokenForBlacklist($token)) {
@error_log("[ip_blacklist] 放行(持/webtoken): ip=$ip uri=" . ($_SERVER['REQUEST_URI'] ?? ''));
return;
}
@error_log("[ip_blacklist] 拦截: ip=$ip uri=" . ($_SERVER['REQUEST_URI'] ?? ''));
$uri = $_SERVER['REQUEST_URI'] ?? '';
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
$isApi = strpos($uri, '/api/') !== false || strpos($accept, 'application/json') !== false;
if ($isApi) {
header('Content-Type: application/json; charset=utf-8');
http_response_code(403);
echo json_encode([
'success' => false,
'blocked_ip' => true,
'message' => '该IP已被服务器封禁,需在游戏内执行 /web 获取授权token后才能访问'
], JSON_UNESCAPED_UNICODE);
} else {
http_response_code(403);
echo '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">'
. '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
. '<title>403 - IP已被封禁</title><style>'
. 'body{background:#0d1117;color:#e6edf3;font-family:"Segoe UI",system-ui,sans-serif;'
. 'min-height:100vh;display:flex;align-items:center;justify-content:center;margin:0}'
. '.c{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:40px;'
. 'max-width:460px;text-align:center}'
. 'h1{color:#f85149;font-size:48px;margin:0 0 12px}'
. 'p{color:#8b949e;line-height:1.8;font-size:14px}'
. 'code{color:#79c0ff;background:#0d1117;padding:2px 6px;border-radius:4px}'
. '</style></head><body><div class="c">'
. '<h1>403</h1><p><b>当前IP已被服务器封禁</b></p>'
. '<p>如需访问网页,请先在游戏内执行 <code>/web</code> 获取授权token,'
. '再携带 <code>web_token</code> 访问。</p></div></body></html>';
}
exit;
}
// core.php 被所有页面/API引入 → 在此统一执行入口拦截
enforceIpBlacklist();