diff --git a/admin.php b/admin.php index 10ca29b..649cd9e 100644 --- a/admin.php +++ b/admin.php @@ -1,4 +1,17 @@ - + 才放行, +// 不带/带错令牌一律返回 nginx 原生 404 页(与文件不存在时完全一致)。 +// 注意必须放在 secGatePage() 之前 —— 否则白名单 403 会暴露这个文件确实存在。 +// 第二层 IP 白名单 —— 页面本身也只对白名单 IP 开放。 +require_once __DIR__ . '/security.php'; +secTokenGate(); +secGatePage(); +// 令牌在 URL 上,禁止页面内任何外链通过 Referer 把它带走 +header('Referrer-Policy: no-referrer'); +header('Cache-Control: no-cache, no-store, must-revalidate, max-age=0'); +header('Pragma: no-cache'); +?> @@ -171,6 +184,7 @@
🧾 收银员管理
💳 充值对账
🛒 充值商店配置
+
🛡️ 安全配置(2FA / IP白名单 / 入口令牌)
@@ -261,7 +275,11 @@ function go(p) { } }); if (!d.data || !d.data.logged_in) { - location.href='admin_login.php'; + location.href='admin_login.php'; + return; + } + if (d.data.need_2fa) { + location.href='admin_2fa.php'; return; } go('dashboard'); @@ -281,6 +299,10 @@ async function loadDashboard(el) { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }).then(r => r.text().then(t => { try { return JSON.parse(t.replace(/^\uFEFF/,'').trim()); } catch(e) { console.error('Status JSON error:', t.substring(0,200)); throw e; } })); + if (s.success && s.data && s.data.need_2fa) { + location.href='admin_2fa.php'; + return; + } if (!s.success || !s.data || !s.data.logged_in) { el.innerHTML = '

请先登录

尚未登录管理后台

如已登录,请清除浏览器缓存后重试

调试: ' + JSON.stringify(s) + '

'; return; diff --git a/admin_2fa.php b/admin_2fa.php new file mode 100644 index 0000000..b53f7c5 --- /dev/null +++ b/admin_2fa.php @@ -0,0 +1,165 @@ + 0) { + $err = '验证失败次数过多,已锁定,请 ' . $lock . ' 秒后再试'; + secLog('2fa_throttled', 'lock=' . $lock . 's'); + } else { + $code = isset($_POST['code']) ? (string)$_POST['code'] : ''; + if (secTotpVerify(SEC_2FA_SECRET, $code)) { + $_SESSION['admin_2fa_ok'] = time(); + $_SESSION['admin_2fa_ip'] = secClientIp(); + secThrottleReset('twofa_fail', $sid); + secLog('2fa_pass', 'session granted'); + header('Location: ' . secTokenUrl('admin.php')); + exit; + } + $left = secThrottleFail('twofa_fail', $sid, 5, 900); + // ★ 诊断:把"密钥录错/绑了旧密钥"和"设备时钟漂移"两种失败原因区分开, + // 否则用户只能看到一句"无效",无从下手。 + $off = secTotpDiagnose(SEC_2FA_SECRET, $code); + secLog('2fa_fail', $left > 0 ? 'LOCKED_' . $left . 's' + : ($off === null ? 'wrong_secret' : 'clock_skew ' . $off . 's')); + $err = $left > 0 + ? '验证失败次数过多,已锁定,请 ' . $left . ' 秒后再试' + : secTotpDiagnoseMsg($off); + } +} +?> + + + + + + SDF1 - 二次验证 + + + +
+

🛡️ 二次验证

+
请输入认证器 App 中的 6 位动态码
+ (1Password / Microsoft Authenticator / Google Authenticator 均可)
+
+ + +
+
动态码每 30 秒刷新
+
+ +
+ +
+ +
当前访问 IP:
+
提示:认证器与上方服务器时间偏差超过 90 秒就会被拒
+
+ + + + diff --git a/admin_2fa_setup.php b/admin_2fa_setup.php new file mode 100644 index 0000000..914da05 --- /dev/null +++ b/admin_2fa_setup.php @@ -0,0 +1,2738 @@ +(引导模式,免密码) + * C. 服务器本机 127.0.0.1 永远可访问(仍需登录,或用 boot) + * + * 所有配置写入 config.php 的 SEC-UPDATE 标记区(写前自动备份到 db/config_bak/)。 + */ +require_once __DIR__ . '/security.php'; +secEnsureSession(); + +// IP 白名单闸门 + boot 引导令牌解锁 +secGatePage(true); +$boot = secBootSessionActive(); + +// ===== 访问模式判定 ===== +// 说明:本页只依赖 security.php(不加载 core.php), +// isAdminLoggedIn() 是 core 的函数,这里用等价的内联判定(admin_auth + 安全纪元)。 +$loggedIn = !empty($_SESSION['admin_auth']) && secSessionEpochOk(); +if ($boot) { + $mode = 'boot'; +} elseif ($loggedIn) { + // 已登录:若 2FA 已启用但本会话未验证 → 先去验证页 + if (defined('SEC_2FA_ENABLED') && SEC_2FA_ENABLED && empty($_SESSION['admin_2fa_ok'])) { + header('Location: admin_2fa.php'); + exit; + } + $mode = 'session'; +} else { + header('Location: admin_login.php'); + exit; +} + +// ===== 当前状态(操作后会就地更新,保证同请求内显示正确)===== +$whitelist = (defined('ADMIN_IP_WHITELIST') && is_array(ADMIN_IP_WHITELIST)) ? ADMIN_IP_WHITELIST : array(); +$twoFaOn = defined('SEC_2FA_ENABLED') && SEC_2FA_ENABLED; +$twoFaSecret = defined('SEC_2FA_SECRET') ? (string)SEC_2FA_SECRET : ''; +// ★ 第三层:后台入口令牌(config.php 的 SEC_ACCESS_TOKEN)。 +// 若旧版 config 尚未有该键,首次任何写操作会顺手生成一个(自愈),本页会把地址显示出来。 +$secToken = (defined('SEC_ACCESS_TOKEN') && SEC_ACCESS_TOKEN !== '') ? (string)SEC_ACCESS_TOKEN : secNewToken(); +$pending = isset($_SESSION['sec_pending_secret']) ? (string)$_SESSION['sec_pending_secret'] : ''; +$myIp = secClientIp(); +$err = ''; +$ok = ''; + +// ===== POST:所有写操作 ===== +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $mode !== 'none') { + $act = isset($_POST['act']) ? (string)$_POST['act'] : ''; + $allowed = false; + + if ($mode === 'boot') { + $allowed = true; // 引导模式(已持有服务器文件级凭据)免密码 + } else { + // 第二层附加保护:改配置必须重输管理密码(防活会话被盗用者直接改安全配置) + $lock = secThrottleLocked('setup_pw', $myIp, 5, 600); + if ($lock > 0) { + $err = '密码尝试过多,请 ' . $lock . ' 秒后再试'; + secLog('setup_throttled', 'lock=' . $lock . 's'); + } else { + $pw = isset($_POST['admin_pass']) ? (string)$_POST['admin_pass'] : ''; + if ($pw !== '' && $pw === ADMIN_PASS) { + $allowed = true; + secThrottleReset('setup_pw', $myIp); + } else { + $left = secThrottleFail('setup_pw', $myIp, 5, 600); + secLog('setup_pw_fail', $left > 0 ? 'LOCKED_' . $left . 's' : 'wrong_password'); + $err = $left > 0 + ? '管理密码错误且尝试过多,已锁定 ' . $left . ' 秒' + : '管理密码错误,操作被拒绝'; + } + } + } + + if ($allowed) { + try { + // 全量写回(未提及的键保留旧值由 secWriteConfig 内部保证) + $full = function ($over) { + return array_merge(array( + 'SEC_BOOT_TOKEN' => SEC_BOOT_TOKEN, + 'SEC_ACCESS_TOKEN' => $GLOBALS['secToken'], + 'SEC_EPOCH' => (int)SEC_EPOCH, + 'ADMIN_IP_WHITELIST' => $GLOBALS['whitelist'], + 'SEC_2FA_ENABLED' => $GLOBALS['twoFaOn'], + 'SEC_2FA_SECRET' => $GLOBALS['twoFaSecret'], + ), $over); + }; + + if ($act === 'save_whitelist') { + $lines = preg_split('/\r\n|\r|\n/', isset($_POST['whitelist']) ? (string)$_POST['whitelist'] : ''); + $items = array(); + foreach ($lines as $L) { + $L = trim($L); + if ($L === '') continue; + if (filter_var($L, FILTER_VALIDATE_IP)) { + $items[] = $L; + } elseif (preg_match('#^(\d{1,3}\.){3}\d{1,3}/(\d{1,2})$#', $L, $mm) + && (int)$mm[2] <= 32 + && filter_var(explode('/', $L)[0], FILTER_VALIDATE_IP)) { + $items[] = $L; + } else { + throw new Exception('无效的 IP / CIDR:' . htmlspecialchars($L, ENT_QUOTES, 'UTF-8')); + } + } + $items = array_values(array_unique($items)); + secWriteConfig($full(array('ADMIN_IP_WHITELIST' => $items))); + $whitelist = $items; + secLog('whitelist_save', 'count=' . count($items) . ' ' . implode(',', $items)); + $ok = 'IP 白名单已保存(' . count($items) . ' 条)' . (count($items) === 0 ? '。注意:白名单为空 = 仅服务器本机可访问后台!' : ''); + } elseif ($act === 'twofa_begin') { + // ★ 幂等:已有待绑定密钥就继续用它。 + // 以前每次点都换新密钥 —— 用户手里的认证器可能刚扫/刚录完旧的, + // 再点一次就把那条作废了,之后所有动态码"永远无效",极难排查。 + if ($pending === '') { + $_SESSION['sec_pending_secret'] = secNewSecret(); + $pending = $_SESSION['sec_pending_secret']; + secLog('twofa_begin', 'pending secret generated'); + $ok = '密钥已生成:请扫码或手动录入后,提交下方验证码完成绑定'; + } else { + secLog('twofa_begin', 'reuse pending secret'); + $ok = '沿用已生成的密钥(避免认证器里出现多条作废记录)。要换新请点「重新生成密钥」。'; + } + } elseif ($act === 'twofa_regen') { + $_SESSION['sec_pending_secret'] = secNewSecret(); + $pending = $_SESSION['sec_pending_secret']; + secLog('twofa_regen', 'pending secret regenerated, old one discarded'); + $ok = '密钥已重新生成 —— 之前录入认证器的那条已作废,请用新二维码/新密钥重新添加'; + } elseif ($act === 'twofa_finish') { + if ($pending === '') throw new Exception('没有待验证的密钥,请先点击"生成密钥"'); + $code = isset($_POST['code']) ? (string)$_POST['code'] : ''; + $lock2 = secThrottleLocked('twofa_bind', $myIp, 5, 600); + if ($lock2 > 0) throw new Exception('尝试过多,请 ' . $lock2 . ' 秒后再试'); + if (!secTotpVerify($pending, $code)) { + $left2 = secThrottleFail('twofa_bind', $myIp, 5, 600); + // ★ 诊断:区分"认证器里根本不是这把密钥"与"设备时钟偏了" + $off = secTotpDiagnose($pending, $code); + secLog('twofa_bind_fail', $left2 > 0 ? 'LOCKED' + : ($off === null ? 'wrong_secret' : 'clock_skew ' . $off . 's')); + if ($left2 > 0) throw new Exception('尝试过多,请 ' . $left2 . ' 秒后再试'); + throw new Exception('绑定未完成:' . secTotpDiagnoseMsg($off)); + } + secWriteConfig($full(array('SEC_2FA_ENABLED' => true, 'SEC_2FA_SECRET' => $pending))); + $twoFaOn = true; + $twoFaSecret = $pending; + unset($_SESSION['sec_pending_secret']); + $pending = ''; + secThrottleReset('twofa_bind', $myIp); + secLog('twofa_enabled', ''); + $ok = '✅ 二次验证已启用!之后每次登录后台都必须输入 6 位动态码(当前会话也需重新验证)'; + } elseif ($act === 'rotate_token') { + $newTok = secNewToken(); + secWriteConfig($full(array('SEC_ACCESS_TOKEN' => $newTok))); + $secToken = $newTok; + secLog('token_rotate', 'admin.php 入口令牌已更换'); + $ok = '后台入口令牌已重新生成,旧地址立即失效(返回 404)。请复制下方新地址并更新书签。'; + } elseif ($act === 'twofa_disable') { + if ($mode !== 'boot') { + // 非引导模式:必须出示当前动态码(证明认证器在手) + $code = isset($_POST['code']) ? (string)$_POST['code'] : ''; + if (!secTotpVerify($twoFaSecret, $code)) { + $offD = secTotpDiagnose($twoFaSecret, $code); + secLog('twofa_disable_fail', $offD === null ? 'wrong_secret' : 'clock_skew ' . $offD . 's'); + throw new Exception('未执行解绑:' . secTotpDiagnoseMsg($offD)); + } + } + secWriteConfig($full(array('SEC_2FA_ENABLED' => false, 'SEC_2FA_SECRET' => ''))); + $twoFaOn = false; + $twoFaSecret = ''; + unset($_SESSION['sec_pending_secret']); + $pending = ''; + secLog('twofa_disabled', 'mode=' . $mode); + $ok = '二次验证已关闭' . ($mode === 'boot' ? '(引导模式强制关闭)' : ''); + } else { + throw new Exception('未知操作'); + } + } catch (Exception $e) { + $err = $e->getMessage(); + } + } +} + +// ===== 恢复访问地址(引导令牌入口,供状态卡片展示)===== +$reqPath = parse_url(isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/', PHP_URL_PATH); +$baseDir = rtrim(dirname($reqPath), '/'); +$scheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https' : 'http'; +$host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : ''; +$recoverUrl = $scheme . '://' . $host . $baseDir . '/admin_2fa_setup.php?boot=' . SEC_BOOT_TOKEN; +// ★ 第三层:后台唯一正确入口(不带令牌访问 admin.php 一律 404) +$adminUrl = $scheme . '://' . $host . $baseDir . '/admin.php?token=' . rawurlencode($secToken); + +// ===== 安全审计日志(最近 8 条)===== +$logLines = array(); +$secLogPath = __DIR__ . '/db/security.log'; +if (file_exists($secLogPath)) { + $all = @file($secLogPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if (is_array($all)) $logLines = array_slice($all, -8); +} +?> + + + + + + SDF1 - 后台安全配置 + + + +
+
+

🔐 后台安全配置

+
+ 引导模式(boot 令牌) + ← 返回 +
+
+
第一层:TOTP 二次验证(6 位动态码) | 第二层:管理面 IP 白名单(未配置时仅本机可访问)
+ + ❌ + ✅ + +
+ + + +
+
+ + +
+
+ + + +
+

📋 当前状态

+
+ 二次验证: +
+ IP 白名单:
+ 你的 IP:
+ 安全纪元:(此前的登录会话已全部作废)
+ 后台入口令牌: +
+ 引导令牌: + +
🔖 后台唯一入口(请收藏这一条,不带 token 访问 admin.php 一律返回 404):
+ +
+
恢复访问地址: +
+
+
+ + +
+

🚪 第二层 · IP 白名单

+ 0): ?> +
+ + +
+ + + +
+
+ · 白名单为空 = 除服务器本机外任何人都无法访问后台(包括登录页与本页)。
+ · 支持 CIDR 网段(家用动态 IP 可填运营商段,如 x.x.x.0/24,慎用大段)。
+ · 动态 IP 变更后进不来 → 用上方引导令牌恢复访问。 +
+
+ + +
+

🛡️ 第一层 · 二次验证(TOTP 6 位动态码)

+ + +
未启用。启用后,每次登录后台都需在认证器 App 中输入 6 位动态码(无推送,纯本地校验)。
+
+ +
+ + + +
+
📱 第 1 步:用认证器扫码添加(打开 App 的「扫描二维码 / Setup another account」)
+
+
扫码会自动填好密钥与参数,避免手工输入 32 位密钥时敲错字符
+
Base32 密钥(认证器选择"无法扫描/手动输入密钥"时填这个) +
+
+
otpauth 链接(部分 App 支持粘贴) +
+
+
+
+ + + + +
+
扫码(或手动输入密钥)→ 认证器上出现 6 位数字 → 回填并点「确认绑定」。 + 提交被拒时会明确告诉你原因:是密钥没对上还是手机时钟偏了多少秒。
+ ⚠ 绑定完成前不要点「重新生成密钥」 —— 一旦换新,认证器里已录入的那条立即作废, + 之后提交的动态码会全部被拒(这正是"验证码一直无效"最常见的原因)。
+ + + +
+ + + +
+
关闭需出示当前动态码(证明认证器在手);boot 引导模式可强制关闭(灾难恢复)。关闭后请尽快重新绑定。
+ 动态码一直被拒?先看报错提示是"密钥没对上"还是"时钟偏了多少秒": + 前者用状态卡里的引导令牌地址进入本页强制关闭后重新扫码绑定;后者把手机时间改成自动同步即可。
+ +
+ + +
+

📜 安全审计(db/security.log 最近 8 条)

+
+
+
+
+ + + + + + + diff --git a/admin_login.php b/admin_login.php index 23deaa1..9609de4 100644 --- a/admin_login.php +++ b/admin_login.php @@ -1,3 +1,8 @@ + @@ -70,8 +75,14 @@ }); const data = await res.json(); if (data.success) { - // 设置session cookie后跳转 - window.location.href = 'admin.php'; + // ★ 第三层:登录接口在密码正确后才下发入口令牌,用来拼 admin.php?token= + const tk = (data.data && data.data.token) ? ('?token=' + encodeURIComponent(data.data.token)) : ''; + // ★ 第一层:启用二次验证时先进 6 位动态码验证页,通过后再进后台 + if (data.data && data.data.need_2fa) { + window.location.href = 'admin_2fa.php'; + } else { + window.location.href = 'admin.php' + tk; + } } else { document.getElementById('errorMsg').textContent = data.message; document.getElementById('errorMsg').style.display = 'block'; diff --git a/api/admin.php b/api/admin.php index 6426c25..04b9a50 100644 --- a/api/admin.php +++ b/api/admin.php @@ -41,6 +41,10 @@ if (session_status() === PHP_SESSION_NONE) { session_start(); } +// ★ 紧急加固(2026-09-29):第二层 IP 白名单 —— 白名单未配置时, +// 任何外部 IP 连 login/status 也一律 403(127.0.0.1 本机永放行) +secGateApi(); + $action = getParam('action', 'status'); try { @@ -89,10 +93,33 @@ switch ($action) { // ===== 各函数定义 ===== function adminDoLogin() { + // ★ 紧急加固:同 IP 暴力破解限速(5 次失败锁定 10 分钟),失败写审计日志 + $ip = function_exists('secClientIp') ? secClientIp() : ''; + if (function_exists('secThrottleLocked')) { + $lock = secThrottleLocked('login_fail', $ip, 5, 600); + if ($lock > 0) { + if (function_exists('secLog')) secLog('login_throttled', 'lock=' . $lock . 's'); + exit(json_encode(['success' => false, 'message' => '尝试次数过多,请 ' . $lock . ' 秒后再试', 'code' => 429], JSON_UNESCAPED_UNICODE)); + } + } $password = getParam('password'); if (!$password) exit(json_encode(['success' => false, 'message' => 'Missing password'], JSON_UNESCAPED_UNICODE)); if (adminLogin($password)) { - exit(json_encode(['success' => true, 'data' => ['login_time' => time()], 'message' => 'OK'], JSON_UNESCAPED_UNICODE)); + if (function_exists('secThrottleReset')) secThrottleReset('login_fail', $ip); + if (function_exists('secLog')) secLog('login_pass', 'password_ok'); + // ★ 第一层:已启用二次验证时,告知前端跳转 6 位动态码验证页 + $need2fa = function_exists('secSessionNeed2FA') && secSessionNeed2FA(); + // ★ 第三层:登录成功后把后台入口令牌交给前端,用于拼 admin.php?token=... + // —— 令牌只在密码正确之后才出现,登录页 HTML 里不含它。 + $tok = function_exists('secToken') ? secToken() : ''; + exit(json_encode(['success' => true, 'data' => ['login_time' => time(), 'need_2fa' => $need2fa, 'token' => $tok], 'message' => 'OK'], JSON_UNESCAPED_UNICODE)); + } + if (function_exists('secThrottleFail')) { + $left = secThrottleFail('login_fail', $ip, 5, 600); + if (function_exists('secLog')) secLog('login_fail', 'bad_password' . ($left > 0 ? ' LOCKED_' . $left . 's' : '')); + if ($left > 0) { + exit(json_encode(['success' => false, 'message' => '尝试次数过多,请 ' . $left . ' 秒后再试', 'code' => 429], JSON_UNESCAPED_UNICODE)); + } } exit(json_encode(['success' => false, 'message' => '密码错误', 'code' => 401], JSON_UNESCAPED_UNICODE)); } @@ -103,9 +130,16 @@ function adminDoLogout() { } function adminStatus() { + $in = function_exists('isAdminLoggedIn') ? isAdminLoggedIn() : false; + // ★ 第一层:前端据 need_2fa 决定跳验证页还是直进后台 + $need2fa = $in && function_exists('secSessionNeed2FA') && secSessionNeed2FA(); exit(json_encode([ 'success' => true, - 'data' => ['logged_in' => isAdminLoggedIn(), 'login_time' => $_SESSION['admin_login_time'] ?? 0], + 'data' => [ + 'logged_in' => $in, + 'login_time' => $_SESSION['admin_login_time'] ?? 0, + 'need_2fa' => $need2fa + ], 'message' => 'ok' ], JSON_UNESCAPED_UNICODE)); } diff --git a/config.php b/config.php index 421b29b..f043303 100644 --- a/config.php +++ b/config.php @@ -45,3 +45,22 @@ define('SMTP_USER', 'mcserver@ypshidifu.cn'); define('SMTP_PASS', 'sQ2ZiCZGq96xi9Sv'); define('SMTP_SENDER_NAME', 'Sdf1_login'); define('SMTP_USE_SSL', true); + +// ===== 紧急安全加固(2026-09-29 入侵事件响应,实现见 security.php)===== +// 第一层:TOTP 二次验证 —— 1Password / Microsoft Authenticator 等标准 6 位动态码(仅校验,无推送)。 +// 第二层:管理面 IP 白名单 —— ADMIN_IP_WHITELIST 为空数组时,仅服务器本机 127.0.0.1 +// 与 boot 引导令牌可访问后台,其余任何人一律 403。 +// 第三层:admin.php 动态访问令牌 —— 只有 https://域名/plugin/admin.php?token= +// 才能打开后台;不带令牌(或令牌错)的请求一律返回 nginx 原生 404 页, +// 与"服务器上根本没有这个文件"表现完全一致,探测者无从判断。 +// 被锁在外面时的恢复方法:宝塔/SSH 查看本文件的 SEC_BOOT_TOKEN,访问 +// https://你的域名/plugin/admin_2fa_setup.php?boot=<令牌> +// 进入配置页把自己当前 IP 加入白名单(boot 令牌本身也是配置页入口)。 +// >>>SEC-UPDATE-BEGIN 本区段由 admin_2fa_setup.php 自动维护,请勿手工改动标记行 +define('SEC_BOOT_TOKEN', '2132ac30a611aa6df3a8a78d17396385'); +define('SEC_ACCESS_TOKEN', '7c40ad41e5a384ef4f496088a955f8f0'); +define('SEC_EPOCH', 1790687661); +define('ADMIN_IP_WHITELIST', array('119.39.100.21')); +define('SEC_2FA_ENABLED', false); +define('SEC_2FA_SECRET', ''); +// >>>SEC-UPDATE-END diff --git a/core.php b/core.php index 4c7ffc0..ee94a82 100644 --- a/core.php +++ b/core.php @@ -1,6 +1,8 @@ 0) { @@ -1226,6 +1228,16 @@ function requireAdminSession() { if (!isset($_SESSION['admin_auth']) || !$_SESSION['admin_auth']) { error('未登录管理后台', 401); } + // ★ 紧急加固:安全纪元之前的会话一律作废(部署时同步踢掉入侵者的活会话) + if (function_exists('secSessionEpochOk') && !secSessionEpochOk()) { + $_SESSION = array(); + @session_destroy(); + error('登录已失效,请重新登录', 401); + } + // ★ 紧急加固:已启用二次验证但本会话未通过 6 位动态码 → 拒绝一切管理操作 + if (function_exists('secSessionNeed2FA') && secSessionNeed2FA()) { + error('需要二次验证:请先完成6位动态码验证', 403); + } } // ===== 管理员认证 ===== @@ -1239,6 +1251,8 @@ function adminLogin($password) { } $_SESSION['admin_auth'] = true; $_SESSION['admin_login_time'] = time(); + // ★ 紧急加固:每次重新登录都必须重新过二次验证 + unset($_SESSION['admin_2fa_ok'], $_SESSION['admin_2fa_ip']); return true; } @@ -1246,7 +1260,14 @@ function isAdminLoggedIn() { if (session_status() === PHP_SESSION_NONE) { session_start(); } - return isset($_SESSION['admin_auth']) && $_SESSION['admin_auth']; + if (!isset($_SESSION['admin_auth']) || !$_SESSION['admin_auth']) { + return false; + } + // ★ 紧急加固:安全纪元之前的旧会话视为未登录(只读展示场景,不销毁) + if (function_exists('secSessionEpochOk') && !secSessionEpochOk()) { + return false; + } + return true; } // ===== 收银员认证 ===== diff --git a/recharge_orders.php b/recharge_orders.php index 5215feb..cb9edd3 100644 --- a/recharge_orders.php +++ b/recharge_orders.php @@ -1,12 +1,14 @@ + * 才算"这个文件存在",否则一律返回 nginx 原生 404 页(与文件不存在时 + * 的表现完全一致,让探测者分不清是文件不在还是自己请求错了)。 + * + * 引导令牌(防自锁逃生舱): + * 忘记密码之外的"被锁在外面"场景,用宝塔/SSH 打开 config.php 查看 SEC_BOOT_TOKEN, + * 访问 admin_2fa_setup.php?boot=<令牌> 即可进入配置页把自己 IP 加入白名单。 + * + * 依赖:config.php(本文件自动加载) + * 使用方:core.php(会话增强)、admin.php、admin_login.php、 + * admin_2fa.php、admin_2fa_setup.php、api/admin.php + * + * 注意:本文件必须以 ,避免任何意外输出污染页面/JSON。 + */ +require_once __DIR__ . '/config.php'; + +// ====================================================================== +// 基础:客户端 IP +// ====================================================================== + +/** 客户端真实 IP。只信 REMOTE_ADDR(不取 X-Forwarded-For,防伪造)。 */ +function secClientIp() { + $ip = isset($_SERVER['REMOTE_ADDR']) ? trim((string)$_SERVER['REMOTE_ADDR']) : ''; + if (strncmp($ip, '::ffff:', 7) === 0) $ip = substr($ip, 7); // IPv4-mapped IPv6 归一 + // ★ Cloudflare 回源场景:nginx 未配 real_ip 时 REMOTE_ADDR 是每次变化的 CF 节点 IP, + // 真实客户端 IP 在 CF-Connecting-IP 头里。仅当 REMOTE_ADDR 确属 CF 官方网段 + // 才信任该头 —— 攻击者直连源站伪造该头时不属于 CF 网段,伪造无效; + // 经 CF 访问时 CF 会覆盖客户端传入的同名头,亦无法伪造。 + if ($ip !== '' && secIsCloudflareIp($ip)) { + foreach (array('HTTP_CF_CONNECTING_IP', 'HTTP_TRUE_CLIENT_IP') as $h) { + if (!empty($_SERVER[$h])) { + $c = trim((string)$_SERVER[$h]); + if (filter_var($c, FILTER_VALIDATE_IP)) { + if (strncmp($c, '::ffff:', 7) === 0) $c = substr($c, 7); + return $c; + } + } + } + } + return $ip; +} + +/** IP 是否属于 Cloudflare 官方回源网段(developers.cloudflare.com/ips,IPv4)。 */ +function secIsCloudflareIp($ip) { + static $nets = array( + '173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', + '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', + '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', + '104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22', + ); + foreach ($nets as $cidr) { + if (secCidrMatch($ip, $cidr)) return true; + } + return false; +} + +function secIsLocalIp($ip) { + return $ip === '127.0.0.1' || $ip === '::1'; +} + +/** IPv4 CIDR 匹配,支持 "1.2.3.4/24" 写法(家用动态 IP 段适用)。 */ +function secCidrMatch($ip, $cidr) { + $parts = explode('/', $cidr, 2); + if (count($parts) !== 2 || !is_numeric($parts[1])) return false; + $bits = (int)$parts[1]; + if ($bits < 0 || $bits > 32) return false; + $ipLong = ip2long($ip); + $snLong = ip2long($parts[0]); + if ($ipLong === false || $snLong === false) return false; + $mask = $bits === 0 ? 0 : ((-1 << (32 - $bits)) & 0xFFFFFFFF); + return ($ipLong & $mask) === ($snLong & $mask); +} + +/** + * IP 白名单判定(第二层)。 + * 规则:127.0.0.1/::1 永远放行(服务器本机逃生舱); + * 白名单为空 → 除本机外任何人一律拒绝(含 IP 缺失的异常请求)。 + */ +function secIpAllowed() { + $ip = secClientIp(); + if ($ip === '') { + secLog('ip_unknown', 'REMOTE_ADDR 缺失,按拒绝处理'); + return false; + } + if (secIsLocalIp($ip)) return true; + if (!defined('ADMIN_IP_WHITELIST') || !is_array(ADMIN_IP_WHITELIST)) return false; + foreach (ADMIN_IP_WHITELIST as $entry) { + $entry = trim((string)$entry); + if ($entry === '') continue; + if ($entry === $ip) return true; + if (strpos($entry, '/') !== false && secCidrMatch($ip, $entry)) return true; + } + return false; +} + +// ====================================================================== +// 审计日志(入侵排查用) +// ====================================================================== + +function secLog($event, $detail = '') { + $dir = __DIR__ . '/db'; + if (!is_dir($dir)) { @mkdir($dir, 0755, true); } + $line = '[' . date('Y-m-d H:i:s') . '] ip=' . secClientIp() . ' event=' . $event + . ($detail !== '' ? ' | ' . $detail : '') . "\n"; + @file_put_contents($dir . '/security.log', $line, FILE_APPEND | LOCK_EX); +} + +// ====================================================================== +// boot 引导令牌(防自锁:读得到 config.php 的管理员 = 服务器管理员) +// ====================================================================== + +function secBootToken() { + return defined('SEC_BOOT_TOKEN') ? (string)SEC_BOOT_TOKEN : ''; +} + +function secEnsureSession() { + if (session_status() === PHP_SESSION_NONE) { + @session_start(); + } +} + +/** boot 会话是否有效(令牌正确 且 与建立会话时的 IP 一致)。 */ +function secBootSessionActive() { + if (secBootToken() === '') return false; + if (empty($_SESSION['sec_boot_ok'])) return false; + return !empty($_SESSION['sec_boot_ip']) && $_SESSION['sec_boot_ip'] === secClientIp(); +} + +/** + * 尝试用 URL/POST 的 boot 参数解锁(成功则建立 boot 会话); + * 已有有效 boot 会话也返回 true。 + */ +function secBootAuthenticate() { + if (secBootToken() === '') return false; + secEnsureSession(); + $t = ''; + if (isset($_GET['boot'])) $t = (string)$_GET['boot']; + elseif (isset($_POST['boot'])) $t = (string)$_POST['boot']; + if ($t !== '' && hash_equals(secBootToken(), $t)) { + $_SESSION['sec_boot_ok'] = 1; + $_SESSION['sec_boot_ip'] = secClientIp(); + secLog('boot_unlock', 'boot 令牌解锁配置页'); + return true; + } + if ($t !== '') { + secLog('boot_fail', 'boot 令牌不匹配'); + } + return secBootSessionActive(); +} + +// ====================================================================== +// 动态访问令牌(第三层:admin.php?token= 放行,否则 nginx 原生 404) +// ====================================================================== + +/** 后台入口令牌(config.php 的 SEC_ACCESS_TOKEN;为空字符串 = 本层未启用)。 */ +function secToken() { + return defined('SEC_ACCESS_TOKEN') ? trim((string)SEC_ACCESS_TOKEN) : ''; +} + +/** 生成新令牌(32 位十六进制,128 bit 随机)。 */ +function secNewToken() { + return bin2hex(random_bytes(16)); +} + +/** + * 复刻 nginx 默认 404 页面(原样字节)。 + * 目的:不给探测者任何"服务器上有这个文件"的线索 —— 拿不到令牌时的 admin.php + * 与一个根本不存在的 .php 文件表现完全相同。 + */ +function secNginx404() { + while (ob_get_level() > 0) { @ob_end_clean(); } + http_response_code(404); + header('Content-Type: text/html'); + header('X-Content-Type-Options: nosniff'); + echo "\r\n" + . "404 Not Found\r\n" + . "\r\n" + . "

404 Not Found

\r\n" + . "
nginx
\r\n" + . "\r\n" + . "\r\n"; + exit; +} + +/** + * 第三层闸门:只有 admin.php?token= 才放行。 + * - 令牌未配置(SEC_ACCESS_TOKEN 为空/不存在)→ 直接放行,向后兼容; + * - $allowBoot=true 时 ?boot= 也可放行(配置页逃生舱, + * boot 令牌本身就是"读得到 config.php 的服务器管理员"凭据); + * - 其余一律 nginx 404(不是 403 —— 403 会暴露文件确实存在)。 + */ +function secTokenGate($allowBoot = false) { + $want = secToken(); + if ($want === '') return; + $got = ''; + if (isset($_GET['token'])) $got = (string)$_GET['token']; + elseif (isset($_POST['token'])) $got = (string)$_POST['token']; + if ($got !== '' && hash_equals($want, $got)) return; + if ($allowBoot && secBootAuthenticate()) return; + if (function_exists('secLog')) { + secLog('token_block', 'path=' . (isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '-')); + } + secNginx404(); +} + +/** 拼出带令牌的后台入口 URL(页面内跳转/展示用;服务器端持有令牌,故可直接拼)。 */ +function secTokenUrl($page = 'admin.php', $extra = '') { + $t = secToken(); + if ($t === '') return $page . $extra; + return $page . (strpos($page, '?') === false ? '?' : '&') . 'token=' . rawurlencode($t) . $extra; +} + +// ====================================================================== +// 页面 / API 闸门(第二层 IP 白名单的挂载点) +// ====================================================================== + +/** 页面级闸门。$allowBoot=true 时接受 boot 令牌解锁(仅配置页使用)。 */ +function secGatePage($allowBoot = false) { + // ★ boot 必须先于 IP 判定:IP 已在白名单时原逻辑直接 return, + // 会导致 admin_2fa_setup.php?boot=... 在"自己 IP 已加白"的情况下进不了引导模式。 + if ($allowBoot && secBootAuthenticate()) return; + if (secIpAllowed()) return; + secLog('ip_block_page', 'path=' . (isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '-')); + while (ob_get_level() > 0) { @ob_end_clean(); } + http_response_code(403); + header('Content-Type: text/html; charset=utf-8'); + $ip = secClientIp() !== '' ? secClientIp() : '(未知)'; + echo '' + . '' + . '403 - 访问被拒绝
' + . '

⛔ 403 访问被拒绝

' + . '

管理后台已启用 IP 白名单(未配置白名单时仅服务器本机可访问)。

' + . '
你的 IP:' . htmlspecialchars($ip, ENT_QUOTES, 'UTF-8') . '(不在白名单内)
' + . '

管理员恢复访问的两种方式:
' + . '1. 通过宝塔/SSH 编辑 plugin/config.php,把上面的 IP 加进 ' + . 'ADMIN_IP_WHITELIST;
' + . '2. 或用同一文件中的 SEC_BOOT_TOKEN 访问:
' + . '你的域名/plugin/admin_2fa_setup.php?boot=令牌

' + . '

本页拒绝记录已写入 db/security.log

' + . '
'; + exit; +} + +/** API 级闸门(JSON 403)。 */ +function secGateApi() { + if (secIpAllowed()) return; + secLog('ip_block_api', 'action=' . (isset($_GET['action']) ? $_GET['action'] : '-')); + while (ob_get_level() > 0) { @ob_end_clean(); } + http_response_code(403); + header('Content-Type: application/json; charset=utf-8'); + header('X-Content-Type-Options: nosniff'); + echo json_encode(array( + 'success' => false, + 'code' => 403, + 'need_ip' => true, + 'ip' => secClientIp(), + 'message' => '访问被拒绝:IP 不在管理白名单内' + ), JSON_UNESCAPED_UNICODE); + exit; +} + +// ====================================================================== +// 会话增强(core.php 的 requireAdminSession / isAdminLoggedIn 调用) +// ====================================================================== + +function secEpoch() { + return defined('SEC_EPOCH') ? (int)SEC_EPOCH : 0; +} + +/** 会话是否晚于安全纪元(部署加固时把之前的活会话全部作废,踢掉入侵者)。 */ +function secSessionEpochOk() { + $t = isset($_SESSION['admin_login_time']) ? (int)$_SESSION['admin_login_time'] : 0; + return $t >= secEpoch(); +} + +/** 当前管理会话是否欠二次验证(已启用 2FA 且尚未通过)。 */ +function secSessionNeed2FA() { + if (!defined('SEC_2FA_ENABLED') || !SEC_2FA_ENABLED) return false; + if (empty($_SESSION['admin_auth'])) return false; + return empty($_SESSION['admin_2fa_ok']); +} + +// ====================================================================== +// TOTP(RFC 6238 / SHA1 / 30 秒 / 6 位) +// ====================================================================== + +/** + * Base32 解码(RFC 4648)。 + * 兼容手工录入的常见脏字符:空格/制表符/连字符/下划线/填充 '=' 一律剔除; + * 手机键盘最容易敲错的 0/1/8/9 按等价字符归一(0→O、1→I、8→B、9→G), + * 否则这些字符会被静默丢弃导致密钥变短、之后所有动态码永远对不上。 + */ +function secB32Decode($s) { + $s = strtoupper((string)$s); + $s = str_replace(array('0', '1', '8', '9'), array('O', 'I', 'B', 'G'), $s); + $s = preg_replace('/[^A-Z2-7]/', '', $s); + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + $buf = 0; $bits = 0; $out = ''; + for ($i = 0; $i < strlen($s); $i++) { + $v = strpos($alphabet, $s[$i]); + if ($v === false) continue; + $buf = ($buf << 5) | $v; + $bits += 5; + if ($bits >= 8) { + $bits -= 8; + $out .= chr(($buf >> $bits) & 0xFF); + } + } + return $out; +} + +function secB32Encode($data) { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + $out = ''; $buf = 0; $bits = 0; + for ($i = 0; $i < strlen($data); $i++) { + $buf = ($buf << 8) | ord($data[$i]); + $bits += 8; + while ($bits >= 5) { + $bits -= 5; + $out .= $alphabet[($buf >> $bits) & 31]; + } + } + if ($bits > 0) $out .= $alphabet[($buf << (5 - $bits)) & 31]; + return $out; +} + +/** 生成 20 字节随机密钥(Base32 无填充,认证器通用格式)。 */ +function secNewSecret() { + return secB32Encode(random_bytes(20)); +} + +/** 计算某一时刻的 6 位动态码。 */ +function secTotp($secret, $time = null, $step = 30, $digits = 6) { + $key = secB32Decode($secret); + if ($key === '') return ''; + $t = ($time === null) ? time() : (int)$time; + $counter = (int)floor($t / $step); + $bin = ''; + for ($i = 7; $i >= 0; $i--) { + $bin .= chr(($counter >> ($i * 8)) & 0xFF); + } + $hash = hash_hmac('sha1', $bin, $key, true); + $offset = ord($hash[19]) & 0x0F; + $code = ((ord($hash[$offset]) & 0x7F) << 24) + | (ord($hash[$offset + 1]) << 16) + | (ord($hash[$offset + 2]) << 8) + | (ord($hash[$offset + 3])); + $code = $code % (int)pow(10, $digits); + return str_pad((string)$code, $digits, '0', STR_PAD_LEFT); +} + +/** + * 校验动态码。 + * 默认 window=3 → 接受 [now-90s, now+90s] 共 7 个时间步(210 秒跨度), + * 容忍手机与服务器之间 ±90 秒的时钟漂移;限速(5 次/15 分钟)仍在, + * 窗口放大不会带来可利用的暴力破解空间。 + */ +function secTotpVerify($secret, $code, $window = 3) { + $code = trim((string)$code); + if (!preg_match('/^\d{6}$/', $code)) return false; + if (!$secret) return false; + $now = time(); + for ($i = -$window; $i <= $window; $i++) { + if (hash_equals(secTotp($secret, $now + $i * 30), $code)) return true; + } + return false; +} + +/** + * 诊断:在 ±$range 个时间步内找这个码到底对应哪个时刻。 + * @return int|null 该码相对服务器当前时间的偏移秒数(30 的倍数);完全无匹配返回 null。 + * + * 用途 —— 验证失败时把"密钥录错了"和"设备时钟不对"这两种原因区分开: + * 返回非 null → 认证器密钥是对的,只是设备时钟偏了(提示快/慢多少秒); + * 返回 null → 码根本不属于这个密钥(手工录入密钥出错 / 绑定了旧密钥), + * 应引导用户回到配置页重新扫码绑定。 + */ +function secTotpDiagnose($secret, $code, $range = 20) { + $code = trim((string)$code); + if (!preg_match('/^\d{6}$/', $code)) return null; + if (!$secret) return null; + $now = time(); + for ($i = -$range; $i <= $range; $i++) { + if (hash_equals(secTotp($secret, $now + $i * 30), $code)) return $i * 30; + } + return null; +} + +/** + * 把诊断结果转成给管理员看的一句话。 + * @param int|null $off secTotpDiagnose 的返回值 + */ +function secTotpDiagnoseMsg($off) { + if ($off === null) { + return '这个 6 位码不属于本后台的密钥 —— 多半是手工输入 Base32 密钥时敲错了字符,' + . '或认证器里添加的是旧密钥。请到「安全配置」页重新生成密钥并扫码绑定。'; + } + $n = abs($off); + $dir = $off > 0 ? '快' : '慢'; + return '认证器密钥是对的,但你的设备时钟比服务器' . $dir . '了约 ' . $n . ' 秒(已超出 ±90 秒容错范围)。' + . '请到手机「设置 → 系统 → 日期和时间」打开「自动设置/网络提供时间」,' + . '等它校准到与服务器一致后(本页会显示服务器时间,可直接比对)再重试。'; +} + +/** 供认证器手动录入的 otpauth 链接(不外联、不生成第三方二维码)。 */ +function secOtpauthUri($secret) { + $label = rawurlencode('SDF1-Admin'); + $issuer = rawurlencode('SDF1'); + return 'otpauth://totp/' . $label . '?secret=' . $secret + . '&issuer=' . $issuer . '&algorithm=SHA1&digits=6&period=30'; +} + +// ====================================================================== +// 限速(db/sec_state.json,文件锁;防 6 位码/密码暴力破解) +// ====================================================================== + +function secStateFile() { + return __DIR__ . '/db/sec_state.json'; +} + +/** + * 记录一次失败并返回当前锁定状态。 + * @return int 剩余锁定秒数(0 = 未锁定) + */ +function secThrottleFail($bucket, $key, $max, $win) { + $f = secStateFile(); + if (!is_dir(dirname($f))) { @mkdir(dirname($f), 0755, true); } + $fp = @fopen($f, 'c+'); + if (!$fp) return 0; + flock($fp, LOCK_EX); + $raw = stream_get_contents($fp); + $state = json_decode($raw, true); + if (!is_array($state)) $state = array(); + $now = time(); + $k = $bucket . '|' . $key; + $arr = isset($state[$k]) && is_array($state[$k]) ? $state[$k] : array(); + $arr[] = $now; + // 顺手清理窗口外的旧记录 + $fresh = array(); + foreach ($arr as $ts) { if ((int)$ts > $now - $win) $fresh[] = (int)$ts; } + $state[$k] = $fresh; + ftruncate($fp, 0); rewind($fp); + fwrite($fp, json_encode($state, JSON_UNESCAPED_UNICODE)); + fflush($fp); flock($fp, LOCK_UN); fclose($fp); + if (count($fresh) >= $max) { + return max(1, ($fresh[0] + $win) - $now); + } + return 0; +} + +/** 只读查询:当前是否被锁定,返回剩余秒数(0 = 未锁定)。 */ +function secThrottleLocked($bucket, $key, $max, $win) { + $f = secStateFile(); + if (!file_exists($f)) return 0; + $fp = @fopen($f, 'r'); + if (!$fp) return 0; + flock($fp, LOCK_SH); + $raw = stream_get_contents($fp); + flock($fp, LOCK_UN); fclose($fp); + $state = json_decode($raw, true); + if (!is_array($state)) return 0; + $k = $bucket . '|' . $key; + if (!isset($state[$k]) || !is_array($state[$k])) return 0; + $now = time(); + $fresh = array(); + foreach ($state[$k] as $ts) { if ((int)$ts > $now - $win) $fresh[] = (int)$ts; } + if (count($fresh) >= $max) { + return max(1, ($fresh[0] + $win) - $now); + } + return 0; +} + +/** 成功后清除该 key 的失败计数。 */ +function secThrottleReset($bucket, $key) { + $f = secStateFile(); + if (!file_exists($f)) return; + $fp = @fopen($f, 'r+'); + if (!$fp) return; + flock($fp, LOCK_EX); + $raw = stream_get_contents($fp); + $state = json_decode($raw, true); + if (is_array($state)) { + $k = $bucket . '|' . $key; + if (isset($state[$k])) { + unset($state[$k]); + ftruncate($fp, 0); rewind($fp); + fwrite($fp, json_encode($state, JSON_UNESCAPED_UNICODE)); + fflush($fp); + } + } + flock($fp, LOCK_UN); fclose($fp); +} + +// ====================================================================== +// config.php 标记区回写(2FA 与白名单的持久化) +// ====================================================================== + +function secConfigPath() { + return __DIR__ . '/config.php'; +} + +/** 值导出为单行 PHP 字面量。 */ +function secExportVal($v) { + if (is_bool($v)) return $v ? 'true' : 'false'; + if (is_int($v) || is_float($v)) return (string)$v; + if (is_array($v)) { + $items = array(); + foreach ($v as $x) $items[] = secExportVal($x); + return 'array(' . implode(', ', $items) . ')'; + } + return var_export((string)$v, true); +} + +/** + * 整段重写 config.php 的 SEC-UPDATE 标记区。 + * - 未在 $kv 中出现的既有 define 一律按旧值保留(防丢 SEC_EPOCH / SEC_BOOT_TOKEN) + * - 写前备份到 db/config_bak/(保留最近 5 份),tmp+rename 原子替换 + * @throws Exception 失败时抛出(调用方给用户回显) + */ +function secWriteConfig(array $kv) { + $path = secConfigPath(); + $src = @file_get_contents($path); + if ($src === false) throw new Exception('读取 config.php 失败'); + + $bPos = strpos($src, 'SEC-UPDATE-BEGIN'); + $ePos = strpos($src, 'SEC-UPDATE-END'); + if ($bPos === false || $ePos === false || $ePos < $bPos) { + throw new Exception('config.php 缺少 SEC-UPDATE 标记区,拒绝写入'); + } + $bStart = strrpos(substr($src, 0, $bPos), "\n"); + $bStart = ($bStart === false) ? 0 : $bStart + 1; + $eEnd = strpos($src, "\n", $ePos); + $eEnd = ($eEnd === false) ? strlen($src) : $eEnd + 1; + + // 解析旧区既有 define(单行约定) + $old = substr($src, $bStart, $eEnd - $bStart); + $kept = array(); + foreach (explode("\n", $old) as $line) { + if (preg_match('/^define\(\'([A-Z0-9_]+)\',\s*(.*)\);\s*$/', trim($line), $m)) { + $kept[$m[1]] = $m[2]; // 保留原始字面量文本 + } + } + foreach ($kv as $k => $v) { + $kept[$k] = secExportVal($v); + } + + // 固定输出顺序(可读性),未知 key 追加在后 + $order = array('SEC_BOOT_TOKEN', 'SEC_ACCESS_TOKEN', 'SEC_EPOCH', 'ADMIN_IP_WHITELIST', 'SEC_2FA_ENABLED', 'SEC_2FA_SECRET'); + $lines = array('// >>>SEC-UPDATE-BEGIN 本区段由 admin_2fa_setup.php 自动维护,请勿手工改动标记行'); + foreach ($order as $k) { + if (isset($kept[$k])) { $lines[] = 'define(\'' . $k . '\', ' . $kept[$k] . ');'; unset($kept[$k]); } + } + foreach ($kept as $k => $lit) { + $lines[] = 'define(\'' . $k . '\', ' . $lit . ');'; + } + $lines[] = '// >>>SEC-UPDATE-END'; + $newBlock = implode("\n", $lines) . "\n"; + + $new = substr($src, 0, $bStart) . $newBlock . substr($src, $eEnd); + + // 备份(保留最近 5 份) + $bakDir = __DIR__ . '/db/config_bak'; + if (!is_dir($bakDir)) { @mkdir($bakDir, 0755, true); } + $bak = $bakDir . '/config.' . date('YmdHis') . '.php'; + @file_put_contents($bak, $src, LOCK_EX); + $baks = glob($bakDir . '/config.*.php'); + if (is_array($baks) && count($baks) > 5) { + sort($baks); + $extra = count($baks) - 5; + for ($i = 0; $i < $extra; $i++) { @unlink($baks[$i]); } + } + + // 原子替换 + $tmp = $path . '.tmp.' . getmypid(); + if (@file_put_contents($tmp, $new, LOCK_EX) === false) { + throw new Exception('写入临时文件失败(目录不可写?)'); + } + if (!@rename($tmp, $path)) { + @unlink($tmp); + throw new Exception('替换 config.php 失败'); + } + if (function_exists('opcache_invalidate')) { @opcache_invalidate($path, true); } + secLog('config_write', 'keys=' . implode(',', array_keys($kv))); + return true; +}