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 均可)
+
+
+
当前访问 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 - 后台安全配置
+
+
+
+
+
+
第一层:TOTP 二次验证(6 位动态码) | 第二层:管理面 IP 白名单(未配置时仅本机可访问)
+
+
❌
+
✅
+
+
+
+
+
+
+
+
+
+
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;
+}