三大场景接入人机验证:新增captcha_guard.php统一校验库与CAPTCHA_PROVIDER配置,管理员登录/用户后台账密登录/正版验证浏览器提交三处接入;captcha_keys.php清空为可入库的空模板并加填写说明,部署脚本排除密钥文件防覆盖线上真值

This commit is contained in:
ypsdf1
2026-10-01 13:38:29 +08:00
parent 4f498a80c6
commit 17d53c40a0
9 changed files with 579 additions and 5 deletions
+2 -1
View File
@@ -25,7 +25,8 @@ db/debug_ip_v2.php
api/test_db.php
api/diag_db_content.php
robots.json
captcha_keys.php
# captcha_keys.php 已于 2026-10-02 移出忽略名单:仓库里放的是「空模板」(密钥全部清空 + 填写说明),
# 真实密钥只存在服务器上,且 deploy_php_only.py 的 EXCLUDE_PATTERNS 永不上传它,避免覆盖线上真值。
# 开发辅助文档(本地保留,不推送到仓库)
/代码关联关系.md
+16 -1
View File
@@ -51,6 +51,7 @@ secGatePage();
<label>管理密码</label>
<input type="password" id="password" placeholder="请输入管理密码" autofocus>
</div>
<?php echo cgWidgetHtml(); ?>
<button class="login-btn" id="loginBtn" onclick="doLogin()">登 录</button>
<div class="error" id="errorMsg"></div>
<div class="links"><a href="player.php">← 返回玩家商城</a></div>
@@ -67,11 +68,25 @@ secGatePage();
document.getElementById('loginBtn').disabled = true;
document.getElementById('loginBtn').textContent = '登录中...';
// ★ 人机验证(2026-10-02):CF 渲染完即有 token(基本无感);VA 没做会在这里拉起验证
let cap = {};
if (window.CaptchaGuard) {
try {
cap = await CaptchaGuard.payload();
} catch (e) {
document.getElementById('errorMsg').textContent = e.message || '请先完成人机验证';
document.getElementById('errorMsg').style.display = 'block';
document.getElementById('loginBtn').disabled = false;
document.getElementById('loginBtn').textContent = '登 录';
return;
}
}
try {
const res = await fetch('api/admin.php?action=login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({password: pw})
body: JSON.stringify(Object.assign({password: pw}, cap))
});
const data = await res.json();
if (data.success) {
+2
View File
@@ -104,6 +104,8 @@ function adminDoLogin() {
}
$password = getParam('password');
if (!$password) exit(json_encode(['success' => false, 'message' => 'Missing password'], JSON_UNESCAPED_UNICODE));
// ★ 人机验证码(2026-10-02):放在限速之后、比对密码之前(不通过就不消耗密码尝试次数)
cgEnforce('admin_login');
if (adminLogin($password)) {
if (function_exists('secThrottleReset')) secThrottleReset('login_fail', $ip);
if (function_exists('secLog')) secLog('login_pass', 'password_ok');
+24 -2
View File
@@ -29,6 +29,8 @@ require_once __DIR__ . '/../security.php';
// Java 插件的 secret 调用与任何异常一律放行(fail-open)。
// ====================================================================
require_once __DIR__ . '/../config.php'; // 取 SECRET_KEY(require_once,不会重复定义)
// ★ 人机验证码(2026-10-02):CF/VA 统一接入,见 captcha_guard.php
require_once __DIR__ . '/../captcha_guard.php';
function mcAuthIpBlacklisted() {
if (PHP_SAPI === 'cli') return false;
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
@@ -758,6 +760,12 @@ switch ($action) {
}
}
// ★ 人机验证码(2026-10-02):只拦浏览器粘贴页的提交;
// Java 插件带 secret 的调用走的是服务端,不需要人机验证。
if (empty($secret)) {
cgEnforce('verify_code');
}
// 查询会话
$stmt = $db->prepare("SELECT * FROM mc_auth_sessions WHERE session_id = ?");
$stmt->execute([$sessionId]);
@@ -864,6 +872,7 @@ switch ($action) {
$playerName = htmlspecialchars($session['player_name']);
$authUrl = htmlspecialchars(getAuthUrl($sessionId));
$captchaHtml = cgWidgetHtml();
echo <<<HTML
<!DOCTYPE html>
<html lang="zh-CN">
@@ -908,6 +917,7 @@ button:disabled{opacity:0.5;cursor:not-allowed;transform:none}
<div class="input-group">
<input type="text" id="codeInput" placeholder="粘贴完整URL或授权码" autofocus autocomplete="off">
</div>
{$captchaHtml}
<button id="submitBtn" onclick="submitCode()">提交验证</button>
<div class="msg" id="msg"></div>
</div>
@@ -946,11 +956,18 @@ else{showMsg('❌ 服务器响应异常(HTTP '+httpStatus+'): '+rawPrev,'err');e
};
x2.send();
}
function doSubmit(code){
async function doSubmit(code){
if(submitting)return;
submitting=true;
var btn=document.getElementById('submitBtn');
btn.disabled=true;btn.textContent='验证中...';
// ★ 人机验证(2026-10-02):CF 渲染完即有 token(基本无感);VA 没做会在这里拉起验证
var cap={};
if(window.CaptchaGuard){
try{cap=await CaptchaGuard.payload();}
catch(e){submitting=false;btn.disabled=false;btn.textContent='提交验证';
showMsg('❌ '+(e.message||'请先完成人机验证'),'err');return;}
}
var xhr=new XMLHttpRequest();
xhr.open('POST','minecraft_auth.php?action=verify_code',true);
xhr.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
@@ -964,7 +981,12 @@ if(d.success){showMsg('✅ 验证成功!请关闭此页面返回游戏','ok');
else{showMsg('❌ '+(d.error||d.message||'验证失败'),'err');enableBtn();}
}catch(e){showRealStatus(xhr.status,body);}
}};
xhr.send('session_id='+encodeURIComponent(sessionId)+'&code='+encodeURIComponent(code));
xhr.send('session_id='+encodeURIComponent(sessionId)+'&code='+encodeURIComponent(code)
+'&captcha_provider='+encodeURIComponent(cap.captcha_provider||'')
+'&captcha_token='+encodeURIComponent(cap.captcha_token||'')
+'&captcha_knock='+encodeURIComponent(cap.captcha_knock||'')
+'&captcha_dfu='+encodeURIComponent(cap.captcha_dfu||'')
+'&captcha_ip='+encodeURIComponent(cap.captcha_ip||''));
}
function submitCode(){
var raw=document.getElementById('codeInput').value.trim();
+7
View File
@@ -50,6 +50,9 @@ set_exception_handler(function($e) {
// debugLog() 函数已在 core.php 中定义,无需重复声明
// ★ 人机验证码(2026-10-02):CF/VA 统一接入,见 captcha_guard.php
require_once __DIR__ . '/../captcha_guard.php';
// ===== SMTP邮件发送函数(已统一移至 core.php,通过 require_once core.php 复用,避免重复定义) =====
@@ -2383,6 +2386,10 @@ function webLoginRequest() {
error('玩家名格式不正确(1-16位字母数字下划线)');
}
// ★ 人机验证码(2026-10-02):账密登录提交前校验;
// token 登录 / 邮箱验证码登录不经过这里,不受影响。
cgEnforce('web_login_request');
$db = getDB();
// ★ 封禁拦截:被封禁玩家禁止走密码登录
+461
View File
@@ -0,0 +1,461 @@
<?php
/**
* 人机验证码统一接入(CF Turnstile + VAPTCHA V4)
*
* 覆盖三大场景:
* 1. 管理员登录 admin_login.php → api/admin.php adminDoLogin()
* 2. 用户后台账密登录 login.php(密码页签) → api/sync.php webLoginRequest()
* 3. 正版验证提交前 api/minecraft_auth.php paste_code_page → verify_code
*
* 用哪一家由 config.php 的 CAPTCHA_PROVIDER 决定(未定义时默认 CF):
* 'CF' / 'cloudflare' / 'turnstile'(不区分大小写) → Cloudflare Turnstile
* 'VA' / 'vaptcha' (不区分大小写) → VAPTCHA V4
* '' / 'off' / 'none' → 关闭人机验证
*
* 密钥来源(与 captcha_test.php 同一套优先级,URL 参数除外):
* captcha_keys.php > config.php 常量 > robots.json
*
* 安全策略:
* - 密钥完全没配 → 放行(本地开发/未启用环境不受影响),记日志
* - 密钥已配但没提交 token → 拒绝
* - 校验请求网络不通(传输层失败)→ 放行 + 告警日志(避免第三方故障把所有人锁在门外)
* - 校验接口正常返回但判定不通过 → 拒绝
*
* 本文件自包含,不依赖 core.php(api/minecraft_auth.php 不引 core.php)。
*/
// ====================================================================
// 配置解析
// ====================================================================
require_once __DIR__ . '/config.php'; // 取 CAPTCHA_PROVIDER(require_once,不会重复定义)
/** 取当前启用的验证码厂商:'cf' | 'va' | ''(关闭) */
function cgProvider() {
$raw = defined('CAPTCHA_PROVIDER') ? (string) CAPTCHA_PROVIDER : 'CF';
$v = strtolower(trim($raw));
if ($v === '' || $v === 'off' || $v === 'none' || $v === 'false' || $v === '0') return '';
if (in_array($v, ['va', 'v', 'vaptcha', 'vaptcha-v4', 'vaptcha_v4'], true)) return 'va';
// cf / cloudflare / turnstile / 未识别值 → 一律按 CF 处理(默认厂商)
return 'cf';
}
/** 读取密钥(四级回退,静态缓存)。 */
function cgKeys() {
static $k = null;
if ($k !== null) return $k;
$k = ['source' => '未配置', 'cf_sitekey' => '', 'cf_secret' => '',
'v_vid' => '', 'v_key' => '', 'v_scene' => 0];
// 1) captcha_keys.php(独立密钥文件,真实值只在服务器上,仓库里是空模板)
$f = __DIR__ . '/captcha_keys.php';
if (is_file($f)) {
$CF_SITEKEY = ''; $CF_SECRET = ''; $VAPTCHA_VID = ''; $VAPTCHA_VKEY = ''; $VAPTCHA_SCENE = 0;
@include $f;
if ($CF_SITEKEY !== '' || $VAPTCHA_VID !== '') {
$k = ['source' => 'captcha_keys.php', 'cf_sitekey' => (string) $CF_SITEKEY,
'cf_secret' => (string) $CF_SECRET, 'v_vid' => (string) $VAPTCHA_VID,
'v_key' => (string) $VAPTCHA_VKEY, 'v_scene' => (int) $VAPTCHA_SCENE];
return $k;
}
}
// 2) config.php 常量
if (defined('CF_TURNSTILE_SITEKEY') || defined('VAPTCHA_VID')) {
$k = ['source' => 'config.php 常量',
'cf_sitekey' => defined('CF_TURNSTILE_SITEKEY') ? CF_TURNSTILE_SITEKEY : '',
'cf_secret' => defined('CF_TURNSTILE_SECRET') ? CF_TURNSTILE_SECRET : '',
'v_vid' => defined('VAPTCHA_VID') ? VAPTCHA_VID : '',
'v_key' => defined('VAPTCHA_VKEY') ? VAPTCHA_VKEY : '',
'v_scene' => defined('VAPTCHA_SCENE') ? VAPTCHA_SCENE : 0];
if ($k['cf_sitekey'] !== '' || $k['v_vid'] !== '') return $k;
}
// 3) robots.json 兜底(严禁入库的密钥清单)
$rj = __DIR__ . '/robots.json';
if (is_file($rj)) {
$arr = json_decode((string) @file_get_contents($rj), true);
if (is_array($arr)) {
foreach ($arr as $item) {
if (!is_array($item) || !isset($item['名字'])) continue;
if ($item['名字'] === 'CF') {
$k['cf_sitekey'] = (string) ($item['站点密钥'] ?? '');
$k['cf_secret'] = (string) ($item['密钥'] ?? '');
} elseif ($item['名字'] === 'vaptcha') {
$k['v_vid'] = (string) ($item['VID'] ?? '');
$k['v_key'] = (string) ($item['VKey'] ?? '');
$k['v_scene'] = 0;
}
}
if ($k['cf_sitekey'] !== '' || $k['v_vid'] !== '') $k['source'] = 'robots.json';
}
}
return $k;
}
/** 当前厂商的密钥是否齐全。 */
function cgConfigured($p = null) {
$p = ($p === null) ? cgProvider() : $p;
if ($p === '') return false;
$k = cgKeys();
return ($p === 'cf') ? ($k['cf_sitekey'] !== '' && $k['cf_secret'] !== '')
: ($k['v_vid'] !== '' && $k['v_key'] !== '');
}
/** 是否需要做整套人机验证(厂商开启 + 密钥齐全)。 */
function cgActive() {
return cgConfigured(cgProvider());
}
// ====================================================================
// 日志
// ====================================================================
function cgLog($msg) {
$line = '[' . date('Y-m-d H:i:s') . '] [captcha] ' . $msg . "\n";
@error_log('[captcha] ' . $msg);
if (function_exists('debugLog')) {
debugLog('captcha', ['msg' => $msg]);
return;
}
$dir = __DIR__ . '/db';
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
@file_put_contents($dir . '/debug.log', $line, FILE_APPEND | LOCK_EX);
}
// ====================================================================
// HTTP(PHP 8.5 起 curl_close 是 Deprecated,会污染 JSON —— 一律不调用)
// ====================================================================
function cgHttpPostForm($url, array $fields) {
$r = ['errno' => -1, 'status' => 0, 'body' => '', 'err' => ''];
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($fields),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$body = curl_exec($ch);
$r['status'] = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$r['err'] = curl_error($ch);
if ($body !== false) { $r['body'] = (string) $body; $r['errno'] = 0; }
return $r;
}
$ctx = stream_context_create(['http' => [
'method' => 'POST', 'timeout' => 10,
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => http_build_query($fields)]]);
$body = @file_get_contents($url, false, $ctx);
if ($body !== false) { $r['body'] = (string) $body; $r['errno'] = 0; $r['err'] = 'file_get_contents'; }
else $r['err'] = '无 curl 且请求失败';
return $r;
}
function cgHttpPostJson($url, array $payload) {
$r = ['errno' => -1, 'status' => 0, 'body' => '', 'err' => ''];
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$body = curl_exec($ch);
$r['status'] = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$r['err'] = curl_error($ch);
if ($body !== false) { $r['body'] = (string) $body; $r['errno'] = 0; }
return $r;
}
$ctx = stream_context_create(['http' => [
'method' => 'POST', 'timeout' => 10,
'header' => "Content-Type: application/json\r\n",
'content' => $json]]);
$body = @file_get_contents($url, false, $ctx);
if ($body !== false) { $r['body'] = (string) $body; $r['errno'] = 0; $r['err'] = 'file_get_contents'; }
else $r['err'] = '无 curl 且请求失败';
return $r;
}
// ====================================================================
// 取客户端 IP
// ====================================================================
function cgClientIp() {
if (function_exists('secClientIp')) {
$ip = secClientIp();
if (is_string($ip) && $ip !== '') return $ip;
}
return (string) ($_SERVER['REMOTE_ADDR'] ?? '');
}
// ====================================================================
// 从请求里收集验证码字段(POST 表单 / GET / JSON body 三路通吃)
// ====================================================================
function cgInput() {
$keys = ['captcha_provider', 'captcha_token', 'captcha_knock', 'captcha_dfu', 'captcha_ip'];
$raw = null; // JSON body 缓存
$rawRead = false;
$out = [];
foreach ($keys as $k) {
$v = '';
if (isset($_POST[$k])) $v = $_POST[$k];
elseif (isset($_GET[$k])) $v = $_GET[$k];
elseif (function_exists('getParam')) {
// core.php 的 getParam 会解析 JSON body;minecraft_auth.php 的只读 GET(上面已兜过)
$v = getParam($k, '');
}
$out[$k] = is_string($v) ? trim($v) : '';
}
return $out;
}
// ====================================================================
// 服务端校验
// 返回 ['ok'=>bool, 'msg'=>string, 'skipped'=>bool]
// ====================================================================
function cgVerify(array $in) {
$p = cgProvider();
if ($p === '') return ['ok' => true, 'skipped' => true, 'msg' => '验证码已关闭'];
$k = cgKeys();
if ($p === 'cf' && ($k['cf_sitekey'] === '' || $k['cf_secret'] === '')) {
cgLog('skip: CF 密钥未配置 (source=' . $k['source'] . ')');
return ['ok' => true, 'skipped' => true, 'msg' => 'CF 密钥未配置,已跳过'];
}
if ($p === 'va' && ($k['v_vid'] === '' || $k['v_key'] === '')) {
cgLog('skip: VAPTCHA 密钥未配置 (source=' . $k['source'] . ')');
return ['ok' => true, 'skipped' => true, 'msg' => 'VAPTCHA 密钥未配置,已跳过'];
}
// 前端渲染的厂商必须和服务端配置一致(不一致=篡改或配置漂移)
$claimed = strtolower($in['captcha_provider'] ?? '');
if ($claimed !== '' && $claimed !== $p) {
cgLog('reject: provider mismatch claimed=' . $claimed . ' configured=' . $p);
return ['ok' => false, 'skipped' => false, 'msg' => '人机验证配置不匹配,请刷新页面重试'];
}
$token = (string) ($in['captcha_token'] ?? '');
if ($token === '' || strlen($token) > 4096) {
cgLog('reject: 空 token (provider=' . $p . ')');
return ['ok' => false, 'skipped' => false, 'msg' => '请先完成人机验证'];
}
if ($p === 'cf') return cgVerifyCf($k, $token);
return cgVerifyVa($k, $in, $token);
}
function cgVerifyCf(array $k, $token) {
$endpoint = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
$body = ['secret' => $k['cf_secret'], 'response' => $token];
$ip = cgClientIp();
if ($ip !== '') $body['remoteip'] = $ip;
$r = cgHttpPostForm($endpoint, $body);
if ($r['errno'] !== 0 || $r['status'] === 0) {
cgLog('fail-open: CF siteverify 传输失败 status=' . $r['status'] . ' err=' . $r['err']);
return ['ok' => true, 'skipped' => true, 'msg' => '验证码服务不可用,已放行'];
}
$j = json_decode($r['body'], true);
if (is_array($j) && !empty($j['success'])) {
cgLog('pass: CF (hostname=' . (isset($j['hostname']) ? $j['hostname'] : '-') . ')');
return ['ok' => true, 'skipped' => false, 'msg' => 'ok'];
}
$errc = is_array($j) && isset($j['error-codes']) ? implode(',', (array) $j['error-codes']) : 'no-json';
cgLog('reject: CF http=' . $r['status'] . ' codes=' . $errc . ' body=' . substr($r['body'], 0, 200));
return ['ok' => false, 'skipped' => false, 'msg' => '人机验证未通过,请重试'];
}
function cgVerifyVa(array $k, array $in, $token) {
$knock = substr((string) ($in['captcha_knock'] ?? ''), 0, 256);
$dfu = substr((string) ($in['captcha_dfu'] ?? ''), 0, 512);
$sip = substr((string) ($in['captcha_ip'] ?? ''), 0, 64);
if ($sip === '') $sip = cgClientIp();
$endpoint = 'https://v41.vaptcha.com/api/verify';
$r = cgHttpPostJson($endpoint, [
'vid' => $k['v_vid'],
'vkey' => $k['v_key'],
'token' => $token,
'knock' => $knock,
'dfu' => $dfu,
'ip' => $sip,
]);
if ($r['errno'] !== 0 || $r['status'] === 0) {
cgLog('fail-open: VAPTCHA verify 传输失败 status=' . $r['status'] . ' err=' . $r['err']);
return ['ok' => true, 'skipped' => true, 'msg' => '验证码服务不可用,已放行'];
}
$j = json_decode($r['body'], true);
$code = is_array($j) && isset($j['code']) ? (int) $j['code'] : -1;
$res = is_array($j) && isset($j['data']['result']) ? (bool) $j['data']['result'] : false;
if ($code === 0 && $res) {
cgLog('pass: VAPTCHA V4');
return ['ok' => true, 'skipped' => false, 'msg' => 'ok'];
}
cgLog('reject: VAPTCHA http=' . $r['status'] . ' code=' . $code . ' result=' . var_export($res, true)
. ' body=' . substr($r['body'], 0, 200));
return ['ok' => false, 'skipped' => false, 'msg' => '人机验证未通过,请重试'];
}
/**
* 强制校验:不通过就按当前环境的 error() 约定输出 JSON 并终止。
* 三大 API 入口各调一次(Java 带 secret 的调用不经过这里)。
*/
function cgEnforce($context = '') {
$r = cgVerify(cgInput());
if ($r['ok']) return;
cgLog('enforce-block' . ($context !== '' ? ' ctx=' . $context : '') . ' msg=' . $r['msg']);
if (function_exists('error')) error($r['msg'], 403);
http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
$payload = ['success' => false, 'message' => $r['msg'], 'error' => $r['msg']];
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
}
// ====================================================================
// 前端挂件(HTML + 脚本),未启用时返回空串
// ====================================================================
function cgWidgetHtml() {
if (!cgActive()) return '';
$p = cgProvider();
$k = cgKeys();
$sitekey = $p === 'cf' ? $k['cf_sitekey'] : '';
$vid = $p === 'va' ? $k['v_vid'] : '';
$tpl = <<<'CGTPL'
<style>
.cg-box{margin:0 0 16px;padding:14px 14px 12px;background:#0d1117;border:1px solid #30363d;
border-radius:8px;text-align:left}
.cg-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px}
.cg-title{font-size:13px;color:#8b949e}
.cg-badge{font-size:12px;padding:2px 9px;border-radius:999px;background:#21262d;color:#8b949e}
.cg-badge.ok{background:#1a7f37;color:#fff}
.cg-badge.bad{background:#da3633;color:#fff}
.cg-badge.warn{background:#9e6a03;color:#fff}
.cg-err{display:none;margin-top:8px;font-size:12.5px;color:#f85149}
.cg-btn{width:100%;padding:11px 14px;font-size:14px;font-weight:600;border:none;border-radius:6px;
background:#21262d;color:#e6edf3;cursor:not-allowed}
.cg-btn:enabled{background:#238636;color:#fff;cursor:pointer}
.cg-btn:enabled:hover{background:#2ea043}
</style>
<div class="cg-box" id="cgBox" data-provider="__PROVIDER__">
<div class="cg-head"><span class="cg-title">🛡 人机验证</span><span class="cg-badge" id="cgBadge">加载中…</span></div>
<div id="cgCfBox" style="display:none"></div>
<div id="cgVaBox" style="display:none"></div>
<button type="button" class="cg-btn" id="cgVaBtn" style="display:none" disabled>点击完成人机验证</button>
<div class="cg-err" id="cgErr"></div>
<input type="hidden" name="captcha_provider" id="captcha_provider" value="__PROVIDER__">
<input type="hidden" name="captcha_token" id="captcha_token" value="">
<input type="hidden" name="captcha_knock" id="captcha_knock" value="">
<input type="hidden" name="captcha_dfu" id="captcha_dfu" value="">
<input type="hidden" name="captcha_ip" id="captcha_ip" value="">
</div>
<script>
(function(){
var P = '__PROVIDER__';
var st = {tok:'',knock:'',dfu:'',ip:'',err:'',vaObj:null,vaBusy:false,vaDone:false};
function el(id){return document.getElementById(id);}
function put(){el('captcha_token').value=st.tok;el('captcha_knock').value=st.knock;
el('captcha_dfu').value=st.dfu;el('captcha_ip').value=st.ip;}
function badge(t,c){var b=el('cgBadge');if(!b)return;b.textContent=t;b.className='cg-badge '+(c||'');}
function fail(m){st.err=m;badge('验证未通过','bad');var e=el('cgErr');
if(e){e.textContent=m;e.style.display='block';}}
function collect(){return {captcha_provider:P,captcha_token:st.tok,captcha_knock:st.knock,
captcha_dfu:st.dfu,captcha_ip:st.ip};}
function loadScript(src,ok,failFn){var s=document.createElement('script');s.src=src;s.async=true;
s.onload=ok;s.onerror=failFn;document.head.appendChild(s);}
function initCf(){
el('cgCfBox').style.display='block';
var tries=0;
(function wait(){
if (typeof turnstile === 'undefined'){
if (++tries > 96){ fail('验证组件加载失败(网络或域名未加入白名单),请刷新重试'); return; }
return setTimeout(wait,250);
}
badge('请完成验证','');
try{
turnstile.render('#cgCfBox', {
sitekey: '__SITEKEY__',
theme: 'auto',
callback: function(t){ st.tok=t; st.err=''; put(); badge('已通过','ok');
var e=el('cgErr'); if(e) e.style.display='none'; },
'error-callback': function(m){ fail('验证错误: '+m); },
'expired-callback': function(){ st.tok=''; put(); badge('已过期,请重新勾选','warn'); }
});
}catch(ex){ fail('render 异常: '+ex.message); }
})();
}
function initVa(){
el('cgVaBox').style.display='block';
var btn=el('cgVaBtn'); btn.style.display='block';
loadScript('https://c4.vaptcha.com/src/v4.js', function(){
if (typeof vaptcha !== 'function'){ fail('VAPTCHA SDK 异常'); return; }
vaptcha({vid:'__VID__', container:'#cgVaBox', lang:'zh-CN'}).then(function(obj){
st.vaObj=obj; badge('准备就绪','');
btn.disabled=false;
btn.addEventListener('click', function(){ validateVa(function(){}); });
}).catch(function(e){ fail('VAPTCHA 初始化失败: '+(e&&e.message?e.message:e)); });
}, function(){ fail('VAPTCHA SDK 加载失败(c4.vaptcha.com 不可达)'); });
}
function validateVa(done){
if (st.tok){ done(true); return; }
if (!st.vaObj){ fail(st.err || '人机验证组件未就绪,请稍候重试'); done(false); return; }
if (st.vaBusy) return;
st.vaBusy=true;
badge('验证中…','');
Promise.resolve(st.vaObj.validate()).then(function(r){
st.vaBusy=false;
if (!r || !r.token){ fail('未完成人机验证'); done(false); return; }
st.tok=r.token; st.knock=r.knock||''; st.dfu=r.dfu||''; st.ip=r.ip||'';
st.err=''; st.vaDone=true; put(); badge('已通过','ok');
var e=el('cgErr'); if(e) e.style.display='none';
var b=el('cgVaBtn'); if(b){ b.textContent='人机验证已完成'; b.disabled=true; }
done(true);
}).catch(function(e){
st.vaBusy=false;
fail('人机验证失败: '+(e&&e.message?e.message:e));
done(false);
});
}
window.CaptchaGuard = {
provider: P,
payload: function(){
return new Promise(function(resolve,reject){
if (st.tok) { resolve(collect()); return; }
if (P === 'cf'){
if (st.err){ reject(new Error(st.err)); return; }
var t0=Date.now();
var iv=setInterval(function(){
if (st.tok){ clearInterval(iv); resolve(collect()); }
else if (st.err){ clearInterval(iv); reject(new Error(st.err)); }
else if (Date.now()-t0 > 60000){ clearInterval(iv);
reject(new Error('人机验证超时,请刷新页面重试')); }
},200);
return;
}
if (st.err){ reject(new Error(st.err)); return; }
validateVa(function(ok){
if (ok) resolve(collect());
else reject(new Error(st.err || '请先完成人机验证'));
});
});
}
};
if (P === 'cf') initCf(); else initVa();
})();
</script>
CGTPL;
$html = str_replace(['__PROVIDER__', '__SITEKEY__', '__VID__'],
[$p, $sitekey, $vid], $tpl);
return $html;
}
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* 人机验证码密钥(模板版 —— 里面没有任何真实密钥,可安全入库/上传)
*
* ⚠️ 本文件已在 .gitignore 中排除,线上真实密钥请直接在服务器上编辑本文件填写。
* 填好后不要再把真实值提交进 git。
*
* ── 怎么填 ──────────────────────────────────────────────
*
* 1) Cloudflare Turnstile(默认使用的验证码,体验最好、无感)
* 申请入口:Cloudflare 控制台 → Workers & Pages / Turnstile → Add site
* https://dash.cloudflare.com/?to=/:account/turnstile
* 绑定域名:*.ypshidifu.cn (添加 hostname 白名单)
* 拿到两个值:
* $CF_SITEKEY → 页面上显示的 Sitekey(0x4AAAA 开头)
* $CF_SECRET → 后台的 Secret key(0x4AAAA 开头,不要和 sitekey 搞混)
*
* 2) VAPTCHA(备用验证码,配置 CAPTCHA_PROVIDER = 'VA' 时启用)
* 申请入口:VAPTCHA 官网控制台 → 创建应用 → 拿 VID / VKey
* https://www.vaptcha.com/
* 注意:V3 的 v-cn/cdn/api.vaptcha.com 已全部失效,本项目走 V4
* SDK:c4.vaptcha.com/src/v4.js
* 校验:v41.vaptcha.com/api/verify
* 拿到两个值:
* $VAPTCHA_VID → 形如 id_xxxxxxxxxxxxxxxx
* $VAPTCHA_VKEY → 形如 key_xxxxxxxxxxxxxxxx
* $VAPTCHA_SCENE → 场景号,不确定就填 0
*
* 3) 用哪个验证码由 config.php 里的 CAPTCHA_PROVIDER 决定:
* define('CAPTCHA_PROVIDER', 'CF'); // 默认 CF(Cloudflare)
* define('CAPTCHA_PROVIDER', 'VA'); // 或 VAPTCHA
* 兼容写法:cloudflare / turnstile / cf(不区分大小写)都算 CF;
* vaptcha / va(不区分大小写)都算 VA。
*
* 4) 填完本文件后,可用 captcha_test.php 自测连通性。
* ───────────────────────────────────────────────────────
*/
// ===== Cloudflare Turnstile =====
$CF_SITEKEY = ''; // 例:'0x4AAAA...站点密钥'
$CF_SECRET = ''; // 例:'0x4AAAA...密钥'
// ===== VAPTCHA (V4) =====
$VAPTCHA_VID = ''; // 例:'id_xxxxxxxxxxxxxxxx'
$VAPTCHA_VKEY = ''; // 例:'key_xxxxxxxxxxxxxxxx'
$VAPTCHA_SCENE = 0; // 场景号,默认 0
+10
View File
@@ -58,6 +58,16 @@ define('SMTP_PASS', 'REPLACE_ME_SMTP_PASSWORD');
define('SMTP_SENDER_NAME', 'Sdf1_login');
define('SMTP_USE_SSL', true);
// ===== 人机验证码选择(2026-10-02,实现见 captcha_guard.php)=====
// 覆盖三个场景:管理员登录 / 用户后台账密登录 / 正版验证提交前。
// 取值不区分大小写,兼容全名:
// 'CF' 或 'cloudflare' 或 'turnstile' → Cloudflare Turnstile(默认,体验最好基本无感)
// 'VA' 或 'vaptcha' → VAPTCHA V4
// '' 或 'off' 或 'none' → 关闭人机验证
// 本文件未定义本常量时(线上 config.php 是老版本),一律按默认 'CF' 处理。
// 密钥本身不在这里,写在 captcha_keys.php(仓库里是空模板,真实值只放服务器)。
define('CAPTCHA_PROVIDER', 'CF');
// ===== 紧急安全加固(2026-09-29 入侵事件响应,实现见 security.php)=====
// 第一层:TOTP 二次验证 —— 1Password / Microsoft Authenticator 等标准 6 位动态码(仅校验,无推送)。
// 第二层:管理面 IP 白名单 —— 未配置白名单时,仅服务器本机 127.0.0.1 与持有 boot 引导令牌的
+11 -1
View File
@@ -1,5 +1,7 @@
<?php
require_once __DIR__ . '/security.php'; // 第四层:全局限流(2026-09-30)
// ★ 人机验证码(2026-10-02):按 config.php 的 CAPTCHA_PROVIDER 选择 CF/VA,密钥在 captcha_keys.php
require_once __DIR__ . '/captcha_guard.php';
?>
<!DOCTYPE html>
<html lang="zh-CN">
@@ -86,6 +88,7 @@ require_once __DIR__ . '/security.php'; // 第四层:全局限流(2026-09-
style="width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box">
<p style="color:var(--dim);font-size:12px;margin-top:8px;text-align:left">密码由游戏服务器验证,Web端不存储密码</p>
</div>
<?php echo cgWidgetHtml(); ?>
<button class="btn btn-primary" style="width:100%" onclick="doPasswordLogin()">登录到Web端</button>
</div>
<!-- 邮箱验证码登录 -->
@@ -198,6 +201,13 @@ require_once __DIR__ . '/security.php'; // 第四层:全局限流(2026-09-
if (!password) { showPwdError('请输入密码'); return; }
// ★ 人机验证(2026-10-02):CF 渲染完即有 token(基本无感);VA 没做会在这里拉起验证
let cap = {};
if (window.CaptchaGuard) {
try { cap = await CaptchaGuard.payload(); }
catch (e) { showPwdError(e.message || '请先完成人机验证'); return; }
}
pwdStatusBox.className = 'status-box loading';
pwdStatusBox.style.display = 'block';
pwdStatusBox.innerHTML = '<span class="spinner"></span> 正在验证密码...';
@@ -208,7 +218,7 @@ require_once __DIR__ . '/security.php'; // 第四层:全局限流(2026-09-
const reqRes = await fetch(apiUrl + '?action=web_login_request', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({player: player, password: password})
body: JSON.stringify(Object.assign({player: player, password: password}, cap))
});
const reqData = await reqRes.json();