diff --git a/admin_2fa_setup.php b/admin_2fa_setup.php index 914da05..48a8c37 100644 --- a/admin_2fa_setup.php +++ b/admin_2fa_setup.php @@ -42,6 +42,9 @@ $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(); +// ★ boot 引导令牌同理:config.php 分发版里 SEC-UPDATE 区是空的(隐私信息不入库), +// 首次保存时自动生成,之后一直沿用 —— 否则逃生舱永远是空令牌、谁都进不来也等于没锁。 +$secBootTok = (defined('SEC_BOOT_TOKEN') && SEC_BOOT_TOKEN !== '') ? (string)SEC_BOOT_TOKEN : secNewToken(); $pending = isset($_SESSION['sec_pending_secret']) ? (string)$_SESSION['sec_pending_secret'] : ''; $myIp = secClientIp(); $err = ''; @@ -80,9 +83,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $mode !== 'none') { // 全量写回(未提及的键保留旧值由 secWriteConfig 内部保证) $full = function ($over) { return array_merge(array( - 'SEC_BOOT_TOKEN' => SEC_BOOT_TOKEN, + // 用变量而不是常量:分发版 config.php 的 SEC-UPDATE 区是空的, + // 未定义时常量会触发 PHP8 undefined constant Error;这里取"当前值或本次生成的值", + // 首次保存时把随机 boot 令牌 / 访问令牌 / 纪元就地补进 config.php。 + 'SEC_BOOT_TOKEN' => $GLOBALS['secBootTok'], 'SEC_ACCESS_TOKEN' => $GLOBALS['secToken'], - 'SEC_EPOCH' => (int)SEC_EPOCH, + 'SEC_EPOCH' => defined('SEC_EPOCH') ? (int)SEC_EPOCH : time(), 'ADMIN_IP_WHITELIST' => $GLOBALS['whitelist'], 'SEC_2FA_ENABLED' => $GLOBALS['twoFaOn'], 'SEC_2FA_SECRET' => $GLOBALS['twoFaSecret'], @@ -106,7 +112,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $mode !== 'none') { } } $items = array_values(array_unique($items)); + // 改白名单同样不许碰 2FA:写前快照 + 写后护栏(同 rotate_token) + $twoFaSnap = secReadUpdateBlock(); secWriteConfig($full(array('ADMIN_IP_WHITELIST' => $items))); + secGuardTwoFaUnchanged($twoFaSnap, 'save_whitelist'); $whitelist = $items; secLog('whitelist_save', 'count=' . count($items) . ' ' . implode(',', $items)); $ok = 'IP 白名单已保存(' . count($items) . ' 条)' . (count($items) === 0 ? '。注意:白名单为空 = 仅服务器本机可访问后台!' : ''); @@ -151,11 +160,26 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $mode !== 'none') { secLog('twofa_enabled', ''); $ok = '✅ 二次验证已启用!之后每次登录后台都必须输入 6 位动态码(当前会话也需重新验证)'; } elseif ($act === 'rotate_token') { + // ★ 需求:重置访问密钥不得关闭二次验证。 + // 1) 写前先拍一张 SEC-UPDATE 区快照; + // 2) 最小写入 —— 本次只提交"两个令牌键",绝不再走 $full() 把内存里的 + // 2FA / 白名单 / 纪元一起带进去(secWriteConfig 对未提及的键一律按 + // 磁盘旧值原样保留,所以那些键连一个字节都不会动)。 + // 顺带写 SEC_BOOT_TOKEN 是为了:分发版 config.php 的区段是空的, + // 页面上算出来的引导令牌必须落盘一次,否则刷新就变、逃生舱永远用不了。 + // 3) 写后由 secGuardTwoFaUnchanged 复核磁盘上的 2FA 状态, + // 只要被打开/关闭或密钥被清空就立刻回滚并报错。 + $twoFaSnap = secReadUpdateBlock(); $newTok = secNewToken(); - secWriteConfig($full(array('SEC_ACCESS_TOKEN' => $newTok))); + secWriteConfig(array( + 'SEC_ACCESS_TOKEN' => $newTok, + 'SEC_BOOT_TOKEN' => $GLOBALS['secBootTok'], + )); + secGuardTwoFaUnchanged($twoFaSnap, 'rotate_token'); $secToken = $newTok; - secLog('token_rotate', 'admin.php 入口令牌已更换'); - $ok = '后台入口令牌已重新生成,旧地址立即失效(返回 404)。请复制下方新地址并更新书签。'; + secLog('token_rotate', 'admin.php 入口令牌已更换,2FA 保持' . ($twoFaOn ? '启用' : '原样')); + $ok = '后台入口令牌已重新生成,旧地址立即失效(返回 404)。二次验证状态不受影响(仍为' + . ($twoFaOn ? '已启用 ✅' : '未启用') . ')。请复制下方新地址并更新书签。'; } elseif ($act === 'twofa_disable') { if ($mode !== 'boot') { // 非引导模式:必须出示当前动态码(证明认证器在手) @@ -187,7 +211,7 @@ $reqPath = parse_url(isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : $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; +$recoverUrl = $scheme . '://' . $host . $baseDir . '/admin_2fa_setup.php?boot=' . $secBootTok; // ★ 第三层:后台唯一正确入口(不带令牌访问 admin.php 一律 404) $adminUrl = $scheme . '://' . $host . $baseDir . '/admin.php?token=' . rawurlencode($secToken); @@ -287,11 +311,11 @@ if (file_exists($secLogPath)) {
IP 白名单:
你的 IP:
- 安全纪元:(此前的登录会话已全部作废)
+ 安全纪元: 0 ? date('Y-m-d H:i:s', secEpoch()) : '未设置(首次保存时自动定格)'; ?>(此前的登录会话已全部作废)
后台入口令牌:
- 引导令牌: - + 引导令牌: +
🔖 后台唯一入口(请收藏这一条,不带 token 访问 admin.php 一律返回 404):
diff --git a/security.php b/security.php index 1335b9b..1888ffb 100644 --- a/security.php +++ b/security.php @@ -527,6 +527,84 @@ function secExportVal($v) { return var_export((string)$v, true); } +/** + * 读取 config.php 的 SEC-UPDATE 区当前各 define 原始字面量(写前快照 / 写后自检用)。 + * @return array|null 键=常量名,值=define 第二参数的原文;区段缺失或不可读返回 null + */ +function secReadUpdateBlock() { + $path = secConfigPath(); + $src = @file_get_contents($path); + if ($src === false) return null; + $bPos = strpos($src, 'SEC-UPDATE-BEGIN'); + $ePos = strpos($src, 'SEC-UPDATE-END'); + if ($bPos === false || $ePos === false || $ePos < $bPos) return null; + $blk = substr($src, $bPos, $ePos - $bPos); + $out = array(); + foreach (explode("\n", $blk) as $line) { + if (preg_match('/^define\(\'([A-Z0-9_]+)\',\s*(.*)\);\s*$/', trim($line), $m)) { + $out[$m[1]] = $m[2]; + } + } + return $out; +} + +/** + * 二次验证状态的语义指纹:'on|set' / 'off|empty' / null(读不到)。 + * 用语义而不是字面量比对,避免 true/1 这类等价写法误判。 + */ +function secTwoFaState($blk) { + if (!is_array($blk)) return null; + if (!array_key_exists('SEC_2FA_ENABLED', $blk)) return null; + $on = trim($blk['SEC_2FA_ENABLED']); + $enabled = ($on === 'true' || $on === '1' + || ((strncmp($on, '\'', 1) === 0 || strncmp($on, '"', 1) === 0) && trim($on, '\'"') !== '')); + $sec = array_key_exists('SEC_2FA_SECRET', $blk) ? trim($blk['SEC_2FA_SECRET']) : ''; + $hasSecret = !($sec === '' || $sec === "''" || $sec === '""' || $sec === 'null'); + return ($enabled ? 'on' : 'off') . '|' . ($hasSecret ? 'set' : 'empty'); +} + +/** + * 还原 config.php 到写入前的最近一次自动备份(secWriteConfig 每次写前必备份)。 + * @return bool 是否还原成功 + */ +function secRestoreLastConfigBackup($op) { + $baks = glob(__DIR__ . '/db/config_bak/config.*.php'); + if (!is_array($baks) || count($baks) === 0) return false; + sort($baks); + $last = $baks[count($baks) - 1]; + $src = @file_get_contents($last); + if ($src === false) return false; + $path = secConfigPath(); + if (@file_put_contents($path, $src, LOCK_EX) === false) return false; + if (function_exists('opcache_invalidate')) @opcache_invalidate($path, true); + secLog('config_rollback', $op . ' restored from ' . basename($last)); + return true; +} + +/** + * 二次验证护栏:给「本轮操作本来就不该动 2FA」的动作(重置访问令牌、改白名单)用。 + * 用写入前的快照和写入后的磁盘实际状态比对,一旦 2FA 被打开/关闭或密钥被清空, + * 立即从写前备份回滚并抛错 —— 结构上保证"重置访问密钥绝不会顺带关掉二次验证"。 + * + * @param array|null $before 写入前 secReadUpdateBlock() 的快照 + * @param string $op 操作名(日志用) + * @throws Exception 检测到 2FA 状态被改动时(此时已尝试回滚) + */ +function secGuardTwoFaUnchanged($before, $op) { + $b = secTwoFaState($before); + if ($b === null) return; // 快照不可得:护栏不拦,避免误伤 + $a = secTwoFaState(secReadUpdateBlock()); + if ($a === null) return; + if ($b === $a) { + secLog('twofa_guard_ok', $op . ' 2FA=' . $a . ' 未变'); + return; + } + $restored = secRestoreLastConfigBackup($op); + secLog('twofa_guard_trip', $op . ' 2FA ' . $b . ' -> ' . $a . ' | rollback=' . ($restored ? 'ok' : 'FAIL')); + throw new Exception('安全护栏触发:' . $op . ' 意外改动了二次验证配置,已' + . ($restored ? '自动回滚,二次验证未被关闭。' : '回滚失败,请立刻用引导令牌进本页检查 2FA 状态。')); +} + /** * 整段重写 config.php 的 SEC-UPDATE 标记区。 * - 未在 $kv 中出现的既有 define 一律按旧值保留(防丢 SEC_EPOCH / SEC_BOOT_TOKEN)