aksrt_is_captcha_enabled_for(scene) * -> aksrt_render_captcha(scene) -> [data-aksrt-captcha-config] 挂载点 * -> captcha-front.js(public bundle)-> 客户端 AKSRTCaptcha + token * -> 业务入口 aksrt_require_captcha(scene, $payload) -> proceed/fail。 * * 仅支持云厂商:tencent / aliyun / cloudflare / geetest。 * 不含本地图形码 / 本地滑块。 * * @package AKSRT */ defined('ABSPATH') || exit; const AKSRT_CAPTCHA_BROWSER_COOKIE = 'aksrt_captcha_browser'; const AKSRT_CAPTCHA_CHALLENGE_PREFIX = 'aksrt_captcha_challenge_'; const AKSRT_CAPTCHA_PROOF_PREFIX = 'aksrt_captcha_proof_'; const AKSRT_CAPTCHA_CHALLENGE_TTL = 10 * MINUTE_IN_SECONDS; const AKSRT_CAPTCHA_PROOF_TTL = 5 * MINUTE_IN_SECONDS; function aksrt_captcha_browser_id(): string { $value = isset($_COOKIE[AKSRT_CAPTCHA_BROWSER_COOKIE]) ? sanitize_text_field(wp_unslash((string) $_COOKIE[AKSRT_CAPTCHA_BROWSER_COOKIE])) : ''; if (preg_match('/^[A-Za-z0-9_-]{32,80}$/', $value) === 1) { return $value; } $value = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); if (!headers_sent()) { setcookie(AKSRT_CAPTCHA_BROWSER_COOKIE, $value, [ 'expires' => time() + MONTH_IN_SECONDS, 'path' => defined('COOKIEPATH') && COOKIEPATH ? COOKIEPATH : '/', 'domain' => defined('COOKIE_DOMAIN') ? COOKIE_DOMAIN : '', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ]); } $_COOKIE[AKSRT_CAPTCHA_BROWSER_COOKIE] = $value; return $value; } function aksrt_captcha_challenge_key(string $token): string { return AKSRT_CAPTCHA_CHALLENGE_PREFIX . hash('sha256', trim($token)); } function aksrt_captcha_challenge_material(string $scene, string $provider, string $nonce, int $timestamp): string { $host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)); return sanitize_key($scene) . '|' . sanitize_key($provider) . '|' . hash('sha256', aksrt_captcha_browser_id()) . '|' . $host . '|' . $timestamp . '|' . $nonce; } function aksrt_captcha_issue_challenge(string $scene, string $provider): string { $nonce = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); $timestamp = time(); $material = aksrt_captcha_challenge_material($scene, $provider, $nonce, $timestamp); $signature = hash_hmac('sha256', $material, wp_salt('auth')); return $nonce . '.' . $timestamp . '.' . $signature; } function aksrt_captcha_challenge_valid(string $token, string $scene, string $provider): bool { $parts = explode('.', trim($token)); if (count($parts) !== 3) { return false; } [$nonce, $raw_timestamp, $signature] = $parts; if (preg_match('/^[A-Za-z0-9_-]{32,80}$/', $nonce) !== 1 || preg_match('/^\d{10}$/', $raw_timestamp) !== 1 || preg_match('/^[a-f0-9]{64}$/', $signature) !== 1) { return false; } $timestamp = (int) $raw_timestamp; if ($timestamp > time() + 60 || $timestamp < time() - AKSRT_CAPTCHA_CHALLENGE_TTL) { return false; } $expected = hash_hmac( 'sha256', aksrt_captcha_challenge_material($scene, $provider, $nonce, $timestamp), wp_salt('auth') ); return hash_equals($expected, $signature); } function aksrt_captcha_validate_challenge(string $token, string $scene, string $provider, bool $claim = false): bool { if (!aksrt_captcha_challenge_valid($token, $scene, $provider)) { return false; } $key = aksrt_captcha_challenge_key($token); if (!$claim) { $current = get_option($key, null); if (is_numeric($current) && (int) $current < time()) { delete_option($key); return true; } return $current === null; } $current = get_option($key, null); if (is_numeric($current) && (int) $current < time()) { delete_option($key); } return add_option($key, time() + AKSRT_CAPTCHA_CHALLENGE_TTL, '', false); } function aksrt_captcha_challenge_release(string $token): void { delete_option(aksrt_captcha_challenge_key($token)); } function aksrt_captcha_rate_limited(string $scope, string $subject, int $limit = 30, int $ttl = 60): bool { $key = 'aksrt_captcha_rl_' . md5($scope . '|' . strtolower($subject)); $count = (int) get_transient($key); if ($count >= $limit) { return true; } set_transient($key, $count + 1, $ttl); return false; } function aksrt_captcha_proof_key(string $token): string { return AKSRT_CAPTCHA_PROOF_PREFIX . hash('sha256', trim($token)); } function aksrt_captcha_issue_proof(string $scene, string $provider): string { $token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); $row = [ 'scene' => sanitize_key($scene), 'provider' => sanitize_key($provider), 'browser_hash' => hash('sha256', aksrt_captcha_browser_id()), 'host' => strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)), 'expires_at' => time() + AKSRT_CAPTCHA_PROOF_TTL, ]; if (!add_option(aksrt_captcha_proof_key($token), $row, '', false)) { throw new \RuntimeException('captcha_proof_issue_failed'); } return $token; } function aksrt_captcha_consume_proof(string $token, string $scene, string $provider): bool { if ($token === '') { return false; } $key = aksrt_captcha_proof_key($token); $row = get_option($key, null); $host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)); $valid = is_array($row) && (int) ($row['expires_at'] ?? 0) >= time() && hash_equals(sanitize_key($scene), (string) ($row['scene'] ?? '')) && hash_equals(sanitize_key($provider), (string) ($row['provider'] ?? '')) && hash_equals(hash('sha256', aksrt_captcha_browser_id()), (string) ($row['browser_hash'] ?? '')) && $host !== '' && hash_equals($host, (string) ($row['host'] ?? '')); if (!$valid) { if (is_array($row) && (int) ($row['expires_at'] ?? 0) < time()) { delete_option($key); } return false; } global $wpdb; $deleted = $wpdb->query($wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $key, maybe_serialize($row) )); if ($deleted !== 1) { return false; } wp_cache_delete($key, 'options'); return true; } function aksrt_captcha_cleanup_records(int $limit = 200): int { global $wpdb; $prefixes = [AKSRT_CAPTCHA_CHALLENGE_PREFIX, AKSRT_CAPTCHA_PROOF_PREFIX]; $removed = 0; foreach ($prefixes as $prefix) { $rows = $wpdb->get_results($wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT %d", $wpdb->esc_like($prefix) . '%', max(1, min(1000, $limit)) ), ARRAY_A); foreach (is_array($rows) ? $rows : [] as $row) { $value = maybe_unserialize($row['option_value'] ?? ''); $expires_at = is_array($value) ? (int) ($value['expires_at'] ?? 0) : (int) $value; if ($expires_at > 0 && $expires_at < time() && delete_option((string) $row['option_name'])) { $removed++; } } } return $removed; } add_action('init', static function (): void { if (wp_rand(1, 100) === 1) { aksrt_captcha_cleanup_records(); } }, 30); add_action('rest_api_init', static function (): void { register_rest_route('aksrt/v1', '/captcha/challenge', [ 'methods' => 'POST', 'permission_callback' => '__return_true', 'callback' => static function (\WP_REST_Request $request) { $data = $request->get_json_params(); $scene = sanitize_key((string) (is_array($data) ? ($data['scene'] ?? '') : '')); if (!aksrt_is_captcha_enabled_for($scene)) { return new \WP_Error('aksrt_captcha_scene', __('人机验证场景未启用', 'aksrt'), ['status' => 400]); } $cfg = aksrt_get_captcha_config(); $provider = $cfg['active_provider']; if ($provider === '' || !aksrt_captcha_provider_is_configured($provider, $cfg[$provider] ?? [])) { return new \WP_Error('aksrt_captcha_config', __('人机验证配置不完整', 'aksrt'), ['status' => 503]); } if (aksrt_captcha_rate_limited('challenge:' . $scene, aksrt_get_client_ip(), 20, MINUTE_IN_SECONDS)) { return new \WP_Error('aksrt_captcha_rate', __('请求过于频繁,请稍后再试', 'aksrt'), ['status' => 429]); } return new \WP_REST_Response([ 'success' => true, 'data' => ['challenge' => aksrt_captcha_issue_challenge($scene, $provider)], ], 200); }, ]); register_rest_route('aksrt/v1', '/captcha/exchange', [ 'methods' => 'POST', 'permission_callback' => '__return_true', 'callback' => static function (\WP_REST_Request $request) { $data = $request->get_json_params(); $data = is_array($data) ? $data : []; $scene = sanitize_key((string) ($data['scene'] ?? '')); if (!aksrt_is_captcha_enabled_for($scene)) { return new \WP_Error('aksrt_captcha_scene', __('人机验证场景未启用', 'aksrt'), ['status' => 400]); } $cfg = aksrt_get_captcha_config(); $provider = $cfg['active_provider']; $pcfg = $cfg[$provider] ?? []; if ($provider === '' || !aksrt_captcha_provider_is_configured($provider, $pcfg)) { return new \WP_Error('aksrt_captcha_config', __('人机验证配置不完整', 'aksrt'), ['status' => 503]); } $challenge = sanitize_text_field((string) ($data['aksrt_captcha_challenge'] ?? '')); if (!aksrt_captcha_validate_challenge($challenge, $scene, $provider, false)) { return new \WP_Error('aksrt_captcha_challenge', __('人机验证会话无效或已过期,请重试', 'aksrt'), ['status' => 400]); } // 原子占用 challenge:并发请求只有首个能进入厂商验签,其余直接 409,避免同一票据放大厂商调用。 if (!aksrt_captcha_validate_challenge($challenge, $scene, $provider, true)) { return new \WP_Error('aksrt_captcha_replayed', __('人机验证会话已在验证中,请重试', 'aksrt'), ['status' => 409]); } $result = aksrt_verify_captcha_provider($provider, $pcfg, $data, aksrt_get_client_ip(), $scene); if (is_wp_error($result)) { aksrt_captcha_challenge_release($challenge); return $result; } try { $proof = aksrt_captcha_issue_proof($scene, $provider); } catch (\Throwable $e) { aksrt_captcha_challenge_release($challenge); return new \WP_Error('aksrt_captcha_proof', __('无法创建人机验证凭证,请重试', 'aksrt'), ['status' => 500]); } return new \WP_REST_Response([ 'success' => true, 'data' => ['proof' => $proof], ], 200); }, ]); }); function aksrt_captcha_provider_metadata(): array { return [ 'tencent' => [ 'label' => __('腾讯云 Captcha', 'aksrt'), 'fields' => [ 'secret_id' => ['default' => '', 'required' => true], 'secret_key' => ['default' => '', 'required' => true, 'secret' => true], 'app_id' => ['default' => '', 'required' => true], 'app_secret_key' => ['default' => '', 'required' => true, 'secret' => true], ], ], 'aliyun' => [ 'label' => __('阿里云 CAPTCHA 2.0', 'aksrt'), 'fields' => [ 'region' => ['default' => 'cn', 'required' => true, 'values' => ['cn', 'sgp']], 'prefix' => ['default' => '', 'required' => true], 'scene_id' => ['default' => '', 'required' => true], 'access_key_id' => ['default' => '', 'required' => true], 'access_key_secret' => ['default' => '', 'required' => true, 'secret' => true], ], ], 'cloudflare' => [ 'label' => __('Cloudflare Turnstile', 'aksrt'), 'fields' => [ 'site_key' => ['default' => '', 'required' => true], 'secret_key' => ['default' => '', 'required' => true, 'secret' => true], 'theme' => ['default' => 'auto', 'values' => ['auto', 'light', 'dark']], 'size' => ['default' => 'normal', 'values' => ['normal', 'compact']], ], ], 'geetest' => [ 'label' => __('极验 GeeTest', 'aksrt'), 'fields' => [ 'captcha_id' => ['default' => '', 'required' => true], 'captcha_key' => ['default' => '', 'required' => true, 'secret' => true], 'product' => ['default' => 'bind', 'values' => ['bind', 'float', 'popup']], ], ], ]; } /** * 支持的验证服务商。 */ function aksrt_captcha_providers(): array { return array_keys(aksrt_captcha_provider_metadata()); } /** * 服务商字段白名单与默认值。 */ function aksrt_captcha_provider_defaults(string $provider): array { $fields = aksrt_captcha_provider_metadata()[$provider]['fields'] ?? []; $defaults = []; foreach ($fields as $key => $meta) { $defaults[$key] = $meta['default'] ?? ''; } return $defaults; } /** * 服务商 secret 字段。 */ function aksrt_captcha_provider_secret_fields(string $provider): array { $fields = aksrt_captcha_provider_metadata()[$provider]['fields'] ?? []; return array_values(array_keys(array_filter($fields, static fn(array $meta): bool => !empty($meta['secret'])))); } /** * 按集中元数据规范化服务商配置,丢弃未知键。 */ function aksrt_captcha_sanitize_provider_config(string $provider, $raw): array { $meta = aksrt_captcha_provider_metadata()[$provider]['fields'] ?? []; if ($meta === []) { return []; } $raw = is_array($raw) ? $raw : []; $clean = aksrt_captcha_provider_defaults($provider); foreach ($meta as $key => $field) { if (!array_key_exists($key, $raw) || is_array($raw[$key]) || is_object($raw[$key])) { continue; } $value = sanitize_text_field((string) $raw[$key]); if (!empty($field['values']) && !in_array($value, $field['values'], true)) { continue; } $clean[$key] = $value; } return $clean; } /** * 人机验证场景白名单。 */ 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'), 'download' => __('资源下载', 'aksrt'), 'signin' => __('每日签到', 'aksrt'), 'front_submit' => __('前台投稿', 'aksrt'), ]; } /** * 默认配置结构。 */ function aksrt_captcha_default_config(): array { $config = [ 'enabled' => false, 'active_provider' => '', 'enabled_scenarios' => [], ]; foreach (aksrt_captcha_providers() as $provider) { $config[$provider] = aksrt_captcha_provider_defaults($provider); } return $config; } /** * Sanitize a list of captcha scene keys against the whitelist. * * @param mixed $raw * @return array */ 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); } /** * 获取人机验证完整配置(规范化,不合并历史脏键)。 * * 结果在同一请求内缓存;后台保存后调用 aksrt_captcha_config_reset() 失效。 * * @return array */ function aksrt_get_captcha_config(): array { $cache =& $GLOBALS['aksrt_captcha_config_cache']; if (is_array($cache)) { return $cache; } $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] = aksrt_captcha_sanitize_provider_config($p, $cfg[$p] ?? []); } $cache = $result; return $result; } /** * 使请求内的配置缓存失效(后台保存路径调用)。 */ function aksrt_captcha_config_reset(): void { $GLOBALS['aksrt_captcha_config_cache'] = null; } /** * 各服务商必填字段。 */ function aksrt_captcha_required_fields(string $provider): array { $fields = aksrt_captcha_provider_metadata()[$provider]['fields'] ?? []; return array_values(array_keys(array_filter($fields, static fn(array $meta): bool => !empty($meta['required'])))); } /** * 当前服务商必填凭据是否齐全(避免空 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'])) { return false; } return in_array($scene, $cfg['enabled_scenarios'], true); } /** * 业务入口统一调用:仅显式关闭或未勾选场景放行;启用场景 fail-closed。 * * @param string $scene 场景 * @param array $payload 客户端提交的验证字段 * @return true|\WP_Error 成功返回 true,失败返回 WP_Error */ function aksrt_require_captcha(string $scene, array $payload = []) { if (!aksrt_is_captcha_enabled_for($scene)) { return true; } $cfg = aksrt_get_captcha_config(); $provider = $cfg['active_provider']; if ($provider === '' || !aksrt_captcha_provider_is_configured($provider, $cfg[$provider] ?? [])) { return aksrt_captcha_error('aksrt_captcha_config', __('人机验证配置不完整,请联系站点管理员', 'aksrt')); } $proof = sanitize_text_field((string) ($payload['aksrt_captcha_proof'] ?? '')); if (!aksrt_captcha_consume_proof($proof, $scene, $provider)) { return aksrt_captcha_error('aksrt_captcha_proof', __('人机验证凭证无效、已过期或已使用,请重试', 'aksrt')); } return true; } /** * 后台摘要: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, ]; } /** * 凭据未配齐时的后台警告(已启用场景会 fail-closed,必须补全配置)。 */ add_action('admin_notices', static function () { if (!current_user_can('manage_options')) { return; } $cfg = aksrt_get_captcha_config(); if (empty($cfg['enabled']) || $cfg['active_provider'] === '') { return; } $provider = $cfg['active_provider']; if (aksrt_captcha_provider_is_configured($provider, $cfg[$provider] ?? [])) { return; } echo '

' . esc_html(sprintf( /* translators: %s: captcha provider key */ __('人机验证服务商「%s」已启用但凭据未配齐,已启用场景将拒绝请求。请到「功能&权限 → 人机验证」补全配置。', 'aksrt'), $provider )) . '

'; }); // ============================================================ // 客户端真实 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)); } // ============================================================ // 服务端二次校验 // ============================================================ /** * 记录传输层 / 厂商原始错误细节,供站点排查;对用户只暴露通用文案。 */ function aksrt_captcha_log(string $provider, string $detail): void { error_log('[AKSRT Captcha] ' . $provider . ': ' . $detail); } function aksrt_captcha_error(string $code, string $message, array $data = []): \WP_Error { return new \WP_Error($code, $message, $data); } /** * Require a successful 2xx JSON response from a captcha provider. * * @return array|\WP_Error */ function aksrt_captcha_remote_json(string $provider, $response) { if (is_wp_error($response)) { aksrt_captcha_log($provider, $response->get_error_message()); return aksrt_captcha_error('aksrt_captcha_transport', __('人机验证服务暂不可用,请稍后重试', 'aksrt')); } $status = (int) wp_remote_retrieve_response_code($response); if ($status < 200 || $status >= 300) { aksrt_captcha_log($provider, 'HTTP ' . $status . ': ' . substr((string) wp_remote_retrieve_body($response), 0, 500)); return aksrt_captcha_error('aksrt_captcha_http', __('人机验证服务暂不可用,请稍后重试', 'aksrt'), ['status' => $status]); } $body = json_decode(wp_remote_retrieve_body($response), true); if (!is_array($body)) { aksrt_captcha_log($provider, 'unparsable response'); return aksrt_captcha_error('aksrt_captcha_response', __('人机验证服务暂不可用,请稍后重试', 'aksrt')); } return $body; } /** * Execute one provider verification with an explicit configuration. * * @return true|\WP_Error */ function aksrt_verify_captcha_provider(string $provider, array $pcfg, array $post_data, string $client_ip, string $scene = '', string $action = 'aksrt-verify') { if (!in_array($provider, aksrt_captcha_providers(), true)) { return aksrt_captcha_error('aksrt_captcha_provider', __('未知的人机验证服务商', 'aksrt')); } if (!aksrt_captcha_provider_is_configured($provider, $pcfg)) { return aksrt_captcha_error('aksrt_captcha_config', __('验证码配置不完整', 'aksrt')); } switch ($provider) { case 'tencent': return aksrt_verify_tencent($pcfg, $post_data, $client_ip); case 'cloudflare': return aksrt_verify_cloudflare($pcfg, $post_data, $client_ip, $scene, $action); case 'geetest': return aksrt_verify_geetest($pcfg, $post_data); case 'aliyun': return aksrt_verify_aliyun($pcfg, $post_data); } return aksrt_captcha_error('aksrt_captcha_provider', __('未知的人机验证服务商', 'aksrt')); } /** * 腾讯云 Captcha 2.0(DescribeCaptchaResult + TC3-HMAC-SHA256)。 * * @return true|\WP_Error */ 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_captcha_error('aksrt_captcha_missing', __('缺少验证参数', 'aksrt')); } if (str_starts_with($ticket, 'trerror_')) { return aksrt_captcha_error('aksrt_captcha_disaster_ticket', __('验证码 SDK 异常,请刷新页面重试(容灾票据)', 'aksrt')); } $secret_id = (string) ($pcfg['secret_id'] ?? ''); $secret_key = (string) ($pcfg['secret_key'] ?? ''); if ($secret_id === '' || $secret_key === '') { return aksrt_captcha_error('aksrt_captcha_config', __('验证码配置不完整:缺少云 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); $resp = wp_remote_post('https://captcha.tencentcloudapi.com/', [ 'timeout' => 5, 'headers' => $headers, 'body' => $payload, ]); $body = aksrt_captcha_remote_json('tencent', $resp); if (is_wp_error($body)) { return $body; } if (isset($body['Response']['Error'])) { $err = $body['Response']['Error']; aksrt_captcha_log('tencent', ($err['Code'] ?? 'unknown') . ': ' . ($err['Message'] ?? '')); return aksrt_captcha_error('aksrt_captcha_provider_error', __('人机验证服务暂不可用,请稍后重试', 'aksrt')); } $resp_data = $body['Response'] ?? []; $captcha_code = (int) ($resp_data['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 = (string) ($resp_data['CaptchaMsg'] ?? ($messages[$captcha_code] ?? '未知错误')); aksrt_captcha_log('tencent', $msg . " (CaptchaCode: {$captcha_code})"); return aksrt_captcha_error('aksrt_captcha_rejected', __('人机验证未通过,请重试', 'aksrt')); } /** * Cloudflare Turnstile siteverify。校验 action 与 cdata(场景)绑定,防 token 转用。 * * @return true|\WP_Error */ function aksrt_verify_cloudflare(array $pcfg, array $post_data, string $client_ip, string $scene = '', string $action = 'aksrt-verify') { $token = (string) ($post_data['cf_turnstile_response'] ?? ''); if ($token === '' || strlen($token) < 10) { return aksrt_captcha_error('aksrt_captcha_missing', __('缺少验证参数', 'aksrt')); } $resp = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ 'timeout' => 5, 'body' => [ 'secret' => (string) ($pcfg['secret_key'] ?? ''), 'response' => $token, 'remoteip' => $client_ip, ], ]); $body = aksrt_captcha_remote_json('cloudflare', $resp); if (is_wp_error($body)) { return $body; } if (empty($body['success'])) { aksrt_captcha_log('cloudflare', 'siteverify failed: ' . implode(', ', (array) ($body['error-codes'] ?? []))); return aksrt_captcha_error('aksrt_captcha_rejected', __('人机验证未通过,请重试', 'aksrt')); } if (!array_key_exists('action', $body) || (string) $body['action'] !== $action) { aksrt_captcha_log('cloudflare', 'action mismatch: ' . (string) ($body['action'] ?? '') . ' != ' . $action); return aksrt_captcha_error('aksrt_captcha_action', __('人机验证未通过,请重试', 'aksrt')); } if (!array_key_exists('cdata', $body) || (string) $body['cdata'] !== $scene) { aksrt_captcha_log('cloudflare', 'cdata mismatch: ' . (string) ($body['cdata'] ?? '') . ' != ' . $scene); return aksrt_captcha_error('aksrt_captcha_cdata', __('人机验证未通过,请重试', 'aksrt')); } $expected_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)); $response_host = strtolower(trim((string) ($body['hostname'] ?? ''))); if ($expected_host === '' || $response_host === '' || !hash_equals($expected_host, $response_host)) { aksrt_captcha_log('cloudflare', 'hostname mismatch: ' . ($response_host ?: '') . ' != ' . ($expected_host ?: '')); return aksrt_captcha_error('aksrt_captcha_hostname', __('人机验证未通过,请重试', 'aksrt')); } return true; } /** * 极验 GeeTest v4 validate。 * * @return true|\WP_Error */ 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_error('aksrt_captcha_missing', __('缺少验证参数', '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' => 5, '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, ], ]); $body = aksrt_captcha_remote_json('geetest', $resp); if (is_wp_error($body)) { return $body; } if (($body['result'] ?? '') === 'success') { return true; } aksrt_captcha_log('geetest', 'validate failed: ' . (string) ($body['reason'] ?? ($body['msg'] ?? 'unknown'))); return aksrt_captcha_error('aksrt_captcha_rejected', __('人机验证未通过,请重试', 'aksrt')); } /** * 阿里云 CAPTCHA 2.0 V3 (VerifyIntelligentCaptcha)。 * * @return true|\WP_Error */ function aksrt_verify_aliyun(array $pcfg, array $post_data) { $captcha_verify_param = (string) ($post_data['aliyun_captcha_verify_param'] ?? ''); if ($captcha_verify_param === '') { return aksrt_captcha_error('aksrt_captcha_missing', __('缺少验证参数 (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' => wp_generate_uuid4(), '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' => 5, 'body' => $params, ]); $body = aksrt_captcha_remote_json('aliyun', $resp); if (is_wp_error($body)) { return $body; } 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'] ?? ''); $detail = $code_messages[$verify_code] ?? '未知错误 (' . ($verify_code ?: 'N/A') . ')'; aksrt_captcha_log('aliyun', 'verify rejected: ' . $detail); return aksrt_captcha_error('aksrt_captcha_rejected', __('人机验证未通过,请重试', 'aksrt')); } aksrt_captcha_log('aliyun', 'api failed: ' . (string) ($body['Message'] ?? 'unknown')); return aksrt_captcha_error('aksrt_captcha_provider_error', __('人机验证服务暂不可用,请稍后重试', 'aksrt')); } // ============================================================ // 前端渲染(挂载点 + data 配置,运行时在 public bundle 的 captcha-front.js) // ============================================================ /** * 渲染人机验证挂载点。未启用场景直接返回。 * * 挂载点自带 JSON 配置,由 captcha-front.js(public bundle)扫描激活。 */ 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 '
'; echo esc_html(sprintf( /* translators: %s: captcha provider key */ __('人机验证服务商「%s」已启用但凭据未配齐,当前场景将拒绝提交。请到「功能&权限 → 人机验证」补全配置。', 'aksrt'), $provider )); echo '
'; } 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, 'challenge_url' => rest_url('aksrt/v1/captcha/challenge'), 'exchange_url' => rest_url('aksrt/v1/captcha/exchange'), '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'] ?? ''), ], ]; ?>