紧急加固三层:admin.php 入口令牌门禁 + 2FA 扫码绑定与精确诊断

第三层(新增,config.php 的 SEC_ACCESS_TOKEN):
- 只有 admin.php?token=<令牌> 才放行,缺/错令牌一律返回 nginx 原生 404
  (与"文件真的不存在"逐字节一致,探测者无从判断),令牌检查置于白名单之前
- 登录接口在密码校验通过后才下发 token,2FA 通过与收银台跳转由服务端补拼
- 入口页加 Referrer-Policy: no-referrer,防令牌经外链 Referer 泄露
- 配置页展示后台唯一入口地址,支持一键重新生成令牌

第一层修复(2FA 验证码"无效"):
- secTotpVerify 容错窗口 ±30 秒扩到 ±90 秒
- 新增 secTotpDiagnose:失败时区分"密钥没对上"与"设备时钟偏移多少秒"并给出处置指引
- Base32 解码兼容空格/连字符与 0/1/8/9 手误,避免密钥被静默截短
- 验证页倒计时与服务器时间显示改用服务端时钟基准
- 密钥生成改幂等,"重新生成"二次确认,防止已扫码密钥被误作废

第二层(二维码):
- admin_2fa_setup.php 内联 qrcode-generator(MIT) 生成 otpauth 二维码,离线可用
- 后台侧边栏新增"安全配置"入口;admin_2fa_setup.php?boot= 在白名单 IP 下同样生效

已通过线上端到端验证 22 项 + 二维码 OpenCV 往返解码校验。
This commit is contained in:
ypsdf1
2026-09-29 22:55:46 +08:00
parent fd03189f31
commit c46c9ebdfe
9 changed files with 3622 additions and 9 deletions
+165
View File
@@ -0,0 +1,165 @@
<?php
/**
* 第一层二次验证 —— 6 位动态码验证页
* 兼容 1Password / Microsoft Authenticator / Google Authenticator 等标准 RFC6238 认证器。
* 登录成功且 SEC_2FA_ENABLED=true 时,前端跳转到本页;验证通过后进入 admin.php。
*/
require_once __DIR__ . '/security.php';
secEnsureSession();
// IP 白名单闸门;boot 引导会话(配置流程中转)也放行
secGatePage(true);
$enabled = defined('SEC_2FA_ENABLED') && SEC_2FA_ENABLED;
$err = '';
// 未启用 → 引导去配置页
if (!$enabled) {
header('Location: admin_2fa_setup.php');
exit;
}
// 未登录 / 旧纪元会话 → 回登录页
// (本页只依赖 security.php,不加载 core.php,故用等价内联判定)
if (empty($_SESSION['admin_auth']) || !secSessionEpochOk()) {
header('Location: admin_login.php');
exit;
}
// 已通过 → 直接进后台(★ 第三层:必须带上入口令牌,否则 admin.php 会返回 404)
if (!empty($_SESSION['admin_2fa_ok'])) {
header('Location: ' . secTokenUrl('admin.php'));
exit;
}
// ===== POST:校验动态码 =====
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$sid = session_id();
$lock = secThrottleLocked('twofa_fail', $sid, 5, 900);
if ($lock > 0) {
$err = '验证失败次数过多,已锁定,请 ' . $lock . ' 秒后再试';
secLog('2fa_throttled', 'lock=' . $lock . 's');
} else {
$code = isset($_POST['code']) ? (string)$_POST['code'] : '';
if (secTotpVerify(SEC_2FA_SECRET, $code)) {
$_SESSION['admin_2fa_ok'] = time();
$_SESSION['admin_2fa_ip'] = secClientIp();
secThrottleReset('twofa_fail', $sid);
secLog('2fa_pass', 'session granted');
header('Location: ' . secTokenUrl('admin.php'));
exit;
}
$left = secThrottleFail('twofa_fail', $sid, 5, 900);
// ★ 诊断:把"密钥录错/绑了旧密钥"和"设备时钟漂移"两种失败原因区分开,
// 否则用户只能看到一句"无效",无从下手。
$off = secTotpDiagnose(SEC_2FA_SECRET, $code);
secLog('2fa_fail', $left > 0 ? 'LOCKED_' . $left . 's'
: ($off === null ? 'wrong_secret' : 'clock_skew ' . $off . 's'));
$err = $left > 0
? '验证失败次数过多,已锁定,请 ' . $left . ' 秒后再试'
: secTotpDiagnoseMsg($off);
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SDF1 - 二次验证</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0d1117; color: #e6edf3;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex; justify-content: center; align-items: center; min-height: 100vh;
}
.box {
background: #161b22; border: 1px solid #30363d; border-radius: 12px;
padding: 40px; width: 400px; max-width: 92%;
}
.box h1 { text-align: center; color: #58a6ff; margin-bottom: 8px; font-size: 22px; }
.box .sub { text-align: center; color: #8b949e; font-size: 13px; margin-bottom: 24px; line-height: 1.7; }
.code-input {
width: 100%; padding: 14px; background: #0d1117; border: 1px solid #30363d;
border-radius: 8px; color: #e6edf3; font-size: 30px; letter-spacing: 10px;
text-align: center; outline: none; font-family: monospace;
}
.code-input:focus { border-color: #58a6ff; }
.btn {
width: 100%; padding: 12px; background: #58a6ff; color: #fff; border: none;
border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; margin-top: 16px;
}
.btn:hover { background: #79c0ff; }
.timer-bar { height: 4px; background: #30363d; border-radius: 2px; margin-top: 14px; overflow: hidden; }
.timer-bar .fill { height: 100%; background: #3fb950; width: 100%; transition: width .3s linear; }
.timer-text { font-size: 11px; color: #8b949e; text-align: center; margin-top: 6px; }
.error {
color: #f85149; font-size: 13px; text-align: center; margin-top: 14px;
background: rgba(248,81,73,0.1); border: 1px solid rgba(248,81,73,0.3);
border-radius: 6px; padding: 8px; display: block;
}
.links { text-align: center; margin-top: 18px; }
.links a { color: #58a6ff; text-decoration: none; font-size: 13px; }
.ip { text-align: center; font-size: 11px; color: #8b949e; margin-top: 16px; }
.ip b { color: #d29922; font-family: monospace; }
</style>
</head>
<body>
<div class="box">
<h1>🛡️ 二次验证</h1>
<div class="sub">请输入认证器 App 中的 <b style="color:#e6edf3">6 位动态码</b><br>
(1Password / Microsoft Authenticator / Google Authenticator 均可)</div>
<form method="post" autocomplete="off">
<input class="code-input" id="code" name="code" inputmode="numeric" pattern="[0-9]*"
maxlength="6" placeholder="000000" autofocus>
<button class="btn" type="submit" id="submitBtn">验 证</button>
<div class="timer-bar"><div class="fill" id="tfill"></div></div>
<div class="timer-text" id="ttext">动态码每 30 秒刷新</div>
<div class="timer-text" id="srvtime" style="color:#58a6ff"></div>
<?php if ($err !== ''): ?>
<div class="error" style="white-space:pre-wrap;text-align:left"><?php echo htmlspecialchars($err, ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
</form>
<div class="links">
<a href="admin_login.php">← 返回登录</a> &nbsp;|&nbsp;
<a href="admin_2fa_setup.php">安全配置</a>
</div>
<div class="ip">当前访问 IP:<b><?php echo htmlspecialchars(secClientIp(), ENT_QUOTES, 'UTF-8'); ?></b></div>
<div class="ip" style="margin-top:4px">提示:认证器与上方<b>服务器时间</b>偏差超过 90 秒就会被拒</div>
</div>
<script>
(function() {
var input = document.getElementById('code');
input.addEventListener('input', function() {
this.value = this.value.replace(/\D/g, '').slice(0, 6);
});
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && this.value.length === 6) {
document.querySelector('form').submit();
}
});
// ★ 进度条/倒计时改用【服务器时钟】:
// 手机时间不准时,用浏览器自身时钟画进度条会误导用户(看着还剩 20 秒,
// 服务器那边其实已经翻到下一窗格了),所以以 PHP 渲染时刻为基准推进。
var srvBase = <?php echo time(); ?>; // 页面渲染时的服务器 Unix 时间
var srvOff = <?php echo (new DateTime('now'))->getOffset(); ?>; // 服务器时区偏移(秒)
var t0 = Date.now();
function srvNow() { return srvBase + Math.floor((Date.now() - t0) / 1000); }
function pad(n) { return (n < 10 ? '0' : '') + n; }
var fill = document.getElementById('tfill');
var text = document.getElementById('ttext');
var srvBox = document.getElementById('srvtime');
function tick() {
var s = srvNow();
var left = 30 - (s % 30);
fill.style.width = (left / 30 * 100) + '%';
var d = new Date((s + srvOff) * 1000);
text.textContent = left + ' 秒后动态码刷新';
srvBox.textContent = '服务器时间 ' + pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes()) + ':' + pad(d.getUTCSeconds())
+ '(与你手机差多少秒请自行比对)';
}
tick();
setInterval(tick, 300);
})();
</script>
</body>
</html>