Files
wordpress-theme-aksrt/inc/modules/captcha.php
T
kate522 20387fdb06 feat(affiliate): attribution modes, settlement hardening, trusted proxies, QR and poster
- 归因模式可配置(first_touch/last_touch)、停佣开关、懒结算与佣金基数降级复查
- aksrt_get_client_ip 接入 aksrt_trusted_proxies 信任代理名单,防 CDN 头伪造
- 收益页 Canvas 二维码 + 750x1000 推广海报(本地打包 qrcode.min.js,无 CDN)
- 契约测试固化归因/海报/信任代理链路
2026-09-21 20:53:22 +08:00

1076 lines
39 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* AKSRT 人机验证(Cloud Captcha)核心。
*
* 唯一配置源:option `aksrt_captcha_config`。
* 闭环契约:
* aksrt_get_captcha_config() -> 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<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);
}
/**
* 获取人机验证完整配置(规范化,不合并历史脏键)。
*
* 结果在同一请求内缓存;后台保存后调用 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 '<div class="notice notice-error"><p>' . esc_html(sprintf(
/* translators: %s: captcha provider key */
__('人机验证服务商「%s」已启用但凭据未配齐,已启用场景将拒绝请求。请到「功能&权限 → 人机验证」补全配置。', 'aksrt'),
$provider
)) . '</p></div>';
});
// ============================================================
// 客户端真实 IP(穿透 CDN / 代理)
// ============================================================
function aksrt_get_client_ip(): string {
$remote = isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '';
// 信任代理名单:过滤返回非空 CIDR/IP 列表时,仅当 REMOTE_ADDR 命中名单
// 才采纳 CDN / 反代头;否则客户端可伪造这些头绕过 IP 维度的风控。
$trusted = apply_filters('aksrt_trusted_proxies', []);
if (is_array($trusted) && $trusted !== [] && $remote !== '' && !aksrt_ip_in_list($remote, $trusted)) {
return $remote;
}
// 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';
}
/**
* IP 是否命中精确 IP 或 CIDR 名单(IPv4 与 IPv6)。
*
* @param string $ip 待判定 IP。
* @param array $list 精确 IP / CIDR 段列表。
*/
function aksrt_ip_in_list(string $ip, array $list): bool {
foreach ($list as $entry) {
$entry = trim((string) $entry);
if ($entry === '') {
continue;
}
if (!str_contains($entry, '/')) {
if (strcasecmp($entry, $ip) === 0) {
return true;
}
continue;
}
[$subnet, $prefix] = explode('/', $entry, 2);
$prefix = (int) $prefix;
$ip_binary = @inet_pton($ip);
$subnet_binary = @inet_pton($subnet);
if ($ip_binary === false || $subnet_binary === false || strlen($ip_binary) !== strlen($subnet_binary)) {
continue;
}
$max_bits = strlen($ip_binary) * 8;
if ($prefix < 0 || $prefix > $max_bits) {
continue;
}
$full_bytes = intdiv($prefix, 8);
$remain_bits = $prefix % 8;
if ($full_bytes > 0 && substr($ip_binary, 0, $full_bytes) !== substr($subnet_binary, 0, $full_bytes)) {
continue;
}
if ($remain_bits > 0) {
$mask = chr((0xFF << (8 - $remain_bits)) & 0xFF);
if ((substr($ip_binary, $full_bytes, 1) & $mask) !== (substr($subnet_binary, $full_bytes, 1) & $mask)) {
continue;
}
}
return true;
}
return false;
}
// 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'] ?? '<missing>') . ' != ' . $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'] ?? '<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|\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 '<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,
'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'] ?? ''),
],
];
?>
<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="aksrt_captcha_challenge" value="">
<input type="hidden" name="aksrt_captcha_proof" value="">
</div>
<?php
}