feat: 封禁IP同步Web黑名单(须持游戏内/web token)、清空队列联动清理pending登录请求
This commit is contained in:
@@ -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
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user