需求4护栏:重置访问密钥不再关闭二次验证——security.php新增2FA快照/语义指纹/回滚/写后比对四函数,rotate_token与save_whitelist走最小写入+护栏,误关即回滚报错
This commit is contained in:
+33
-9
@@ -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)) {
|
||||
<span class="badge <?php echo $twoFaOn ? 'b-on' : 'b-off'; ?>"><?php echo $twoFaOn ? 'ON' : 'OFF'; ?></span><br>
|
||||
IP 白名单:<b><?php echo count($whitelist) === 0 ? '空(仅服务器本机可访问)⚠️' : count($whitelist) . ' 条'; ?></b><br>
|
||||
你的 IP:<b style="font-family:monospace;color:#58a6ff"><?php echo htmlspecialchars($myIp, ENT_QUOTES, 'UTF-8'); ?></b><br>
|
||||
安全纪元:<b><?php echo date('Y-m-d H:i:s', (int)SEC_EPOCH); ?></b>(此前的登录会话已全部作废)<br>
|
||||
安全纪元:<b><?php echo secEpoch() > 0 ? date('Y-m-d H:i:s', secEpoch()) : '未设置(首次保存时自动定格)'; ?></b>(此前的登录会话已全部作废)<br>
|
||||
后台入口令牌:<b style="font-family:monospace;color:#3fb950"><?php echo htmlspecialchars($secToken, ENT_QUOTES, 'UTF-8'); ?></b>
|
||||
<button type="button" class="copy" onclick="copyText('<?php echo htmlspecialchars($secToken, ENT_QUOTES, 'UTF-8'); ?>', this)">复制</button><br>
|
||||
引导令牌:<b style="font-family:monospace;color:#d29922"><?php echo htmlspecialchars(SEC_BOOT_TOKEN, ENT_QUOTES, 'UTF-8'); ?></b>
|
||||
<button type="button" class="copy" onclick="copyText('<?php echo htmlspecialchars(SEC_BOOT_TOKEN, ENT_QUOTES, 'UTF-8'); ?>', this)">复制</button>
|
||||
引导令牌:<b style="font-family:monospace;color:#d29922"><?php echo htmlspecialchars($secBootTok, ENT_QUOTES, 'UTF-8'); ?></b>
|
||||
<button type="button" class="copy" onclick="copyText('<?php echo htmlspecialchars($secBootTok, ENT_QUOTES, 'UTF-8'); ?>', this)">复制</button>
|
||||
<div class="hint">🔖 后台唯一入口(请收藏这一条,不带 token 访问 admin.php 一律返回 404):<br><code><?php echo htmlspecialchars($adminUrl, ENT_QUOTES, 'UTF-8'); ?></code>
|
||||
<button type="button" class="copy" onclick="copyText('<?php echo htmlspecialchars($adminUrl, ENT_QUOTES, 'UTF-8'); ?>', this)">复制地址</button>
|
||||
<button type="submit" class="btn btn-gray" style="margin-left:6px" onclick="setAct('rotate_token')">重新生成令牌</button></div>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user