引导初始化:管理密码首次由部署者自设,解开占位符密码无法保存配置的死结

This commit is contained in:
ypsdf1
2026-10-02 17:10:39 +08:00
parent e18d8df22f
commit 5912c6fd76
5 changed files with 257 additions and 23 deletions
+94 -18
View File
@@ -17,6 +17,10 @@ secEnsureSession();
secGatePage(true);
$boot = secBootSessionActive();
// ★ 首次部署:管理密码还是分发占位符 → 走初始化模式。
// 旧行为在这里直接跳登录页,是个死胡同:登录要密码,而这时候根本没有可用密码。
$needInit = !secAdminPassConfigured();
// ===== 访问模式判定 =====
// 说明:本页只依赖 security.php(不加载 core.php),
// isAdminLoggedIn() 是 core 的函数,这里用等价的内联判定(admin_auth + 安全纪元)。
@@ -30,6 +34,8 @@ if ($boot) {
exit;
}
$mode = 'session';
} elseif ($needInit) {
$mode = 'init';
} else {
header('Location: admin_login.php');
exit;
@@ -49,6 +55,11 @@ $pending = isset($_SESSION['sec_pending_secret']) ? (string)$_SESSION['sec_pendi
$myIp = secClientIp();
$err = '';
$ok = '';
// 初始化成功后 302 回本页刷新(令牌要落盘后重读才准),提示语靠 session 带过来
if (!empty($_SESSION['sec_flash_ok'])) {
$ok = (string)$_SESSION['sec_flash_ok'];
unset($_SESSION['sec_flash_ok']);
}
// ===== POST:所有写操作 =====
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $mode !== 'none') {
@@ -57,6 +68,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $mode !== 'none') {
if ($mode === 'boot') {
$allowed = true; // 引导模式(已持有服务器文件级凭据)免密码
} elseif ($act === 'init_setup') {
// ★ 首次初始化:这一步要设的就是密码本身,没有密码可校验。
// 真正的闸门是 $needInit —— 密码一旦配置成功,这条分支立刻关死。
if (!$needInit) {
$err = '管理密码已配置,初始化只能进行一次';
} else {
$allowed = true;
}
} else {
// 第二层附加保护:改配置必须重输管理密码(防活会话被盗用者直接改安全配置)
$lock = secThrottleLocked('setup_pw', $myIp, 5, 600);
@@ -65,7 +84,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $mode !== 'none') {
secLog('setup_throttled', 'lock=' . $lock . 's');
} else {
$pw = isset($_POST['admin_pass']) ? (string)$_POST['admin_pass'] : '';
if ($pw !== '' && $pw === ADMIN_PASS) {
// 顺带关死"占位符当密码"这条路:密码没配置时 session 模式一律不放行
if ($pw !== '' && secAdminPassConfigured() && $pw === ADMIN_PASS) {
$allowed = true;
secThrottleReset('setup_pw', $myIp);
} else {
@@ -95,23 +115,45 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $mode !== 'none') {
), $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'));
}
if ($act === 'init_setup') {
// ===== 首次初始化:设置管理密码 + 写入白名单,一次做完 =====
if (!$needInit) throw new Exception('管理密码已配置,初始化只能进行一次');
$pw1 = isset($_POST['new_pass']) ? (string)$_POST['new_pass'] : '';
$ve = secValidateNewPassword($pw1, isset($_POST['new_pass2']) ? (string)$_POST['new_pass2'] : '');
if ($ve !== null) throw new Exception($ve);
$items = secParseWhitelistText(isset($_POST['whitelist']) ? (string)$_POST['whitelist'] : '');
// 自锁保护:初始化页还没展示过任何恢复地址,白名单里漏掉自己的 IP
// 就会保存完立刻 404、且不知道 boot 令牌是什么 → 这里直接拦下来。
if (!secIpInWhitelist($myIp, $items)) {
throw new Exception('白名单里没有你当前的 IP(<b>' . htmlspecialchars($myIp, ENT_QUOTES, 'UTF-8')
. '</b>),保存后你会立刻把自己挡在门外。请把它加进去,或改成覆盖你网段的 CIDR。');
}
$items = array_values(array_unique($items));
$twoFaSnap = secReadUpdateBlock();
// ① 先改写 config 第 32 行那条 ADMIN_PASS,再写 SEC-UPDATE 区
// (白名单 + 自动生成的两个令牌 + 安全纪元,顺手打引导标记)。
// 任一步失败都抛给下面的 catch 显示,可直接重试、不会把人锁死:
// 密码没设成 → 还是初始化页;密码设成而区段没写成 → 用新密码登录回来接着配。
secWriteAdminPass($pw1);
secWriteConfig($full(array('ADMIN_IP_WHITELIST' => $items)));
secGuardTwoFaUnchanged($twoFaSnap, 'init_setup');
$whitelist = $items;
secLog('admin_init', 'whitelist=' . count($items) . ' ' . implode(',', $items));
// ② 自动登录,省得刚设完密码又敲一遍
secEnsureSession();
$_SESSION['admin_auth'] = true;
$_SESSION['admin_login_time'] = time();
unset($_SESSION['admin_2fa_ok'], $_SESSION['admin_2fa_ip']);
// ③ 回本页刷新:两个令牌是这次才落盘的,当前请求里的 $secToken 还是
// 刚生成、没保存过的随机值,直接渲染出来会骗人(保存后就对不上了)。
$_SESSION['sec_flash_ok'] = '初始化完成:管理密码已设置,IP 白名单已保存(' . count($items)
. ' 条),门禁已转为强制模式。请立刻收藏本页显示的后台入口地址。';
header('Location: admin_2fa_setup.php');
exit;
} elseif ($act === 'save_whitelist') {
$items = secParseWhitelistText(isset($_POST['whitelist']) ? (string)$_POST['whitelist'] : '');
// 改白名单同样不许碰 2FA:写前快照 + 写后护栏(同 rotate_token)
$twoFaSnap = secReadUpdateBlock();
secWriteConfig($full(array('ADMIN_IP_WHITELIST' => $items)));
@@ -290,7 +332,8 @@ if (file_exists($secLogPath)) {
<h1>🔐 后台安全配置</h1>
<div>
<?php if ($mode === 'boot'): ?><span class="badge b-boot">引导模式(boot 令牌)</span><?php endif; ?>
<a href="<?php echo $twoFaOn ? 'admin_2fa.php' : htmlspecialchars(secTokenUrl('admin.php'), ENT_QUOTES, 'UTF-8'); ?>">← 返回</a>
<?php if ($mode === 'init'): ?><span class="badge b-boot">首次初始化</span><?php endif; ?>
<?php if (!$needInit): ?><a href="<?php echo $twoFaOn ? 'admin_2fa.php' : htmlspecialchars(secTokenUrl('admin.php'), ENT_QUOTES, 'UTF-8'); ?>">← 返回</a><?php endif; ?>
</div>
</div>
<div class="sub">第一层:TOTP 二次验证(6 位动态码) | 第二层:管理面 IP 白名单(未配置时仅本机可访问)</div>
@@ -298,6 +341,38 @@ if (file_exists($secLogPath)) {
<?php if ($err !== ''): ?><span class="msg err">❌ <?php echo $err; ?></span><?php endif; ?>
<?php if ($ok !== ''): ?><span class="msg ok">✅ <?php echo $ok; ?></span><?php endif; ?>
<?php if ($needInit): ?>
<!-- ===== 首次初始化:管理密码还是占位符时,本页唯一的入口 ===== -->
<form method="post" autocomplete="off">
<!-- act 默认就是 init_setup:回车隐式提交也走对分支(靠 JS 置值的话回车会提交空 act) -->
<input type="hidden" name="act" id="act" value="init_setup">
<div class="card" style="border-color:#d29922">
<h2>🚀 首次初始化 · 设置你的管理密码</h2>
<div class="hint" style="margin-top:0;margin-bottom:14px;line-height:1.9">
这是全新部署的第一次访问。config.php 里的管理密码还是出厂占位符
<code>REPLACE_ME_ADMIN_PASSWORD</code>,它不是可用密码 ——
<b>请在这里设置你自己的后台管理密码</b>,并确认下方白名单里有你的 IP。<br>
保存后门禁立刻转为强制模式:不在白名单的请求一律 nginx 404,
此后登录、保存配置都用这个密码(不存在"默认密码"这回事)。
</div>
<div class="row"><label>新管理密码</label>
<input type="password" name="new_pass" id="new_pass" placeholder="至少 6 位" required></div>
<div class="row"><label>再次输入</label>
<input type="password" name="new_pass2" id="new_pass2" placeholder="再输一遍确认" required></div>
<div class="row"><label>IP 白名单</label>
<textarea name="whitelist" id="initWhitelist" placeholder="一行一个 IP 或 CIDR"><?php echo htmlspecialchars($myIp, ENT_QUOTES, 'UTF-8'); ?></textarea>
</div>
<div class="hint">
已预填你当前的访问 IP <code><?php echo htmlspecialchars($myIp, ENT_QUOTES, 'UTF-8'); ?></code>,
请确认无误;还有别的管理机就一行填一个。<br>
白名单留空 = 只有服务器本机 127.0.0.1 能进后台(等于把自己挡在外面,只能靠引导令牌回来)。
</div>
<div class="row" style="margin-top:14px;margin-bottom:0">
<button type="submit" class="btn btn-green" onclick="setAct('init_setup')">保存并进入后台</button>
</div>
</div>
</form>
<?php else: ?>
<form method="post" autocomplete="off" id="secForm">
<input type="hidden" name="act" id="act" value="">
<input type="hidden" name="rotate_confirm" id="rotateConfirmBox" value="">
@@ -405,6 +480,7 @@ if (file_exists($secLogPath)) {
<div class="logbox"><?php echo $logLines ? htmlspecialchars(implode("\n", $logLines), ENT_QUOTES, 'UTF-8') : '(暂无记录)'; ?></div>
</div>
</form>
<?php endif; ?>
</div>
<!-- QR Code Generator for JavaScript v1.4.4 | MIT | Copyright (c) 2009 Kazuhiko Arase
+6
View File
@@ -2,6 +2,12 @@
// ★ 紧急加固(2026-09-29):第二层 IP 白名单 —— 登录页本身也只对白名单 IP 开放
require_once __DIR__ . '/security.php';
secGatePage();
// ★ 首次部署:管理密码还是分发占位符 → 登录页是个死胡同(要密码,可密码还不存在)。
// 改投初始化页,让部署者先把自己的第一个密码设出来。
if (!secAdminPassConfigured()) {
header('Location: admin_2fa_setup.php');
exit;
}
// ★ 人机验证(2026-10-02):cgWidgetHtml() 渲染挂件,未配密钥时自动放行
require_once __DIR__ . '/captcha_guard.php';
?>
+7 -1
View File
@@ -486,7 +486,13 @@ function shopBuyCart($token) {
// 收银员已登录即授权,无需额外密码
} elseif ($isAdminToken || $isAdminSession) {
// 代购通路:校验管理员登录密码(代购操作者的密码),而非目标玩家游戏密码
if (!$password || trim($password) === '' || $password !== ADMIN_PASS) {
// ★ 首次部署:ADMIN_PASS 还是占位符 = 没配置密码,此时任何"管理员密码"
// 都无从校验 → 一律按未通过处理(引导页会要求先设置密码)。
$passReady = function_exists('secAdminPassConfigured')
? secAdminPassConfigured()
: (defined('ADMIN_PASS') && trim((string)ADMIN_PASS) !== ''
&& trim((string)ADMIN_PASS) !== 'REPLACE_ME_ADMIN_PASSWORD');
if (!$password || trim($password) === '' || !$passReady || $password !== ADMIN_PASS) {
jsonResponse(['success' => false, 'need_password' => true,
'player' => $player, 'message' => '金额≥1000债券需确认管理员密码'], 401);
}
+5
View File
@@ -1243,6 +1243,11 @@ function requireAdminSession() {
// ===== 管理员认证 =====
function adminLogin($password) {
// ★ 首次部署:ADMIN_PASS 还是出厂占位符 = 密码根本没配置,
// 绝不允许拿占位符当密码登录(配置页会引导用户先设自己的密码)。
if (function_exists('secAdminPassConfigured') && !secAdminPassConfigured()) {
return false;
}
if ($password !== ADMIN_PASS) {
return false;
}
+145 -4
View File
@@ -748,6 +748,91 @@ function secGuardTwoFaUnchanged($before, $op) {
. ($restored ? '自动回滚,二次验证未被关闭。' : '回滚失败,请立刻用引导令牌进本页检查 2FA 状态。'));
}
// ======================================================================
// 管理员密码:首次由部署者自己设置(引导初始化)
// ======================================================================
//
// 分发版 config.php 里 ADMIN_PASS 是占位符 REPLACE_ME_ADMIN_PASSWORD —— 它不是密码,
// 只是"还没配"的标记。旧流程的死结:配置页在会话模式下每次写操作都要重输管理密码,
// 可部署者手上根本没有可用密码,于是 IP 白名单永远提交不进去。
//
// 现在:密码还是占位符 → admin_2fa_setup.php 直接渲染初始化卡片,让部署者第一次就
// 把自己的密码设好(白名单预填当前 IP),设完自动登录、门禁随即转强制。
// 已经配过真密码的站点(线上 /plugin 一直是真密码)完全不受影响,照旧走登录流程。
/** 分发版占位符:出现这个值 = 密码尚未配置。 */
function secAdminPassPlaceholder() {
return 'REPLACE_ME_ADMIN_PASSWORD';
}
/** 管理员密码是否已真正配置(非空且不是占位符)。 */
function secAdminPassConfigured() {
if (!defined('ADMIN_PASS')) return false;
$p = trim((string)ADMIN_PASS);
if ($p === '' || $p === secAdminPassPlaceholder()) return false;
return true;
}
/** 新密码强度校验:通过返回 null,否则返回中文错误信息。 */
function secValidateNewPassword($pw, $pw2 = null) {
$pw = (string)$pw;
if ($pw === secAdminPassPlaceholder()) return '不能把默认占位符当密码';
if (strlen($pw) < 6) return '密码至少 6 位';
if (strlen($pw) > 128) return '密码过长(最多 128 位)';
if (trim($pw) !== $pw) return '密码首尾不能有空格';
if ($pw2 !== null && (string)$pw2 !== $pw) return '两次输入的密码不一致';
return null;
}
/**
* 解析 IP 白名单文本(一行一个 IP / CIDR,去空行去重)。
* 初始化卡片与白名单保存共用同一套校验,避免两处规则漂移。
* @throws Exception 含非法条目时抛出(调用方给用户回显)
*/
function secParseWhitelistText($text) {
$items = array();
foreach (preg_split('/\r\n|\r|\n/', (string)$text) 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'));
}
}
return array_values(array_unique($items));
}
/**
* IP 是否命中白名单(支持精确 IP 与 IPv4 CIDR;本机回环恒命中)。
* 用途:首次初始化时确认"部署者自己不会被刚保存的白名单挡在门外"。
*/
function secIpInWhitelist($ip, $items) {
if ($ip === '') return true;
if ($ip === '127.0.0.1' || $ip === '::1') return true;
$v4 = (string)filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
foreach ((array)$items as $it) {
$it = trim((string)$it);
if ($it === '') continue;
if (strpos($it, '/') === false) {
if ($it === $ip) return true;
continue;
}
if ($v4 !== $ip) continue; // CIDR 解析只做 IPv4
list($net, $bits) = explode('/', $it, 2);
$n = ip2long($net);
$b = (int)$bits;
if ($n === false || $b < 0 || $b > 32) continue;
$m = ($b === 0) ? 0 : (-1 << (32 - $b));
if ((ip2long($ip) & $m) === ($n & $m)) return true;
}
return false;
}
/**
* 整段重写 config.php 的 SEC-UPDATE 标记区。
* - 未在 $kv 中出现的既有 define 一律按旧值保留(防丢 SEC_EPOCH / SEC_BOOT_TOKEN)
@@ -795,6 +880,23 @@ function secWriteConfig(array $kv) {
$new = substr($src, 0, $bStart) . $newBlock . substr($src, $eEnd);
secReplaceConfigFile($src, $new, 'keys=' . implode(',', array_keys($kv)));
// ★ 引导标记:配置一落盘 = "这站已经用过后台面板",secIsBootstrapped() 的判据 2
// 从此成立(判据 1 是 config 里已有令牌,两者互为兜底)。
secMarkBootstrapped('secWriteConfig:' . implode(',', array_keys($kv)));
return true;
}
/**
* 备份 config.php(保留最近 5 份)并原子替换 —— secWriteConfig / secWriteAdminPass 共用。
* @param string $src 替换前的原文(用于备份)
* @param string $new 替换后的全文
* @param string $op 操作标识(写进 security.log)
* @throws Exception 备份目录不可写 / 临时文件写不进 / rename 失败
*/
function secReplaceConfigFile($src, $new, $op) {
$path = secConfigPath();
// 备份(保留最近 5 份)
$bakDir = __DIR__ . '/db/config_bak';
if (!is_dir($bakDir)) { @mkdir($bakDir, 0755, true); }
@@ -817,10 +919,49 @@ function secWriteConfig(array $kv) {
throw new Exception('替换 config.php 失败');
}
if (function_exists('opcache_invalidate')) { @opcache_invalidate($path, true); }
secLog('config_write', 'keys=' . implode(',', array_keys($kv)));
// ★ 引导标记:配置一落盘 = "这站已经用过后台面板",secIsBootstrapped() 的判据 2
// 从此成立(判据 1 是 config 里已有令牌,两者互为兜底)。
secMarkBootstrapped('secWriteConfig:' . implode(',', array_keys($kv)));
secLog('config_write', $op);
return true;
}
/**
* 首次初始化:把管理密码写进 config.php 的 `define('ADMIN_PASS', ...)`。
*
* 注意两点:
* - ADMIN_PASS 定义在 SEC-UPDATE 区**之外**(config.php 第 32 行),而 PHP 重复
* define 只认第一次、之后的会告警并被忽略 —— 所以只能就地改写那一行,不能往
* SEC-UPDATE 区里再塞一个同名 define。
* - 就地改写用逐行匹配而不是正则,避免密码里带 $ / 引号 时被当替换模式或引用吃掉。
*
* @throws Exception 读写 config.php 失败
*/
function secWriteAdminPass($pw) {
$path = secConfigPath();
$src = @file_get_contents($path);
if ($src === false) throw new Exception('读取 config.php 失败');
$line = 'define(\'ADMIN_PASS\', ' . secExportVal((string)$pw) . '); // 管理员密码(引导初始化写入)';
$lines = explode("\n", $src);
$idx = -1;
foreach ($lines as $i => $L) {
if (strncmp(ltrim($L), 'define(\'ADMIN_PASS\'', 19) === 0) { $idx = $i; break; }
}
if ($idx >= 0) {
if (trim($lines[$idx]) === trim($line)) return true; // 值没变,别白备份一次
$lines[$idx] = $line;
} else {
// 模板里没有这一行:插到 SEC-UPDATE 区之前。必须在任何既有定义之前才生效。
$bPos = -1;
foreach ($lines as $i => $L) {
if (strpos($L, 'SEC-UPDATE-BEGIN') !== false) { $bPos = $i; break; }
}
if ($bPos < 0) throw new Exception('config.php 缺少 SEC-UPDATE 标记区,拒绝写入');
array_splice($lines, $bPos, 0, array($line));
}
secReplaceConfigFile($src, implode("\n", $lines), 'admin_pass');
secLog('admin_pass_written', 'len=' . strlen((string)$pw));
return true;
}