(引导模式,免密码) * 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(); // ★ 首跑顺序(2026-10-09):装库先于安全配置 —— install.php 还没跑完(文件在场、 // 无 install.lock)时,本页先让位给数据库安装页;装完它自删,再回来配 2FA/白名单/令牌。 // 引导模式下 secGatePage 已先行 302,这里兜住「已引导 / 本机直连」两种入口; // 已引导站点带 ?token= 过 install.php 的第三层门禁(令牌为空时 secTokenUrl 原样返回)。 // ★ 0.0.0.0 应急时让位反向失效:install.php 属「其它任何页面」,会被 secEmergencyGate() // 302 回本页 —— 若本页再 302 去 install.php 就成了两页互跳死循环。 // 应急的语义就是「直达引导配置页把白名单改回来」,故应急优先于装库让位。 if (secInstallPending() && !secIsEmergency()) { header('Location: ' . secTokenUrl('install.php')); exit; } // ★ 首次部署:管理密码还是分发占位符 → 走初始化模式。 // 旧行为在这里直接跳登录页,是个死胡同:登录要密码,而这时候根本没有可用密码。 $needInit = !secAdminPassConfigured(); // ===== 访问模式判定 ===== // 说明:本页只依赖 security.php(不加载 core.php), // isAdminLoggedIn() 是 core 的函数,这里用等价的内联判定(admin_auth + 安全纪元)。 $loggedIn = !empty($_SESSION['admin_auth']) && secSessionEpochOk(); $isEmergency = false; if ($boot) { $mode = 'boot'; } elseif (secIsEmergency()) { // ★ 0.0.0.0 应急(2026-10-09):boot / 访问令牌全丢时的最后通道 —— 按 boot // 引导模式免密放行(本分支必须在 session 之前:应急时若 session 开着 2FA, // 会被送去 admin_2fa.php,而那页又被全局应急路由 302 回本页 → 死循环)。 // 引导动作:删掉白名单里的 0.0.0.0 → 保存 → 重新生成令牌,即恢复常态。 $mode = 'boot'; $isEmergency = true; secLog('emergency_setup_enter', 'ip=' . secClientIp()); } elseif ($loggedIn) { // 已登录:若 2FA 已启用但本会话未验证 → 先去验证页 if (defined('SEC_2FA_ENABLED') && SEC_2FA_ENABLED && empty($_SESSION['admin_2fa_ok'])) { header('Location: admin_2fa.php'); exit; } $mode = 'session'; } elseif ($needInit) { $mode = 'init'; } 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(); // ★ 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 = ''; $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') { $act = isset($_POST['act']) ? (string)$_POST['act'] : ''; $allowed = false; if ($mode === 'boot') { $allowed = true; // 引导模式(已持有服务器文件级凭据)免密码 } elseif ($act === 'init_setup') { // ★ 首次初始化:这一步要设的就是密码本身,没有密码可校验。 // 真正的闸门是 $needInit —— 密码一旦配置成功,这条分支立刻关死。 if (!$needInit) { $err = '管理密码已配置,初始化只能进行一次'; } else { $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'] : ''; // 顺带关死"占位符当密码"这条路:密码没配置时 session 模式一律不放行 if ($pw !== '' && secAdminPassConfigured() && $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( // 用变量而不是常量:分发版 config.php 的 SEC-UPDATE 区是空的, // 未定义时常量会触发 PHP8 undefined constant Error;这里取"当前值或本次生成的值", // 首次保存时把随机 boot 令牌 / 访问令牌 / 纪元就地补进 config.php。 'SEC_BOOT_TOKEN' => $GLOBALS['secBootTok'], 'SEC_ACCESS_TOKEN' => $GLOBALS['secToken'], 'SEC_EPOCH' => defined('SEC_EPOCH') ? (int)SEC_EPOCH : time(), 'ADMIN_IP_WHITELIST' => $GLOBALS['whitelist'], 'SEC_2FA_ENABLED' => $GLOBALS['twoFaOn'], 'SEC_2FA_SECRET' => $GLOBALS['twoFaSecret'], ), $over); }; 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(' . htmlspecialchars($myIp, ENT_QUOTES, 'UTF-8') . '),保存后你会立刻把自己挡在门外。请把它加进去,或改成覆盖你网段的 CIDR。'); } $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))); secGuardTwoFaUnchanged($twoFaSnap, 'save_whitelist'); $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') { // ★ 防误触兜底(2026-09-30 事故):本操作曾是表单第一个 submit 按钮, // 任何文本框按回车都会隐式提交命中它。现在只有点了「重新生成令牌」 // 按钮并确认对话框(rotate_confirm=1)才执行;回车等路径一律拒绝。 if (!isset($_POST['rotate_confirm']) || (string)$_POST['rotate_confirm'] !== '1') { secLog('token_rotate_denied', '缺少显式确认标记(疑似回车隐式提交),已拒绝'); throw new Exception('已拦截一次非按钮发起的令牌重置(防误触)。请点「重新生成令牌」按钮并在确认框中点确定。'); } // ★ 需求:重置访问密钥不得关闭二次验证。 // 1) 写前先拍一张 SEC-UPDATE 区快照; // 2) 最小写入 —— 本次只提交"两个令牌键",绝不再走 $full() 把内存里的 // 2FA / 白名单 / 纪元一起带进去(secWriteConfig 对未提及的键一律按 // 磁盘旧值原样保留,所以那些键连一个字节都不会动)。 // 顺带写 SEC_BOOT_TOKEN 是为了:分发版 config.php 的区段是空的, // 页面上算出来的引导令牌必须落盘一次,否则刷新就变、逃生舱永远用不了。 // 3) 写后由 secGuardTwoFaUnchanged 复核磁盘上的 2FA 状态, // 只要被打开/关闭或密钥被清空就立刻回滚并报错。 $twoFaSnap = secReadUpdateBlock(); $newTok = secNewToken(); secWriteConfig(array( 'SEC_ACCESS_TOKEN' => $newTok, 'SEC_BOOT_TOKEN' => $GLOBALS['secBootTok'], )); secGuardTwoFaUnchanged($twoFaSnap, 'rotate_token'); $secToken = $newTok; secLog('token_rotate', 'admin.php 入口令牌已更换,2FA 保持' . ($twoFaOn ? '启用' : '原样')); $ok = '后台入口令牌已重新生成,旧地址立即失效(返回 404)。二次验证状态不受影响(仍为' . ($twoFaOn ? '已启用 ✅' : '未启用') . ')。请复制下方新地址并更新书签。'; } 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=' . $secBootTok; // ★ 第三层:后台唯一正确入口(不带令牌访问 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); } // ===== boot 恢复视角下"我的 IP 是否已被白名单接受" ===== // secIpAllowed() 读的是请求开始时定义的 ADMIN_IP_WHITELIST 常量 —— 本页刚点过 // 「保存白名单」时它还是旧值(secWriteConfig 只改磁盘)。不补判一次的话:刚把 IP // 加进去、页面上的恢复提示横幅却还在说"你不在白名单",运维会以为根本没保存成功。 $wlAllowsMe = secIpAllowed() || secIpInWhitelist($myIp, $whitelist); ?>
0.0.0.0,全站其它页面已全部锁定并跳转到本页。0.0.0.0 并点「保存白名单」(确保你的 IP 在列表里);
② 点「重新生成令牌」重置全部令牌(原令牌视为已泄露);
③ 刷新本页确认 🚨 徽标消失。