(引导模式,免密码) * 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(); // ★ 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 = ''; // ===== 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( // 用变量而不是常量:分发版 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 === '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)); // 改白名单同样不许碰 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); } ?>