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
@@ -0,0 +1,181 @@
<?php
/**
* Executable captcha configuration and fail-closed contract harness.
*/
declare(strict_types=1);
namespace {
define('ABSPATH', __DIR__ . '/');
define('MINUTE_IN_SECONDS', 60);
define('MONTH_IN_SECONDS', 2592000);
define('ARRAY_A', 'ARRAY_A');
$GLOBALS['aksrt_test_options'] = [];
$GLOBALS['wp_cache'] = [];
final class HarnessWpdb {
public string $options = 'wp_options';
public function prepare(string $query, ...$args): array { return [$query, $args]; }
public function esc_like(string $value): string { return $value; }
public function get_results($prepared, $format): array { return []; }
public function query($prepared): int {
[, $args] = $prepared;
[$key, $serialized] = $args;
if (!array_key_exists($key, $GLOBALS['aksrt_test_options']) || serialize($GLOBALS['aksrt_test_options'][$key]) !== $serialized) return 0;
unset($GLOBALS['aksrt_test_options'][$key]);
return 1;
}
}
$GLOBALS['wpdb'] = new HarnessWpdb();
final class WP_Error {
public function __construct(private string $code, private string $message, private array $data = []) {}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
}
function is_wp_error($value): bool { return $value instanceof WP_Error; }
function __(string $text, string $domain = ''): string { return $text; }
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? ''; }
function sanitize_text_field($value): string { return trim(strip_tags((string) $value)); }
function sanitize_textarea_field($value): string { return sanitize_text_field($value); }
function wp_kses_post($value): string { return strip_tags((string) $value, '<b><strong><em><i><a><p><br>'); }
function esc_url_raw($value): string { return trim((string) $value); }
function wp_unslash($value) { return $value; }
function get_option(string $key, $default = false) { return array_key_exists($key, $GLOBALS['aksrt_test_options']) ? $GLOBALS['aksrt_test_options'][$key] : $default; }
function update_option(string $key, $value): bool { $GLOBALS['aksrt_test_options'][$key] = $value; return true; }
function add_option(string $key, $value, string $deprecated = '', bool $autoload = true): bool {
if (array_key_exists($key, $GLOBALS['aksrt_test_options'])) return false;
$GLOBALS['aksrt_test_options'][$key] = $value;
return true;
}
function delete_option(string $key): bool {
if (!array_key_exists($key, $GLOBALS['aksrt_test_options'])) return false;
unset($GLOBALS['aksrt_test_options'][$key]);
return true;
}
function maybe_serialize($value): string { return serialize($value); }
function maybe_unserialize($value) { $decoded = @unserialize((string) $value); return $decoded === false && $value !== serialize(false) ? $value : $decoded; }
function wp_cache_delete(string $key, string $group = ''): void { unset($GLOBALS['wp_cache'][$group][$key]); }
function wp_salt(string $scheme = 'auth'): string { return 'captcha-harness-' . $scheme; }
function is_ssl(): bool { return true; }
function wp_rand(int $min, int $max): int { return $max; }
function add_action(...$args): void {}
function add_filter(...$args): void {}
function current_user_can(string $capability): bool { return false; }
function get_transient(string $key) { return false; }
function set_transient(string $key, $value, int $ttl): bool { return true; }
function get_current_user_id(): int { return 1; }
function wp_parse_url(string $url, int $component = -1) { return parse_url($url, $component); }
function home_url(string $path = ''): string { return 'https://example.test/' . ltrim($path, '/'); }
function wp_json_encode($value): string { return json_encode($value, JSON_UNESCAPED_SLASHES); }
function wp_generate_uuid4(): string { return '00000000-0000-4000-8000-000000000000'; }
function wp_remote_post(string $url, array $args = []) { return new WP_Error('unexpected_http', 'unexpected HTTP request'); }
function wp_remote_retrieve_response_code($response): int { return 500; }
function wp_remote_retrieve_body($response): string { return ''; }
function esc_html($value): string { return (string) $value; }
function wp_unique_id(string $prefix = ''): string { return $prefix . '1'; }
}
namespace AKSRT\Settings {
final class Settings {
public static function flush_settings_cache(): void {}
public static function sync_native_site_icon(string $value): void {}
}
}
namespace {
require dirname(__DIR__, 2) . '/inc/modules/captcha.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPermissions.php';
require dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php';
function check(bool $condition, string $message): void {
if (!$condition) {
throw new RuntimeException($message);
}
}
check(aksrt_captcha_default_config()['enabled_scenarios'] === [], 'captcha scenarios are not opt-in');
check(!in_array('domain', array_keys(aksrt_captcha_provider_defaults('cloudflare')), true), 'Cloudflare domain remains allowed');
$GLOBALS['aksrt_test_options']['aksrt_captcha_config'] = [
'enabled' => true,
'active_provider' => 'cloudflare',
'enabled_scenarios' => ['login'],
'cloudflare' => [
'site_key' => 'site-key',
'secret_key' => 'stored-secret',
'theme' => 'dark',
'size' => 'compact',
'domain' => 'should-be-dropped.test',
],
];
aksrt_captcha_config_reset();
$provider = \AKSRT\Admin\SchemaProvider::get_instance();
$read = $provider->read_settings('permissions', 'login');
check(($read['cloudflare']['secret_key'] ?? '') === '***', 'nested secret was not masked');
check(!isset($read['cloudflare']['domain']), 'unknown provider key was exposed');
$provider->write_settings('permissions', [
'cloudflare' => [
'site_key' => 'new-site-key',
'secret_key' => '***',
'theme' => 'light',
'size' => 'normal',
'domain' => 'ignored.test',
],
], 'login');
$stored = $GLOBALS['aksrt_test_options']['aksrt_captcha_config']['cloudflare'];
check($stored['secret_key'] === 'stored-secret', 'masked secret did not preserve stored value');
check($stored['site_key'] === 'new-site-key', 'public provider field was not updated');
check(!isset($stored['domain']), 'unknown provider key was persisted');
$provider->write_settings('permissions', [
'cloudflare' => ['secret_key' => ''],
], 'login');
check($GLOBALS['aksrt_test_options']['aksrt_captcha_config']['cloudflare']['secret_key'] === 'stored-secret', 'empty secret did not preserve stored value');
$provider->write_settings('permissions', [
'__clear_secrets' => ['cloudflare.secret_key'],
], 'login');
check($GLOBALS['aksrt_test_options']['aksrt_captcha_config']['cloudflare']['secret_key'] === '', 'explicit nested secret clear failed');
$GLOBALS['aksrt_test_options']['aksrt_captcha_config'] = [
'enabled' => true,
'active_provider' => '',
'enabled_scenarios' => ['login'],
];
aksrt_captcha_config_reset();
$failed_closed = aksrt_require_captcha('login', []);
check(is_wp_error($failed_closed), 'enabled scene with missing provider failed open');
check($failed_closed->get_error_code() === 'aksrt_captcha_config', 'fail-closed error code mismatch');
$GLOBALS['aksrt_test_options']['aksrt_captcha_config'] = [
'enabled' => true,
'active_provider' => 'cloudflare',
'enabled_scenarios' => ['login', 'comment'],
'cloudflare' => ['site_key' => 'site', 'secret_key' => 'secret', 'theme' => 'auto', 'size' => 'normal'],
];
aksrt_captcha_config_reset();
$_COOKIE[AKSRT_CAPTCHA_BROWSER_COOKIE] = str_repeat('A', 43);
$challenge = aksrt_captcha_issue_challenge('login', 'cloudflare');
check(aksrt_captcha_validate_challenge($challenge, 'login', 'cloudflare', false), 'valid scene challenge failed');
check(!aksrt_captcha_validate_challenge($challenge, 'comment', 'cloudflare', false), 'cross-scene challenge accepted');
check(!aksrt_captcha_validate_challenge($challenge, 'login', 'geetest', false), 'cross-provider challenge accepted');
$_COOKIE[AKSRT_CAPTCHA_BROWSER_COOKIE] = str_repeat('B', 43);
check(!aksrt_captcha_validate_challenge($challenge, 'login', 'cloudflare', false), 'cross-browser challenge accepted');
$_COOKIE[AKSRT_CAPTCHA_BROWSER_COOKIE] = str_repeat('A', 43);
check(aksrt_captcha_validate_challenge($challenge, 'login', 'cloudflare', true), 'valid challenge consume failed');
check(!aksrt_captcha_validate_challenge($challenge, 'login', 'cloudflare', true), 'challenge replay accepted');
$proof = aksrt_captcha_issue_proof('login', 'cloudflare');
check(!aksrt_captcha_consume_proof($proof, 'comment', 'cloudflare'), 'cross-scene proof accepted');
check(aksrt_captcha_consume_proof($proof, 'login', 'cloudflare'), 'valid proof consume failed');
check(!aksrt_captcha_consume_proof($proof, 'login', 'cloudflare'), 'proof replay accepted');
$headers = aksrt_tencent_tc3_sign('id', 'key', 'captcha', 1700000000, '2023-11-14', 'DescribeCaptchaResult', '2019-07-22', '{}');
check(str_starts_with($headers['Authorization'], 'TC3-HMAC-SHA256 Credential=id/2023-11-14/captcha/tc3_request'), 'TC3 authorization scope mismatch');
echo "Captcha security harness passed.\n";
}