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
@@ -115,16 +115,20 @@ test('legacy authentication templates and scripts are removed', () => {
test('unified handlers enforce nonce, captcha, rate limits, configured channels, and generic errors', () => {
const handlers = read('src/Admin/UserControllerAuthPhoneOperations.php');
const service = read('src/Auth/Service.php');
const serviceA = read('src/Auth/ServicePart1a.php');
const serviceB = read('src/Auth/ServicePart1b.php');
assert.match(handlers, /auth_verify_rest_nonce/);
assert.match(handlers, /aksrt_require_captcha/);
assert.match(handlers, /private function auth_require_captcha/);
assert.match(handlers, /get_error_code\(\)/);
assert.match(handlers, /get_error_message\(\)/);
assert.match(handlers, /auth_rate_limited/);
assert.match(handlers, /auth_channel_allowed/);
['auth_code', 'auth_login', 'auth_register', 'auth_reset_password'].forEach((method) => {
const start = handlers.indexOf(`public function ${method}(`);
const end = handlers.indexOf('\n\tpublic function ', start + 1);
const body = handlers.slice(start, end === -1 ? handlers.length : end);
assert.match(body, /aksrt_require_captcha/, method);
assert.match(body, /auth_require_captcha/, method);
});
assert.match(handlers, /passwordless_login_type/);
assert.match(handlers, /signup_verification_type/);
@@ -132,6 +136,10 @@ test('unified handlers enforce nonce, captcha, rate limits, configured channels,
assert.match(handlers, /账号或密码错误/);
assert.match(handlers, /账号或验证码错误/);
assert.match(service, /signin_phone_enabled/);
assert.doesNotMatch(service + serviceA + serviceB, /aksrt_require_captcha/);
assert.doesNotMatch(service + serviceA + serviceB, /_auth_code_verified|_defer_login/);
assert.doesNotMatch(service, /captcha_scene/);
assert.match(handlers, /public function user_bind_phone[\s\S]*?auth_require_captcha\('bind_phone'/);
assert.doesNotMatch(service, /login_by_phone_password|login_by_phone_code/);
});
@@ -0,0 +1,64 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
test('comment REST admission is request-local and preprocess keeps native captcha enforcement', () => {
const controller = read('src/Core/CommentActions.php');
const moderation = read('src/Auth/Moderation.php');
assert.match(controller, /aksrt_require_captcha\('comment', \$params\)/);
assert.match(controller, /begin_captcha_verified_comment_request\(\)[\s\S]*?try \{[\s\S]*?wp_new_comment[\s\S]*?finally \{[\s\S]*?end_captcha_verified_comment_request\(\)/);
assert.match(moderation, /private \$captcha_verified_comment_request_depth = 0/);
assert.match(moderation, /if \(\$this->captcha_verified_comment_request_depth === 0\) \{/);
assert.doesNotMatch(controller + moderation, /function_exists\('aksrt_require_captcha'\)/);
assert.doesNotMatch(controller + moderation, /\$_(?:POST|REQUEST)\[[^\]]*captcha[^\]]*verified|set_transient\([^\n]*comment/i);
});
test('captcha business failures preserve WP_Error codes and never stringify opaque results', () => {
const sources = [
read('src/Core/CommentActions.php'),
read('src/Auth/Moderation.php'),
read('src/Admin/UserControllerAuthPhoneOperations.php'),
read('inc/modules/user-parts/account-ajax.php'),
read('src/Core/FrontSubmit.php'),
].join('\n');
assert.match(sources, /get_error_code\(\)/);
assert.match(sources, /get_error_message\(\)/);
assert.doesNotMatch(sources, /\(string\)\s*\$(?:captcha|verify|result)/);
});
test('password and email account flows verify captcha once per distinct HTTP request', () => {
const account = read('inc/modules/user-parts/account-ajax.php');
const methodBody = (action, nextAction = '') => {
const start = account.indexOf(`add_action('${action}'`);
const end = nextAction ? account.indexOf(`add_action('${nextAction}'`, start + 1) : account.length;
return account.slice(start, end === -1 ? account.length : end);
};
const send = methodBody('wp_ajax_aksrt_send_email_code', 'wp_ajax_aksrt_change_password');
const password = methodBody('wp_ajax_aksrt_change_password', 'wp_ajax_aksrt_change_email');
const email = methodBody('wp_ajax_aksrt_change_email');
assert.equal((send.match(/aksrt_require_captcha\(/g) || []).length, 1);
assert.match(send, /aksrt_require_captcha\( \$scene,/);
assert.equal((password.match(/aksrt_require_captcha\(/g) || []).length, 1);
assert.match(password, /aksrt_require_captcha\( 'change_password'/);
assert.equal((email.match(/aksrt_require_captcha\(/g) || []).length, 1);
assert.match(email, /aksrt_require_captcha\( 'change_email'/);
});
test('legacy public SMS-code AJAX surface is removed while SMS service remains', () => {
const sms = read('src/Sms/SMS.php');
assert.doesNotMatch(sms, /aksrt_send_sms_code/);
assert.doesNotMatch(sms, /function ajax_send_code\s*\(/);
assert.match(sms, /public function send_code\(string \$phone\): array/);
assert.match(sms, /public function verify_code\(string \$phone, string \$code\): array/);
});
@@ -0,0 +1,109 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..', '..');
const runtime = fs.readFileSync(path.join(root, 'assets/js/captcha-front.js'), 'utf8');
function createInput(name, value = '') {
return { name, value, remove() {}, nodeType: 1 };
}
function createHarness() {
const fields = new Map();
const challenge = createInput('aksrt_captcha_challenge', 'challenge-token');
const proof = createInput('aksrt_captcha_proof', '');
const verified = createInput('captcha_verified', '0');
fields.set(challenge.name, challenge);
fields.set(proof.name, proof);
fields.set(verified.name, verified);
const container = {
nodeType: 1,
contains: () => true,
querySelector(selector) {
const match = selector.match(/input\[name="([^"]+)"\]/);
return match ? fields.get(match[1]) || null : null;
},
querySelectorAll(selector) {
const value = this.querySelector(selector);
return value ? [value] : [];
},
appendChild(field) { fields.set(field.name, field); },
};
const box = {
nodeType: 1,
parentElement: container,
closest: () => container,
contains: () => false,
querySelector: () => null,
appendChild() {},
innerHTML: '',
};
const mount = {
nodeType: 1,
dataset: {},
parentElement: container,
contains: () => true,
setAttribute() {},
};
const scripts = [];
const document = {
readyState: 'complete',
documentElement: { nodeType: 1 },
querySelectorAll(selector) { return selector === '[data-aksrt-captcha-config]' ? [mount] : []; },
querySelector: () => null,
getElementById(id) { return id === 'captcha-box' ? box : verified; },
createElement(tag) {
if (tag === 'input') return createInput('', '');
return { setAttribute() {}, addEventListener() {}, appendChild() {}, style: {}, dataset: {} };
},
addEventListener() {},
head: { appendChild(script) { scripts.push(script); } },
body: { appendChild() {} },
};
mount.getAttribute = () => JSON.stringify({
provider: 'tencent', scene: 'login', uid: 'captcha-box', vid: 'captcha-verified',
tc: { app_id: '1' }, challenge_url: '/challenge', exchange_url: '/exchange',
});
let providerCallback;
const window = {
fetch(url) {
if (url === '/challenge') return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, data: { challenge: 'challenge-token' } }) });
if (url === '/exchange') return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, data: { proof: 'proof-token' } }) });
throw new Error('unexpected URL ' + url);
},
TencentCaptcha: class {
constructor(_id, callback) { providerCallback = callback; }
show() { providerCallback({ ret: 0, ticket: 'ticket', randstr: 'rand' }); }
},
setTimeout,
clearTimeout,
Promise,
};
vm.runInNewContext(runtime, { window, document, Promise, MutationObserver: undefined, setTimeout, clearTimeout, console });
return { api: window.AKSRTCaptcha, container };
}
test('captcha run is single-flight until the owning business request resets', async () => {
const { api, container } = createHarness();
await Promise.resolve();
await Promise.resolve();
const first = api.run('login', container);
const second = api.run('login', container);
const firstPayload = await first;
assert.equal(firstPayload.aksrt_captcha_proof, 'proof-token');
let secondSettled = false;
second.then(() => { secondSettled = true; });
await Promise.resolve();
assert.equal(secondSettled, false, 'concurrent caller settled before the owner reset');
api.reset('login', container);
assert.equal(await second, null);
});
@@ -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";
}
@@ -105,6 +105,18 @@ test('public theme does not inject a global focus-visible ring', () => {
assert.doesNotMatch(publicMinCss, globalFocusRule);
});
test('non-draft front-submit updates require one server captcha admission', () => {
const controller = read('src/Core/FrontSubmit.php');
const start = controller.indexOf('public function ajax_update_post(): void');
const end = controller.indexOf('\n public function ajax_delete_draft', start);
const body = controller.slice(start, end);
assert.match(body, /\$action = \(\(\$data\['status'\] \?\? ''\) === 'draft'\) \? 'posts_draft' : 'posts_save';/);
assert.match(body, /if \(\$action !== 'posts_draft'\) \{\s*\$this->verify_captcha\(\);\s*\}/);
assert.match(controller, /get_error_code\(\)/);
assert.match(controller, /get_error_message\(\)/);
});
test('front submit TinyMCE status bar shows one live character counter', () => {
const editor = read('assets/js/front-submit-editor.js');
const submitCss = read('assets/css/frontend/pages/submit.css');
@@ -119,6 +119,20 @@ test('PHP and JavaScript OAuth schema field keys stay in parity', () => {
const jsKeys = [...js.matchAll(/\{ key: '([^']+)', label:/g)].map((match) => match[1])
assert.deepEqual([...new Set(phpKeys)].sort(), [...new Set(jsKeys)].sort())
})
test('OAuth bind starts require a one-time captcha bind pass', () => {
const oauth = read('src/Auth/OAuth.php')
const weixingzh = read('src/Auth/WeixinGzh.php')
const bindings = read('inc/helpers/user-center-renderers/settings-bindings.php')
assert.match(oauth, /\/oauth\/bind-pass/)
assert.match(oauth, /BIND_PASS_KIND = 'oauth_bind_pass'/)
assert.match(oauth, /OAuthTransactionStore::issue\(self::BIND_PASS_KIND/)
assert.match(oauth, /OAuthTransactionStore::claim\(\$token, self::BIND_PASS_KIND, true\)/)
assert.match(oauth, /validate_bind_pass\(\$bind_pass, \$provider, \$user_id, true\)/)
assert.match(oauth, /validate_bind_pass\(\$bind_pass, 'clogin_' \. \$type, get_current_user_id\(\), true\)/)
assert.match(weixingzh, /OAuth::validate_bind_pass\(\$bind_pass, 'weixingzh'/)
assert.match(bindings, /aksrt_render_captcha\('bind'\)/)
})
test('OAuth result handler maps safe errors and preserves unrelated URL state', () => {
const notices = []
let replaced = ''
@@ -14,7 +14,9 @@ define('ARRAY_A', 'ARRAY_A');
$GLOBALS['wp_options'] = [];
$GLOBALS['wp_cache'] = [];
$GLOBALS['captcha_enabled'] = true;
function aksrt_is_captcha_enabled_for(string $scene): bool { return $scene === 'bind' && !empty($GLOBALS['captcha_enabled']); }
function __(string $text, string $domain = ''): string { return $text; }
function sanitize_key(string $value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower($value)) ?? ''; }
function sanitize_text_field(string $value): string { return trim(strip_tags($value)); }
@@ -85,6 +87,7 @@ namespace {
require dirname(__DIR__, 2) . '/src/Auth/OAuthTransactionStore.php';
require dirname(__DIR__, 2) . '/src/Auth/OAuthUrlPolicy.php';
require dirname(__DIR__, 2) . '/src/Auth/OAuthProxyProtocol.php';
require dirname(__DIR__, 2) . '/src/Auth/OAuth.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaUsers.php';
require dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php';
@@ -102,6 +105,16 @@ namespace {
check(\AKSRT\Auth\OAuthTransactionStore::claim($token, 'direct', true) !== null, 'valid browser failed claim');
check(\AKSRT\Auth\OAuthTransactionStore::claim($token, 'direct', true) === null, 'transaction replay accepted');
$bindPass = \AKSRT\Auth\OAuthTransactionStore::issue('oauth_bind_pass', ['user_id' => 17, 'provider' => 'github'], 300, true);
check(\AKSRT\Auth\OAuth::validate_bind_pass($bindPass, 'github', 17, false), 'valid bind pass peek failed');
check(!\AKSRT\Auth\OAuth::validate_bind_pass($bindPass, 'gitee', 17, true), 'wrong bind provider consumed as valid');
check(!\AKSRT\Auth\OAuth::validate_bind_pass($bindPass, 'github', 18, true), 'wrong bind user consumed as valid');
check(\AKSRT\Auth\OAuth::validate_bind_pass($bindPass, 'github', 17, true), 'valid bind pass claim failed after rejected mismatches');
check(!\AKSRT\Auth\OAuth::validate_bind_pass($bindPass, 'github', 17, true), 'bind pass replay accepted');
$GLOBALS['captcha_enabled'] = false;
check(\AKSRT\Auth\OAuth::validate_bind_pass('', 'github', 17, true), 'disabled bind captcha required a pass');
$GLOBALS['captcha_enabled'] = true;
$expired = \AKSRT\Auth\OAuthTransactionStore::issue('direct', [], 30, true);
foreach ($GLOBALS['wp_options'] as $key => $row) {
if (is_array($row) && ($row['kind'] ?? '') === 'direct') $GLOBALS['wp_options'][$key]['expires_at'] = time() - 1;