Remove duplicate comment, captcha, filing, and website-security configuration paths. Move front submission verification to the captcha scene matrix and retire obsolete admin routes and components.
980 lines
33 KiB
PHP
980 lines
33 KiB
PHP
<?php
|
||
/**
|
||
* AKSRT 人机验证(Cloud Captcha)核心。
|
||
*
|
||
* 唯一配置源:option `aksrt_captcha_config`。
|
||
* 闭环契约:
|
||
* aksrt_get_captcha_config() -> aksrt_is_captcha_enabled_for(scene)
|
||
* -> aksrt_render_captcha(scene) -> 客户端 AKSRTCaptchaState + token
|
||
* -> 业务入口 aksrt_require_captcha(scene, $payload) -> proceed/fail。
|
||
*
|
||
* 仅支持云厂商:tencent / aliyun / cloudflare / geetest。
|
||
* 不含本地图形码 / 本地滑块。
|
||
*
|
||
* @package AKSRT
|
||
*/
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* 支持的验证服务商。
|
||
*/
|
||
function aksrt_captcha_providers(): array {
|
||
return ['tencent', 'aliyun', 'cloudflare', 'geetest'];
|
||
}
|
||
|
||
/**
|
||
* 人机验证场景白名单。
|
||
*/
|
||
function aksrt_captcha_scenes(): array {
|
||
return [
|
||
'comment' => __('发表评论', 'aksrt'),
|
||
'login' => __('用户登录', 'aksrt'),
|
||
'register' => __('用户注册', 'aksrt'),
|
||
'bind' => __('绑定第三方', 'aksrt'),
|
||
'bind_phone' => __('绑定手机', 'aksrt'),
|
||
'lostpassword' => __('找回密码', 'aksrt'),
|
||
'change_password' => __('修改密码', 'aksrt'),
|
||
'change_email' => __('更改邮箱', 'aksrt'),
|
||
'verify_email' => __('邮箱验证', 'aksrt'),
|
||
'download' => __('资源下载', 'aksrt'),
|
||
'signin' => __('每日签到', 'aksrt'),
|
||
'front_submit' => __('前台投稿', 'aksrt'),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 默认配置结构。
|
||
*/
|
||
function aksrt_captcha_default_config(): array {
|
||
$default_scenes = array_values(array_diff(array_keys(aksrt_captcha_scenes()), ['front_submit']));
|
||
return [
|
||
'enabled' => false,
|
||
'active_provider' => '',
|
||
// Positive list: checked scenes require captcha. Front submissions stay opt-in.
|
||
'enabled_scenarios' => $default_scenes,
|
||
'tencent' => [],
|
||
'aliyun' => [],
|
||
'cloudflare' => [],
|
||
'geetest' => [],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Sanitize a list of captcha scene keys against the whitelist.
|
||
*
|
||
* @param mixed $raw
|
||
* @return array<int, string>
|
||
*/
|
||
function aksrt_captcha_sanitize_scene_list($raw): array {
|
||
$list = is_array($raw) ? $raw : [];
|
||
$clean = [];
|
||
foreach ($list as $scene) {
|
||
$scene = sanitize_key((string) $scene);
|
||
if ($scene !== '' && array_key_exists($scene, aksrt_captcha_scenes()) && !in_array($scene, $clean, true)) {
|
||
$clean[] = $scene;
|
||
}
|
||
}
|
||
return array_values($clean);
|
||
}
|
||
|
||
/**
|
||
* 获取人机验证完整配置(规范化,不合并历史脏键)。
|
||
*
|
||
* @return array
|
||
*/
|
||
function aksrt_get_captcha_config(): array {
|
||
$stored = get_option('aksrt_captcha_config', []);
|
||
$cfg = array_merge(aksrt_captcha_default_config(), is_array($stored) ? $stored : []);
|
||
|
||
$provider = isset($cfg['active_provider']) ? sanitize_key((string) $cfg['active_provider']) : '';
|
||
if (!in_array($provider, aksrt_captcha_providers(), true)) {
|
||
$provider = '';
|
||
}
|
||
|
||
$enabled = aksrt_captcha_sanitize_scene_list($cfg['enabled_scenarios'] ?? []);
|
||
|
||
$result = [
|
||
'enabled' => !empty($cfg['enabled']),
|
||
'active_provider' => $provider,
|
||
'enabled_scenarios' => $enabled,
|
||
];
|
||
foreach (aksrt_captcha_providers() as $p) {
|
||
$result[$p] = isset($cfg[$p]) && is_array($cfg[$p]) ? $cfg[$p] : [];
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 各服务商必填字段。
|
||
*/
|
||
function aksrt_captcha_required_fields(string $provider): array {
|
||
$map = [
|
||
'tencent' => ['secret_id', 'secret_key', 'app_id', 'app_secret_key'],
|
||
'aliyun' => ['region', 'prefix', 'scene_id', 'access_key_id', 'access_key_secret'],
|
||
'cloudflare' => ['site_key', 'secret_key'],
|
||
'geetest' => ['captcha_id', 'captcha_key'],
|
||
];
|
||
return $map[$provider] ?? [];
|
||
}
|
||
|
||
/**
|
||
* 当前服务商必填凭据是否齐全(避免空 captcha_id 打到第三方 403)。
|
||
*/
|
||
function aksrt_captcha_provider_is_configured(string $provider, array $pcfg = []): bool {
|
||
if (!in_array($provider, aksrt_captcha_providers(), true)) {
|
||
return false;
|
||
}
|
||
$required = aksrt_captcha_required_fields($provider);
|
||
if (empty($required)) {
|
||
return false;
|
||
}
|
||
foreach ($required as $field) {
|
||
if (!isset($pcfg[$field]) || trim((string) $pcfg[$field]) === '') {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 指定场景是否启用人机验证。
|
||
*
|
||
* @param string $scene 见 aksrt_captcha_scenes()
|
||
* @return bool
|
||
*/
|
||
function aksrt_is_captcha_enabled_for($scene): bool {
|
||
$scene = sanitize_key((string) $scene);
|
||
if (!array_key_exists($scene, aksrt_captcha_scenes())) {
|
||
return false;
|
||
}
|
||
|
||
$cfg = aksrt_get_captcha_config();
|
||
if (empty($cfg['enabled']) || $cfg['active_provider'] === '') {
|
||
return false;
|
||
}
|
||
|
||
$provider = $cfg['active_provider'];
|
||
$pcfg = $cfg[$provider] ?? [];
|
||
if (!aksrt_captcha_provider_is_configured($provider, $pcfg)) {
|
||
return false;
|
||
}
|
||
|
||
return in_array($scene, $cfg['enabled_scenarios'], true);
|
||
}
|
||
|
||
/**
|
||
* 业务入口统一调用:未启用放行;启用则校验。
|
||
*
|
||
* @param string $scene 场景
|
||
* @param array $payload 客户端提交的验证字段
|
||
* @return true|string 成功返回 true,失败返回错误信息字符串
|
||
*/
|
||
function aksrt_require_captcha(string $scene, array $payload = []) {
|
||
if (!aksrt_is_captcha_enabled_for($scene)) {
|
||
return true;
|
||
}
|
||
return aksrt_verify_captcha($payload);
|
||
}
|
||
|
||
/**
|
||
* 后台摘要:enabled / provider / configured / 场景命中状态。
|
||
*/
|
||
function aksrt_get_captcha_status_summary(): array {
|
||
$cfg = aksrt_get_captcha_config();
|
||
$provider = $cfg['active_provider'];
|
||
$scenes = [];
|
||
foreach (aksrt_captcha_scenes() as $key => $label) {
|
||
$scenes[$key] = aksrt_is_captcha_enabled_for($key);
|
||
}
|
||
return [
|
||
'enabled' => !empty($cfg['enabled']),
|
||
'provider' => $provider,
|
||
'configured' => $provider !== '' && aksrt_captcha_provider_is_configured($provider, $cfg[$provider] ?? []),
|
||
'scenes' => $scenes,
|
||
];
|
||
}
|
||
|
||
// ============================================================
|
||
// 客户端真实 IP(穿透 CDN / 代理)
|
||
// ============================================================
|
||
|
||
function aksrt_get_client_ip(): string {
|
||
// Prefer CDN / reverse-proxy headers (Cloudflare first), then generic proxies.
|
||
$keys = [
|
||
'HTTP_CF_CONNECTING_IP',
|
||
'HTTP_TRUE_CLIENT_IP',
|
||
'HTTP_X_FORWARDED_FOR',
|
||
'HTTP_X_REAL_IP',
|
||
'HTTP_CLIENT_IP',
|
||
];
|
||
foreach ($keys as $key) {
|
||
if (empty($_SERVER[$key])) {
|
||
continue;
|
||
}
|
||
// X-Forwarded-For may be a chain: client, proxy1, proxy2 — take first valid.
|
||
$parts = explode(',', (string) $_SERVER[$key]);
|
||
foreach ($parts as $part) {
|
||
$ip = trim($part);
|
||
// Strip IPv6 brackets / port noise occasionally injected by proxies.
|
||
if (str_starts_with($ip, '[') && str_contains($ip, ']')) {
|
||
$ip = substr($ip, 1, strpos($ip, ']') - 1);
|
||
} elseif (substr_count($ip, ':') === 1 && str_contains($ip, '.')) {
|
||
// IPv4:port
|
||
$ip = explode(':', $ip, 2)[0];
|
||
}
|
||
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||
return $ip;
|
||
}
|
||
}
|
||
}
|
||
return isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '0.0.0.0';
|
||
}
|
||
|
||
// Native wp-comments-post + any path that omits comment_author_IP.
|
||
// Ensures comment_author_IP (and thus aksrt_comment_addr) is the real client IP behind CDN.
|
||
add_filter('pre_comment_user_ip', static function ($ip) {
|
||
$resolved = aksrt_get_client_ip();
|
||
return ($resolved !== '' && $resolved !== '0.0.0.0') ? $resolved : $ip;
|
||
}, 5);
|
||
|
||
// ============================================================
|
||
// 腾讯云 API 3.0 TC3-HMAC-SHA256 签名
|
||
// ============================================================
|
||
|
||
function aksrt_tencent_tc3_sign($secret_id, $secret_key, $service, $timestamp, $date, $action, $version, $payload): array {
|
||
$content_type = 'application/json; charset=utf-8';
|
||
$host = 'captcha.tencentcloudapi.com';
|
||
$payload_hash = hash('sha256', $payload);
|
||
$canonical_headers = "content-type:{$content_type}\nhost:{$host}\n";
|
||
$signed_headers = 'content-type;host';
|
||
$canonical_request = "POST\n/\n\n{$canonical_headers}\n{$signed_headers}\n{$payload_hash}";
|
||
$credential_scope = "{$date}/{$service}/tc3_request";
|
||
$string_to_sign = "TC3-HMAC-SHA256\n{$timestamp}\n{$credential_scope}\n" . hash('sha256', $canonical_request);
|
||
|
||
$secret_date = hash_hmac('sha256', $date, 'TC3' . $secret_key, true);
|
||
$secret_service = hash_hmac('sha256', $service, $secret_date, true);
|
||
$secret_signing = hash_hmac('sha256', 'tc3_request', $secret_service, true);
|
||
$signature = hash_hmac('sha256', $string_to_sign, $secret_signing);
|
||
|
||
$authorization = 'TC3-HMAC-SHA256 '
|
||
. "Credential={$secret_id}/{$credential_scope}, "
|
||
. "SignedHeaders={$signed_headers}, "
|
||
. "Signature={$signature}";
|
||
|
||
return [
|
||
'Authorization' => $authorization,
|
||
'Content-Type' => $content_type,
|
||
'Host' => $host,
|
||
'X-TC-Action' => $action,
|
||
'X-TC-Timestamp'=> (string) $timestamp,
|
||
'X-TC-Version' => $version,
|
||
'X-TC-Region' => '',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 阿里云 OpenAPI POP Signature v1.0 (HMAC-SHA1)。
|
||
*/
|
||
function aksrt_aliyun_afs_sign(array $params, string $secret): string {
|
||
ksort($params);
|
||
$canonicalized = '';
|
||
foreach ($params as $k => $v) {
|
||
$canonicalized .= '&' . rawurlencode((string) $k) . '=' . rawurlencode((string) $v);
|
||
}
|
||
$string_to_sign = 'POST&%2F&' . rawurlencode(substr($canonicalized, 1));
|
||
return base64_encode(hash_hmac('sha1', $string_to_sign, $secret . '&', true));
|
||
}
|
||
|
||
// ============================================================
|
||
// 服务端二次校验
|
||
// ============================================================
|
||
|
||
/**
|
||
* 校验客户端提交的人机验证字段。
|
||
*
|
||
* @param array $post_data 客户端字段(captcha_verified + 厂商 token)
|
||
* @return true|string 成功返回 true,失败返回错误信息
|
||
*/
|
||
function aksrt_verify_captcha($post_data) {
|
||
$cfg = aksrt_get_captcha_config();
|
||
if (empty($cfg['enabled']) || $cfg['active_provider'] === '') {
|
||
return true;
|
||
}
|
||
|
||
$provider = $cfg['active_provider'];
|
||
$pcfg = $cfg[$provider] ?? [];
|
||
$client_ip = aksrt_get_client_ip();
|
||
|
||
// 客户端验证字段(前端写入 captcha_verified=1 表示用户已完成交互)
|
||
if (($post_data['captcha_verified'] ?? '') !== '1') {
|
||
return __('人机验证失败', 'aksrt');
|
||
}
|
||
|
||
switch ($provider) {
|
||
case 'tencent':
|
||
$result = aksrt_verify_tencent($pcfg, $post_data, $client_ip);
|
||
break;
|
||
case 'cloudflare':
|
||
$result = aksrt_verify_cloudflare($pcfg, $post_data, $client_ip);
|
||
break;
|
||
case 'geetest':
|
||
$result = aksrt_verify_geetest($pcfg, $post_data);
|
||
break;
|
||
case 'aliyun':
|
||
$result = aksrt_verify_aliyun($pcfg, $post_data);
|
||
break;
|
||
default:
|
||
return true;
|
||
}
|
||
|
||
return $result === true ? true : ($result ?: __('人机验证失败', 'aksrt'));
|
||
}
|
||
|
||
/**
|
||
* 腾讯云 Captcha 2.0(DescribeCaptchaResult + TC3-HMAC-SHA256)。
|
||
*
|
||
* @return true|string
|
||
*/
|
||
function aksrt_verify_tencent(array $pcfg, array $post_data, string $client_ip) {
|
||
$ticket = (string) ($post_data['tencent_ticket'] ?? '');
|
||
$randstr = (string) ($post_data['tencent_randstr'] ?? '');
|
||
if ($ticket === '' || $randstr === '') {
|
||
return __('缺少验证参数', 'aksrt');
|
||
}
|
||
if (str_starts_with($ticket, 'trerror_')) {
|
||
return __('验证码 SDK 异常,请刷新页面重试(容灾票据)', 'aksrt');
|
||
}
|
||
|
||
$secret_id = (string) ($pcfg['secret_id'] ?? '');
|
||
$secret_key = (string) ($pcfg['secret_key'] ?? '');
|
||
if ($secret_id === '' || $secret_key === '') {
|
||
return __('验证码配置不完整:缺少云 API 密钥 (SecretId/SecretKey)', 'aksrt');
|
||
}
|
||
|
||
$timestamp = time();
|
||
$date = gmdate('Y-m-d', $timestamp);
|
||
$payload = wp_json_encode([
|
||
'CaptchaType' => 9,
|
||
'CaptchaAppId' => (int) ($pcfg['app_id'] ?? 0),
|
||
'AppSecretKey' => (string) ($pcfg['app_secret_key'] ?? ''),
|
||
'Ticket' => $ticket,
|
||
'Randstr' => $randstr,
|
||
'UserIp' => $client_ip,
|
||
]);
|
||
|
||
$headers = aksrt_tencent_tc3_sign($secret_id, $secret_key, 'captcha', $timestamp, $date, 'DescribeCaptchaResult', '2019-07-22', $payload);
|
||
|
||
$curl_headers = [];
|
||
foreach ($headers as $k => $v) {
|
||
$curl_headers[] = "{$k}: {$v}";
|
||
}
|
||
|
||
$ch = curl_init('https://captcha.tencentcloudapi.com/');
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_POST => true,
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 10,
|
||
CURLOPT_POSTFIELDS => $payload,
|
||
CURLOPT_HTTPHEADER => $curl_headers,
|
||
CURLOPT_SSL_VERIFYPEER => false,
|
||
CURLOPT_FOLLOWLOCATION => false,
|
||
]);
|
||
$response_body = curl_exec($ch);
|
||
$curl_error = curl_error($ch);
|
||
curl_close($ch);
|
||
|
||
if ($curl_error) {
|
||
return '验证码服务请求失败: ' . $curl_error;
|
||
}
|
||
|
||
$body = json_decode((string) $response_body, true);
|
||
if (isset($body['Response']['Error'])) {
|
||
$err = $body['Response']['Error'];
|
||
return '腾讯 API 调用失败: ' . ($err['Message'] ?? $err['Code'] ?? '未知错误');
|
||
}
|
||
|
||
$resp = $body['Response'] ?? [];
|
||
$captcha_code = (int) ($resp['CaptchaCode'] ?? 0);
|
||
if ($captcha_code === 1) {
|
||
return true;
|
||
}
|
||
|
||
$messages = [
|
||
0 => '验证未通过',
|
||
7 => 'Randstr 不合法',
|
||
8 => 'Ticket 已过期(有效期 5 分钟)',
|
||
9 => 'Ticket 被重复使用',
|
||
15 => 'Ticket 不合法(解密失败)',
|
||
16 => 'CaptchaAppId 与 Ticket 不匹配',
|
||
21 => '票据校验异常(风控拦截)',
|
||
100 => '参数校验失败(AppId/SecretKey/Ticket 不匹配)',
|
||
];
|
||
$msg = $resp['CaptchaMsg'] ?? ($messages[$captcha_code] ?? '未知错误');
|
||
return '腾讯验证失败: ' . $msg . " (CaptchaCode: {$captcha_code})";
|
||
}
|
||
|
||
/**
|
||
* Cloudflare Turnstile siteverify。
|
||
*
|
||
* @return true|string
|
||
*/
|
||
function aksrt_verify_cloudflare(array $pcfg, array $post_data, string $client_ip) {
|
||
$token = (string) ($post_data['cf_turnstile_response'] ?? '');
|
||
if ($token === '' || strlen($token) < 10) {
|
||
return __('缺少验证参数', 'aksrt');
|
||
}
|
||
|
||
$secret = (string) ($pcfg['secret_key'] ?? '');
|
||
$bodies = [
|
||
['secret' => $secret, 'response' => $token],
|
||
['secret' => $secret, 'response' => $token, 'remoteip' => $client_ip],
|
||
];
|
||
|
||
$body = [];
|
||
foreach ($bodies as $attempt) {
|
||
$resp = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
|
||
'timeout' => 10,
|
||
'body' => $attempt,
|
||
]);
|
||
if (is_wp_error($resp)) {
|
||
continue;
|
||
}
|
||
$body = json_decode(wp_remote_retrieve_body($resp), true);
|
||
if (!empty($body['success'])) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
$codes = isset($body['error-codes']) && is_array($body['error-codes']) ? implode(', ', $body['error-codes']) : '未知错误';
|
||
return 'Cloudflare 验证失败: ' . $codes;
|
||
}
|
||
|
||
/**
|
||
* 极验 GeeTest v4 validate。
|
||
*
|
||
* @return true|string
|
||
*/
|
||
function aksrt_verify_geetest(array $pcfg, array $post_data) {
|
||
$lot_number = (string) ($post_data['geetest_lot_number'] ?? '');
|
||
$captcha_output = (string) ($post_data['geetest_captcha_output'] ?? '');
|
||
$pass_token = (string) ($post_data['geetest_pass_token'] ?? '');
|
||
$gen_time = (string) ($post_data['geetest_gen_time'] ?? '');
|
||
if ($lot_number === '' || $captcha_output === '' || $pass_token === '') {
|
||
return __('缺少验证参数', 'aksrt');
|
||
}
|
||
|
||
$captcha_key = (string) ($pcfg['captcha_key'] ?? '');
|
||
$sign_token = hash_hmac('sha256', $lot_number, $captcha_key);
|
||
|
||
$resp = wp_remote_post('https://gcaptcha4.geetest.com/validate', [
|
||
'timeout' => 10,
|
||
'body' => [
|
||
'captcha_id' => (string) ($pcfg['captcha_id'] ?? ''),
|
||
'lot_number' => $lot_number,
|
||
'captcha_output' => $captcha_output,
|
||
'pass_token' => $pass_token,
|
||
'gen_time' => $gen_time,
|
||
'sign_token' => $sign_token,
|
||
],
|
||
]);
|
||
|
||
if (is_wp_error($resp)) {
|
||
return '验证请求失败: ' . $resp->get_error_message();
|
||
}
|
||
|
||
$body = json_decode(wp_remote_retrieve_body($resp), true);
|
||
if (!empty($body['result']) && $body['result'] === 'success') {
|
||
return true;
|
||
}
|
||
$reason = $body['reason'] ?? ($body['msg'] ?? '未知错误');
|
||
return '极验验证失败: ' . $reason;
|
||
}
|
||
|
||
/**
|
||
* 阿里云 CAPTCHA 2.0 V3 (VerifyIntelligentCaptcha)。
|
||
*
|
||
* @return true|string
|
||
*/
|
||
function aksrt_verify_aliyun(array $pcfg, array $post_data) {
|
||
$captcha_verify_param = (string) ($post_data['aliyun_captcha_verify_param'] ?? '');
|
||
if ($captcha_verify_param === '') {
|
||
return __('缺少验证参数 (captchaVerifyParam)', 'aksrt');
|
||
}
|
||
|
||
$region = (string) ($pcfg['region'] ?? 'cn');
|
||
$endpoint = $region === 'sgp'
|
||
? 'captcha.ap-southeast-1.aliyuncs.com'
|
||
: 'captcha.cn-shanghai.aliyuncs.com';
|
||
|
||
$params = [
|
||
'AccessKeyId' => (string) ($pcfg['access_key_id'] ?? ''),
|
||
'Action' => 'VerifyIntelligentCaptcha',
|
||
'Format' => 'JSON',
|
||
'SignatureMethod' => 'HMAC-SHA1',
|
||
'SignatureVersion' => '1.0',
|
||
'SignatureNonce' => uniqid('', true),
|
||
'Timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
|
||
'Version' => '2023-03-05',
|
||
'CaptchaVerifyParam' => $captcha_verify_param,
|
||
];
|
||
if (!empty($pcfg['scene_id'])) {
|
||
$params['SceneId'] = (string) $pcfg['scene_id'];
|
||
}
|
||
$params['Signature'] = aksrt_aliyun_afs_sign($params, (string) ($pcfg['access_key_secret'] ?? ''));
|
||
|
||
$resp = wp_remote_post('https://' . $endpoint . '/', [
|
||
'timeout' => 10,
|
||
'body' => $params,
|
||
]);
|
||
|
||
if (is_wp_error($resp)) {
|
||
return '验证请求失败: ' . $resp->get_error_message();
|
||
}
|
||
|
||
$body = json_decode(wp_remote_retrieve_body($resp), true);
|
||
if (!empty($body['Success'])) {
|
||
$verify_result = $body['Result']['VerifyResult'] ?? false;
|
||
if ($verify_result === true) {
|
||
return true;
|
||
}
|
||
$code_messages = [
|
||
'F001' => '疑似攻击请求,风险策略不通过',
|
||
'F002' => '验证参数为空',
|
||
'F003' => '验证参数格式不合法',
|
||
'F004' => '测试模式配置为不通过',
|
||
'F005' => '场景 ID 不合法',
|
||
'F006' => '场景 ID 与配置不匹配',
|
||
'F008' => '验证数据重复提交',
|
||
'F009' => '检测到虚拟设备环境',
|
||
'F010' => '同 IP 访问频率超限',
|
||
'F011' => '同设备访问频率超限',
|
||
'F012' => 'SceneID 与前端配置不一致',
|
||
'F014' => '无初始化记录或已过期(20 分钟)',
|
||
'F015' => '验证交互不通过',
|
||
'F018' => 'CaptchaVerifyParam 重复使用',
|
||
'F019' => '验证请求超时(90 秒)',
|
||
'F020' => 'CaptchaVerifyParam 与场景 ID 不匹配',
|
||
];
|
||
$verify_code = (string) ($body['Result']['VerifyCode'] ?? '');
|
||
return '阿里云 V3 验证失败: ' . ($code_messages[$verify_code] ?? '未知错误 (' . ($verify_code ?: 'N/A') . ')');
|
||
}
|
||
|
||
return '阿里云 V3 验证失败: ' . ($body['Message'] ?? '未知错误');
|
||
}
|
||
|
||
// ============================================================
|
||
// 前端渲染(挂载点 + 内联 SDK + AKSRTCaptchaState)
|
||
// ============================================================
|
||
|
||
/**
|
||
* 渲染人机验证挂载点。未启用场景直接返回。
|
||
*/
|
||
function aksrt_render_captcha($scene = 'comment') {
|
||
$scene = sanitize_key((string) $scene);
|
||
if (!aksrt_is_captcha_enabled_for($scene)) {
|
||
return;
|
||
}
|
||
|
||
$cfg = aksrt_get_captcha_config();
|
||
$provider = $cfg['active_provider'];
|
||
$pcfg = $cfg[$provider] ?? [];
|
||
|
||
if (!aksrt_captcha_provider_is_configured($provider, $pcfg)) {
|
||
if (current_user_can('manage_options')) {
|
||
echo '<div class="aksrt-captcha-misconfig" style="font-size:12px;color:#d03050;margin:8px 0;">';
|
||
echo esc_html(sprintf(
|
||
/* translators: %s: captcha provider key */
|
||
__('人机验证服务商「%s」已启用但凭据未配齐,前台已跳过渲染。请到「功能&权限 → 人机验证」补全配置。', 'aksrt'),
|
||
$provider
|
||
));
|
||
echo '</div>';
|
||
}
|
||
return;
|
||
}
|
||
|
||
$uid = 'aksrt-captcha-' . $scene . '-' . wp_unique_id();
|
||
$vid = 'captcha_verified_' . $scene . '_' . wp_unique_id();
|
||
|
||
$js_config = [
|
||
'provider' => $provider,
|
||
'scene' => $scene,
|
||
'uid' => $uid,
|
||
'vid' => $vid,
|
||
'tc' => ['app_id' => (string) ($pcfg['app_id'] ?? '')],
|
||
'cf' => [
|
||
'site_key' => (string) ($pcfg['site_key'] ?? ''),
|
||
'theme' => (string) ($pcfg['theme'] ?? 'auto'),
|
||
'size' => (string) ($pcfg['size'] ?? 'normal'),
|
||
],
|
||
'gt' => [
|
||
'captcha_id' => trim((string) ($pcfg['captcha_id'] ?? '')),
|
||
'product' => (string) ($pcfg['product'] ?? 'bind'),
|
||
],
|
||
'al' => [
|
||
'region' => (string) ($pcfg['region'] ?? 'cn'),
|
||
'prefix' => (string) ($pcfg['prefix'] ?? ''),
|
||
'scene_id' => (string) ($pcfg['scene_id'] ?? ''),
|
||
],
|
||
];
|
||
|
||
$token_fields = ['cf_turnstile_response', 'tencent_ticket', 'tencent_randstr', 'tencent_sid',
|
||
'geetest_lot_number', 'geetest_captcha_output', 'geetest_pass_token', 'geetest_gen_time',
|
||
'aliyun_captcha_verify_param'];
|
||
$token_fields_json = wp_json_encode($token_fields, JSON_UNESCAPED_UNICODE);
|
||
?>
|
||
<div class="aksrt-captcha-wrap" data-provider="<?php echo esc_attr($provider); ?>" data-scene="<?php echo esc_attr($scene); ?>">
|
||
<div id="<?php echo esc_attr($uid); ?>" class="aksrt-captcha-box" style="display:none"></div>
|
||
<input type="hidden" name="captcha_verified" id="<?php echo esc_attr($vid); ?>" value="0">
|
||
</div>
|
||
<link rel="stylesheet" href="<?php echo esc_url(AKSRT_URI . '/assets/css/shared/captcha.css'); ?>">
|
||
<script>(function(){
|
||
var C=<?php echo wp_json_encode($js_config, JSON_UNESCAPED_UNICODE); ?>;
|
||
var TOKEN_FIELDS=<?php echo $token_fields_json; ?>;
|
||
var root=window.AKSRTCaptchaState=window.AKSRTCaptchaState||{loaders:{}, scenes:{}};
|
||
|
||
function loadScriptOnce(key, src, readyCheck){
|
||
if(typeof readyCheck==='function' && readyCheck()){ return Promise.resolve(); }
|
||
if(root.loaders[key]){ return root.loaders[key]; }
|
||
root.loaders[key]=new Promise(function(resolve, reject){
|
||
var existing=document.querySelector('script[data-aksrt-sdk="'+key+'"]');
|
||
if(existing){
|
||
if(typeof readyCheck==='function' && readyCheck()){ resolve(); return; }
|
||
existing.addEventListener('load', function(){ resolve(); }, {once:true});
|
||
existing.addEventListener('error', function(){ reject(new Error(key+' load failed')); }, {once:true});
|
||
return;
|
||
}
|
||
var script=document.createElement('script');
|
||
script.src=src; script.async=true; script.setAttribute('data-aksrt-sdk', key);
|
||
script.onload=function(){ resolve(); };
|
||
script.onerror=function(){ reject(new Error(key+' load failed')); };
|
||
document.head.appendChild(script);
|
||
});
|
||
return root.loaders[key];
|
||
}
|
||
|
||
var box, hField, form=null;
|
||
var verified=false;
|
||
var pendingAction=null;
|
||
var triggerCaptcha=function(){ showTip('验证码加载中,请稍候', '#e67e22'); };
|
||
|
||
function findForm(){
|
||
form=box.closest('form');
|
||
if(!form&&C.scene==='comment') form=document.getElementById('commentform');
|
||
if(!form&&(C.scene==='login'||C.scene==='register')){
|
||
form=document.getElementById('aksrt-login-form')
|
||
||document.getElementById('aksrt-modal-login-form')
|
||
||document.getElementById('sign-login-form')
|
||
||document.getElementById('aksrt-phone-login-form')
|
||
||document.getElementById('aksrt-modal-register-form')
|
||
||document.getElementById('sign-register-form')
|
||
||document.getElementById('aksrt-phone-register-form')
|
||
||document.querySelector('.auth-form')
|
||
||document.querySelector('.sign-form');
|
||
}
|
||
if(!form&&C.scene==='bind_phone'){
|
||
form=document.getElementById('aksrt-bind-phone-form')
|
||
||document.querySelector('[data-aksrt-bind-phone-form]')
|
||
||document.querySelector('.auth-form');
|
||
}
|
||
return form;
|
||
}
|
||
|
||
function bindSubmit(){
|
||
if(!form) return;
|
||
form.addEventListener('submit', function(e){
|
||
if(verified || hField.value==='1') return;
|
||
e.preventDefault(); e.stopImmediatePropagation();
|
||
pendingAction=null; triggerCaptcha();
|
||
}, true);
|
||
}
|
||
|
||
function setField(name, value){
|
||
var container = form || box;
|
||
var el=container.querySelector('input[name="'+name+'"]');
|
||
if(!el){ el=document.createElement('input'); el.type='hidden'; el.name=name; container.appendChild(el); }
|
||
el.value=value;
|
||
}
|
||
|
||
function resetVerificationState(){
|
||
verified=false;
|
||
hField.value='0';
|
||
var container = form || box;
|
||
TOKEN_FIELDS.forEach(function(n){
|
||
var f=container.querySelector('input[name="'+n+'"]');
|
||
if(f) f.remove();
|
||
});
|
||
if(C.scene==='download'){
|
||
syncDownloadSiblings(false);
|
||
}
|
||
}
|
||
|
||
function syncDownloadSiblings(passed){
|
||
var myContainer=box.closest('[data-aksrt-download-captcha]');
|
||
if(!myContainer) return;
|
||
document.querySelectorAll('[data-aksrt-download-captcha]').forEach(function(target){
|
||
if(target===myContainer) return;
|
||
var tgtH=target.querySelector('input[name="captcha_verified"]');
|
||
if(tgtH) tgtH.value=passed ? '1' : '0';
|
||
TOKEN_FIELDS.forEach(function(name){
|
||
var f=target.querySelector('input[name="'+name+'"]');
|
||
if(f) f.remove();
|
||
if(passed){
|
||
var srcEl=myContainer.querySelector('input[name="'+name+'"]');
|
||
if(srcEl&&srcEl.value!==''){
|
||
var tgtEl=document.createElement('input'); tgtEl.type='hidden'; tgtEl.name=name; target.appendChild(tgtEl);
|
||
tgtEl.value=srcEl.value;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function submitNativeForm(){
|
||
if(!form) return;
|
||
if(form.requestSubmit){ form.requestSubmit(); }
|
||
else { HTMLFormElement.prototype.submit.call(form); }
|
||
}
|
||
|
||
function submitForm(){
|
||
verified=true; hField.value='1';
|
||
if(C.scene==='download') syncDownloadSiblings(true);
|
||
if(typeof pendingAction==='function'){
|
||
finishPending(true); return;
|
||
}
|
||
setTimeout(resetVerificationState, 3000);
|
||
submitNativeForm();
|
||
}
|
||
|
||
function showTip(msg, color){
|
||
var t=box.querySelector('.captcha-tip');
|
||
if(!t){ t=document.createElement('div'); t.className='captcha-tip'; t.style.cssText='font-size:12px;margin-top:4px;text-align:left;'; box.appendChild(t); }
|
||
t.style.color=color||'#e74c3c'; t.textContent=msg;
|
||
setTimeout(function(){ if(t.parentNode) t.remove(); }, 4000);
|
||
}
|
||
|
||
function showLoading(){ box.innerHTML='<div style="text-align:center;padding:20px;color:#9ca3af;">验证码加载中...</div>'; }
|
||
function clearLoading(){ box.innerHTML=''; }
|
||
|
||
function finishPending(ok){
|
||
var action=pendingAction; pendingAction=null;
|
||
if(typeof action==='function'){ action(!!ok); }
|
||
}
|
||
|
||
function cancelPending(msg){
|
||
if(msg) showTip(msg, '#e67e22');
|
||
finishPending(false);
|
||
}
|
||
|
||
root.scenes[C.scene]={
|
||
verify:function(callback){
|
||
if(verified || hField.value==='1'){
|
||
if(typeof callback==='function') callback(true);
|
||
return;
|
||
}
|
||
pendingAction=(typeof callback==='function')?function(ok){ callback(ok!==false); }:null;
|
||
triggerCaptcha();
|
||
},
|
||
reset:resetVerificationState
|
||
};
|
||
|
||
// ============ 腾讯云 Captcha 2.0 ============
|
||
function loadTencent(){
|
||
showLoading();
|
||
loadScriptOnce('tencent', 'https://turing.captcha.qcloud.com/TJCaptcha.js', function(){
|
||
return typeof TencentCaptcha!=='undefined';
|
||
}).then(function(){
|
||
clearLoading();
|
||
var capIns=null;
|
||
triggerCaptcha=function(){
|
||
if(!capIns){
|
||
capIns=new TencentCaptcha(C.tc.app_id, function(res){
|
||
if(res.ret===2){ cancelPending('已取消验证'); return; }
|
||
if(res.ret===0 && res.ticket){
|
||
if(res.ticket.indexOf('trerror_')===0){
|
||
showTip('验证码服务异常,请刷新页面重试', '#e67e22'); return;
|
||
}
|
||
setField('tencent_ticket', res.ticket);
|
||
setField('tencent_randstr', res.randstr);
|
||
setField('tencent_sid', res.sid||'');
|
||
submitForm();
|
||
return;
|
||
}
|
||
if(res.errorCode){
|
||
var errorMap={1001:'验证码组件加载失败',1002:'验证码超时',1006:'验证码配置拉取失败',1008:'验证码页面加载异常',1012:'验证码刷新次数过多',1013:'验证码网络异常'};
|
||
showTip(errorMap[res.errorCode]||('验证码异常(errorCode:'+res.errorCode+')'), '#e74c3c');
|
||
}
|
||
}, {userLanguage:'zh-cn'});
|
||
}
|
||
capIns.show();
|
||
};
|
||
}).catch(function(){ clearLoading(); showTip('验证码组件加载失败,请刷新重试', '#e74c3c'); });
|
||
}
|
||
|
||
// ============ Cloudflare Turnstile(弹窗) ============
|
||
function loadCloudflare(){
|
||
var cfWidgetId=null, cfVerifying=false, cfModal=null;
|
||
|
||
if(typeof turnstile==='undefined'){
|
||
window['onTurnstileLoad_'+C.uid]=function(){};
|
||
loadScriptOnce('cloudflare', 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad_'+C.uid+'&render=explicit', function(){
|
||
return typeof turnstile!=='undefined';
|
||
});
|
||
}
|
||
|
||
function renderWidget(){
|
||
if(cfWidgetId){ try { turnstile.remove(cfWidgetId); } catch(e) {} cfWidgetId=null; }
|
||
if(typeof turnstile==='undefined'){ setTimeout(renderWidget, 200); return; }
|
||
cfWidgetId=turnstile.render('#modalWidget_'+C.uid, {
|
||
sitekey:C.cf.site_key, theme:C.cf.theme, size:C.cf.size, action:'aksrt-verify',
|
||
callback:function(token){
|
||
if(!cfVerifying) return;
|
||
cfVerifying=false;
|
||
setField('cf_turnstile_response', token);
|
||
closeModal(); submitForm();
|
||
},
|
||
'error-callback':function(){ showTip('验证码失败,请重试', '#e74c3c'); cfWidgetId=null; setTimeout(renderWidget, 500); },
|
||
'expired-callback':function(){ showTip('验证码已过期,请重新验证', '#e67e22'); cfWidgetId=null; setTimeout(renderWidget, 500); }
|
||
});
|
||
}
|
||
|
||
function createModal(){
|
||
cfModal=document.createElement('div');
|
||
cfModal.className='aksrt-captcha-modal-overlay';
|
||
cfModal.innerHTML='<div class="aksrt-captcha-modal-backdrop"></div>'+
|
||
'<div class="aksrt-captcha-modal-box"><div class="aksrt-captcha-modal-header">'+
|
||
'<span class="aksrt-captcha-modal-title">人机验证</span>'+
|
||
'<button type="button" class="aksrt-captcha-modal-close">×</button></div>'+
|
||
'<div class="aksrt-captcha-modal-body"><p class="aksrt-captcha-modal-hint">请完成验证后继续操作</p>'+
|
||
'<div id="modalWidget_'+C.uid+'" class="aksrt-captcha-modal-widget"></div></div></div>';
|
||
document.body.appendChild(cfModal);
|
||
var cancelFn=function(){ cfVerifying=false; closeModal(); cancelPending('已取消验证'); };
|
||
cfModal.querySelector('.aksrt-captcha-modal-backdrop').addEventListener('click', cancelFn);
|
||
cfModal.querySelector('.aksrt-captcha-modal-close').addEventListener('click', cancelFn);
|
||
renderWidget();
|
||
}
|
||
|
||
function closeModal(){
|
||
if(cfWidgetId){ try { turnstile.remove(cfWidgetId); } catch(e) {} cfWidgetId=null; }
|
||
if(cfModal && cfModal.parentNode){ cfModal.parentNode.removeChild(cfModal); }
|
||
cfModal=null;
|
||
}
|
||
|
||
triggerCaptcha=function(){
|
||
if(cfVerifying){ showTip('验证码进行中,请稍候', '#e67e22'); return; }
|
||
cfVerifying=true; createModal();
|
||
};
|
||
}
|
||
|
||
// ============ 极验 GeeTest v4 ============
|
||
function loadGeetest(){
|
||
showLoading();
|
||
var captchaId=(C.gt && C.gt.captcha_id) ? String(C.gt.captcha_id).trim() : '';
|
||
var product=(C.gt && C.gt.product) ? String(C.gt.product) : 'bind';
|
||
if(!captchaId){
|
||
clearLoading();
|
||
showTip('极验 Captcha ID 未配置,请到后台「功能&权限 → 人机验证」填写并保存', '#e74c3c');
|
||
return;
|
||
}
|
||
function render(){
|
||
clearLoading();
|
||
initGeetest4({captchaId:captchaId, product:product, hideSuccess:true}, function(capIns){
|
||
window['__gt_'+C.uid]=capIns;
|
||
function onSuccess(){
|
||
var result=capIns.getValidate();
|
||
if(!result) return;
|
||
setField('geetest_lot_number', result.lot_number);
|
||
setField('geetest_captcha_output', result.captcha_output);
|
||
setField('geetest_pass_token', result.pass_token);
|
||
setField('geetest_gen_time', result.gen_time);
|
||
submitForm();
|
||
}
|
||
if(product==='bind' || product==='popup'){
|
||
capIns.onReady(function(){}).onSuccess(onSuccess).onClose(function(){});
|
||
}else{
|
||
capIns.appendTo('#'+C.uid).onSuccess(onSuccess).onClose(function(){});
|
||
}
|
||
});
|
||
triggerCaptcha=function(){
|
||
var instance=window['__gt_'+C.uid];
|
||
if(!instance){ showTip('验证码加载中,请稍候再试', '#e67e22'); return; }
|
||
if(product==='bind' || product==='popup'){ instance.showCaptcha(); return; }
|
||
showTip('请先完成人机验证', '#e67e22');
|
||
};
|
||
}
|
||
if(typeof initGeetest4==='undefined'){
|
||
loadScriptOnce('geetest', 'https://static.geetest.com/v4/gt4.js', function(){
|
||
return typeof initGeetest4!=='undefined';
|
||
}).then(render).catch(function(){ clearLoading(); showTip('极验组件加载失败', '#e74c3c'); });
|
||
return;
|
||
}
|
||
render();
|
||
}
|
||
|
||
// ============ 阿里云 CAPTCHA 2.0 V3 ============
|
||
function loadAliyun(){
|
||
showLoading();
|
||
window.AliyunCaptchaConfig={ region:C.al.region||'cn', prefix:C.al.prefix };
|
||
|
||
function render(){
|
||
clearLoading();
|
||
var captchaIns=null;
|
||
var btn=box.closest('form') ? box.closest('form').querySelector('button[type="submit"], input[type="submit"]') : null;
|
||
window.initAliyunCaptcha({
|
||
SceneId:C.al.scene_id, mode:'popup', element:'#'+C.uid,
|
||
button:btn ? '#' + (btn.id || (function(){ btn.id='aksrt-captcha-btn-'+C.scene; return btn.id; })()) : null,
|
||
success:function(captchaVerifyParam){
|
||
setField('aliyun_captcha_verify_param', captchaVerifyParam);
|
||
submitForm();
|
||
},
|
||
fail:function(result){ showTip('验证码失败: ' + (result || '请重试'), '#e74c3c'); captchaIns=null; },
|
||
getInstance:function(instance){ captchaIns=instance; },
|
||
slideStyle:{width:360, height:40}, language:'cn'
|
||
});
|
||
triggerCaptcha=function(){
|
||
if(captchaIns && captchaIns.show){ captchaIns.show(); return; }
|
||
showTip('验证码组件加载中,请稍候再试', '#e67e22');
|
||
};
|
||
}
|
||
|
||
if(typeof window.initAliyunCaptcha==='undefined'){
|
||
loadScriptOnce('aliyun', 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js', function(){
|
||
return typeof window.initAliyunCaptcha!=='undefined';
|
||
}).then(render).catch(function(){ clearLoading(); showTip('验证码组件加载失败,请刷新重试', '#e74c3c'); });
|
||
return;
|
||
}
|
||
render();
|
||
}
|
||
|
||
function loadProvider(){
|
||
switch(C.provider){
|
||
case 'tencent': loadTencent(); break;
|
||
case 'cloudflare': loadCloudflare(); break;
|
||
case 'geetest': loadGeetest(); break;
|
||
case 'aliyun': loadAliyun(); break;
|
||
}
|
||
}
|
||
|
||
function init(){
|
||
box=document.getElementById(C.uid);
|
||
hField=document.getElementById(C.vid);
|
||
if(!box||!hField) return;
|
||
|
||
var tries=0;
|
||
(function retryFind(){
|
||
findForm();
|
||
if(!form && ++tries < 10){ setTimeout(retryFind, 150); }
|
||
else {
|
||
if(form) bindSubmit();
|
||
setTimeout(loadProvider, 0);
|
||
}
|
||
})();
|
||
}
|
||
|
||
if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', init); }
|
||
else { setTimeout(init, 50); }
|
||
})();</script>
|
||
<?php
|
||
}
|