feat(captcha): 打通人机验证 11 个场景的前后端完整链路

- 统一验证入口 aksrt_require_captcha(),返回 true|WP_Error,启用场景配置缺失时 fail-closed 拒绝
- 前端公共 API 重构为 window.AKSRTCaptcha(verify/collect/reset/run),单飞锁防并发重复验证,一次性 proof 绑定浏览器 cookie+场景+服务商+主机名
- challenge/exchange REST 端点:challenge 原子占用防票据放大厂商调用,增加频率限制
- 新增 OAuth bind pass:服务端签发 5 分钟一次性令牌,绑定用户+服务商+浏览器,微信扫码延迟消费
- 覆盖场景:评论、登录、注册、绑定第三方、绑定手机、找回密码、修改密码、更改邮箱、资料下载、签到、前台投稿
- 移除 Service 层重复验证与 _auth_code_verified/_defer_login 等客户端可伪造标志,删除下载共享 pass 死代码
- 修复腾讯 TC3 签名 scope、Cloudflare action/cdata/hostname 严格校验、阿里云验签错误码映射
- 后台 Schema 保存对 secret 字段做掩码/保留合并;导入导出过滤 aksrt_captcha_config
- 重建 public bundle 与 admin SPA;新增 captcha 运行时/安全/业务契约测试与 harness
This commit is contained in:
2026-09-02 13:03:24 +08:00
parent d2e95acd75
commit ff4ec1eb43
45 changed files with 2694 additions and 1435 deletions
+467 -158
View File
@@ -6,7 +6,7 @@
* 闭环契约:
* aksrt_get_captcha_config() -> aksrt_is_captcha_enabled_for(scene)
* -> aksrt_render_captcha(scene) -> [data-aksrt-captcha-config] 挂载点
* -> captcha-front.js(public bundle)-> 客户端 AKSRTCaptchaState + token
* -> captcha-front.js(public bundle)-> 客户端 AKSRTCaptcha + token
* -> 业务入口 aksrt_require_captcha(scene, $payload) -> proceed/fail。
*
* 仅支持云厂商:tencent / aliyun / cloudflare / geetest。
@@ -17,11 +17,345 @@
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 ['tencent', 'aliyun', 'cloudflare', 'geetest'];
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;
}
/**
@@ -47,17 +381,15 @@ function aksrt_captcha_scenes(): array {
* 默认配置结构。
*/
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' => [],
$config = [
'enabled' => false,
'active_provider' => '',
'enabled_scenarios' => [],
];
foreach (aksrt_captcha_providers() as $provider) {
$config[$provider] = aksrt_captcha_provider_defaults($provider);
}
return $config;
}
/**
@@ -107,7 +439,7 @@ function aksrt_get_captcha_config(): array {
'enabled_scenarios' => $enabled,
];
foreach (aksrt_captcha_providers() as $p) {
$result[$p] = isset($cfg[$p]) && is_array($cfg[$p]) ? $cfg[$p] : [];
$result[$p] = aksrt_captcha_sanitize_provider_config($p, $cfg[$p] ?? []);
}
$cache = $result;
@@ -125,13 +457,8 @@ function aksrt_captcha_config_reset(): void {
* 各服务商必填字段。
*/
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] ?? [];
$fields = aksrt_captcha_provider_metadata()[$provider]['fields'] ?? [];
return array_values(array_keys(array_filter($fields, static fn(array $meta): bool => !empty($meta['required']))));
}
/**
@@ -166,13 +493,7 @@ function aksrt_is_captcha_enabled_for($scene): bool {
}
$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)) {
if (empty($cfg['enabled'])) {
return false;
}
@@ -180,46 +501,28 @@ function aksrt_is_captcha_enabled_for($scene): bool {
}
/**
* 场景验证通过后的服务端放行时长(秒),0 表示不启用。
*
* 下载页多个卡片共享一次验证结果(前端会把 token 同步到兄弟挂载点),
* 而厂商 token 是一次性的,因此验证通过后由服务端记录短时放行,
* 后续下载请求直接命中放行,避免票据被判定重复使用。
*/
function aksrt_captcha_pass_ttl(string $scene): int {
return $scene === 'download' ? 10 * MINUTE_IN_SECONDS : 0;
}
/**
* 服务端放行记录的 transient key:场景 + 用户 + IP + UA。
*/
function aksrt_captcha_pass_key(string $scene): string {
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
return 'aksrt_captcha_pass_' . md5($scene . '|' . get_current_user_id() . '|' . aksrt_get_client_ip() . '|' . md5($ua));
}
/**
* 业务入口统一调用:未启用放行;启用则校验。
* 业务入口统一调用:仅显式关闭或未勾选场景放行;启用场景 fail-closed。
*
* @param string $scene 场景
* @param array $payload 客户端提交的验证字段
* @return true|string 成功返回 true,失败返回错误信息字符串
* @return true|\WP_Error 成功返回 true,失败返回 WP_Error
*/
function aksrt_require_captcha(string $scene, array $payload = []) {
if (!aksrt_is_captcha_enabled_for($scene)) {
return true;
}
$ttl = aksrt_captcha_pass_ttl($scene);
if ($ttl > 0 && get_transient(aksrt_captcha_pass_key($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'));
}
$result = aksrt_verify_captcha($payload, $scene);
if ($result === true && $ttl > 0) {
set_transient(aksrt_captcha_pass_key($scene), time(), $ttl);
$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 $result;
return true;
}
/**
@@ -241,7 +544,7 @@ function aksrt_get_captcha_status_summary(): array {
}
/**
* 凭据未配齐时的后台警告(前台保持放行,验证码不是准入门槛)。
* 凭据未配齐时的后台警告(已启用场景会 fail-closed,必须补全配置)。
*/
add_action('admin_notices', static function () {
if (!current_user_can('manage_options')) {
@@ -257,7 +560,7 @@ add_action('admin_notices', static function () {
}
echo '<div class="notice notice-error"><p>' . esc_html(sprintf(
/* translators: %s: captcha provider key */
__('人机验证服务商「%s」已启用但凭据未配齐,前台人机验证未生效。请到「功能&权限 → 人机验证」补全配置。', 'aksrt'),
__('人机验证服务商「%s」已启用但凭据未配齐,已启用场景将拒绝请求。请到「功能&权限 → 人机验证」补全配置。', 'aksrt'),
$provider
)) . '</p></div>';
});
@@ -321,7 +624,7 @@ function aksrt_tencent_tc3_sign($secret_id, $secret_key, $service, $timestamp, $
$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_signing, true);
$secret_signing = hash_hmac('sha256', 'tc3_request', $secret_service, true);
$signature = hash_hmac('sha256', $string_to_sign, $secret_signing);
$authorization = 'TC3-HMAC-SHA256 '
@@ -364,67 +667,79 @@ 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.
*
* @param array $post_data 客户端字段(captcha_verified + 厂商 token)
* @param string $scene 触发场景(用于 Cloudflare cdata 绑定校验)
* @return true|string 成功返回 true,失败返回错误信息
* @return array|\WP_Error
*/
function aksrt_verify_captcha($post_data, string $scene = '') {
$cfg = aksrt_get_captcha_config();
if (empty($cfg['enabled']) || $cfg['active_provider'] === '') {
return true;
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;
}
$provider = $cfg['active_provider'];
$pcfg = $cfg[$provider] ?? [];
$client_ip = aksrt_get_client_ip();
// 客户端验证字段(前端写入 captcha_verified=1 表示用户已完成交互)
if (($post_data['captcha_verified'] ?? '') !== '1') {
return __('人机验证失败', 'aksrt');
/**
* 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':
$result = aksrt_verify_tencent($pcfg, $post_data, $client_ip);
break;
return aksrt_verify_tencent($pcfg, $post_data, $client_ip);
case 'cloudflare':
$result = aksrt_verify_cloudflare($pcfg, $post_data, $client_ip, $scene);
break;
return aksrt_verify_cloudflare($pcfg, $post_data, $client_ip, $scene, $action);
case 'geetest':
$result = aksrt_verify_geetest($pcfg, $post_data);
break;
return aksrt_verify_geetest($pcfg, $post_data);
case 'aliyun':
$result = aksrt_verify_aliyun($pcfg, $post_data);
break;
default:
return true;
return aksrt_verify_aliyun($pcfg, $post_data);
}
return $result === true ? true : ($result ?: __('人机验证失败', 'aksrt'));
return aksrt_captcha_error('aksrt_captcha_provider', __('未知的人机验证服务商', 'aksrt'));
}
/**
* 腾讯云 Captcha 2.0(DescribeCaptchaResult + TC3-HMAC-SHA256)。
*
* @return true|string
* @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');
return aksrt_captcha_error('aksrt_captcha_missing', __('缺少验证参数', 'aksrt'));
}
if (str_starts_with($ticket, 'trerror_')) {
return __('验证码 SDK 异常,请刷新页面重试(容灾票据)', 'aksrt');
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 __('验证码配置不完整:缺少云 API 密钥 (SecretId/SecretKey)', 'aksrt');
return aksrt_captcha_error('aksrt_captcha_config', __('验证码配置不完整:缺少云 API 密钥 (SecretId/SecretKey)', 'aksrt'));
}
$timestamp = time();
@@ -445,20 +760,14 @@ function aksrt_verify_tencent(array $pcfg, array $post_data, string $client_ip)
'headers' => $headers,
'body' => $payload,
]);
if (is_wp_error($resp)) {
aksrt_captcha_log('tencent', $resp->get_error_message());
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
}
$body = json_decode(wp_remote_retrieve_body($resp), true);
if (!is_array($body)) {
aksrt_captcha_log('tencent', 'unparsable response');
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
$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');
return aksrt_captcha_error('aksrt_captcha_provider_error', __('人机验证服务暂不可用,请稍后重试', 'aksrt'));
}
$resp_data = $body['Response'] ?? [];
@@ -477,60 +786,62 @@ function aksrt_verify_tencent(array $pcfg, array $post_data, string $client_ip)
21 => '票据校验异常(风控拦截)',
100 => '参数校验失败(AppId/SecretKey/Ticket 不匹配)',
];
$msg = $resp_data['CaptchaMsg'] ?? ($messages[$captcha_code] ?? '未知错误');
return '腾讯验证失败: ' . $msg . " (CaptchaCode: {$captcha_code})";
$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|string
* @return true|\WP_Error
*/
function aksrt_verify_cloudflare(array $pcfg, array $post_data, string $client_ip, string $scene = '') {
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');
return aksrt_captcha_error('aksrt_captcha_missing', __('缺少验证参数', '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' => 5,
'body' => $attempt,
]);
if (is_wp_error($resp)) {
continue;
}
$body = json_decode(wp_remote_retrieve_body($resp), true);
if (!empty($body['success'])) {
if (isset($body['action']) && (string) $body['action'] !== 'aksrt-verify') {
aksrt_captcha_log('cloudflare', 'action mismatch: ' . (string) $body['action']);
return __('人机验证未通过,请重试', 'aksrt');
}
if ($scene !== '' && isset($body['cdata']) && (string) $body['cdata'] !== $scene) {
aksrt_captcha_log('cloudflare', 'cdata mismatch: ' . (string) $body['cdata'] . ' != ' . $scene);
return __('人机验证未通过,请重试', 'aksrt');
}
return true;
}
$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 (is_array($body) && isset($body['error-codes'])) {
aksrt_captcha_log('cloudflare', 'siteverify failed: ' . implode(', ', (array) $body['error-codes']));
if (!array_key_exists('action', $body) || (string) $body['action'] !== $action) {
aksrt_captcha_log('cloudflare', 'action mismatch: ' . (string) ($body['action'] ?? '<missing>') . ' != ' . $action);
return aksrt_captcha_error('aksrt_captcha_action', __('人机验证未通过,请重试', 'aksrt'));
}
return __('人机验证未通过,请重试', 'aksrt');
if (!array_key_exists('cdata', $body) || (string) $body['cdata'] !== $scene) {
aksrt_captcha_log('cloudflare', 'cdata mismatch: ' . (string) ($body['cdata'] ?? '<missing>') . ' != ' . $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 ?: '<missing>') . ' != ' . ($expected_host ?: '<missing>'));
return aksrt_captcha_error('aksrt_captcha_hostname', __('人机验证未通过,请重试', 'aksrt'));
}
return true;
}
/**
* 极验 GeeTest v4 validate。
*
* @return true|string
* @return true|\WP_Error
*/
function aksrt_verify_geetest(array $pcfg, array $post_data) {
$lot_number = (string) ($post_data['geetest_lot_number'] ?? '');
@@ -538,7 +849,7 @@ function aksrt_verify_geetest(array $pcfg, array $post_data) {
$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');
return aksrt_captcha_error('aksrt_captcha_missing', __('缺少验证参数', 'aksrt'));
}
$captcha_key = (string) ($pcfg['captcha_key'] ?? '');
@@ -556,28 +867,26 @@ function aksrt_verify_geetest(array $pcfg, array $post_data) {
],
]);
if (is_wp_error($resp)) {
aksrt_captcha_log('geetest', $resp->get_error_message());
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
$body = aksrt_captcha_remote_json('geetest', $resp);
if (is_wp_error($body)) {
return $body;
}
$body = json_decode(wp_remote_retrieve_body($resp), true);
if (is_array($body) && !empty($body['result']) && $body['result'] === 'success') {
if (($body['result'] ?? '') === 'success') {
return true;
}
aksrt_captcha_log('geetest', 'validate failed: ' . (string) ($body['reason'] ?? ($body['msg'] ?? 'unknown')));
return __('人机验证未通过,请重试', 'aksrt');
return aksrt_captcha_error('aksrt_captcha_rejected', __('人机验证未通过,请重试', 'aksrt'));
}
/**
* 阿里云 CAPTCHA 2.0 V3 (VerifyIntelligentCaptcha)。
*
* @return true|string
* @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 __('缺少验证参数 (captchaVerifyParam)', 'aksrt');
return aksrt_captcha_error('aksrt_captcha_missing', __('缺少验证参数 (captchaVerifyParam)', 'aksrt'));
}
$region = (string) ($pcfg['region'] ?? 'cn');
@@ -606,15 +915,9 @@ function aksrt_verify_aliyun(array $pcfg, array $post_data) {
'body' => $params,
]);
if (is_wp_error($resp)) {
aksrt_captcha_log('aliyun', $resp->get_error_message());
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
}
$body = json_decode(wp_remote_retrieve_body($resp), true);
if (!is_array($body)) {
aksrt_captcha_log('aliyun', 'unparsable response');
return __('人机验证服务暂不可用,请稍后重试', 'aksrt');
$body = aksrt_captcha_remote_json('aliyun', $resp);
if (is_wp_error($body)) {
return $body;
}
if (!empty($body['Success'])) {
$verify_result = $body['Result']['VerifyResult'] ?? false;
@@ -640,11 +943,13 @@ function aksrt_verify_aliyun(array $pcfg, array $post_data) {
'F020' => 'CaptchaVerifyParam 与场景 ID 不匹配',
];
$verify_code = (string) ($body['Result']['VerifyCode'] ?? '');
return '阿里云 V3 验证失败: ' . ($code_messages[$verify_code] ?? '未知错误 (' . ($verify_code ?: 'N/A') . ')');
$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');
return aksrt_captcha_error('aksrt_captcha_provider_error', __('人机验证服务暂不可用,请稍后重试', 'aksrt'));
}
// ============================================================
@@ -671,7 +976,7 @@ function aksrt_render_captcha($scene = 'comment') {
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'),
__('人机验证服务商「%s」已启用但凭据未配齐,当前场景将拒绝提交。请到「功能&权限 → 人机验证」补全配置。', 'aksrt'),
$provider
));
echo '</div>';
@@ -686,7 +991,9 @@ function aksrt_render_captcha($scene = 'comment') {
'provider' => $provider,
'scene' => $scene,
'uid' => $uid,
'vid' => $vid,
'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'] ?? ''),
@@ -706,7 +1013,9 @@ function aksrt_render_captcha($scene = 'comment') {
?>
<div class="aksrt-captcha-wrap" data-provider="<?php echo esc_attr($provider); ?>" data-scene="<?php echo esc_attr($scene); ?>" data-aksrt-captcha-config="<?php echo esc_attr(wp_json_encode($js_config, JSON_UNESCAPED_UNICODE)); ?>">
<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">
<input type="hidden" name="captcha_verified" id="<?php echo esc_attr($vid); ?>" value="0">
<input type="hidden" name="aksrt_captcha_challenge" value="">
<input type="hidden" name="aksrt_captcha_proof" value="">
</div>
<?php
}