优化了一些问题

This commit is contained in:
2026-08-29 22:03:48 +08:00
parent 1d904e6532
commit 780e1eba21
46 changed files with 4074 additions and 3143 deletions
@@ -173,6 +173,12 @@ test('shared renderer contracts every configured authentication state', () => {
assert.match(client, /aksrtAuthAutoUntil/);
assert.match(client, /data-password-show/);
assert.match(client, /pollQr/);
assert.match(client, /AbortController/);
assert.match(client, /refreshInFlight|inFlight/);
assert.match(client, /visibilitychange/);
assert.match(client, /Math\.pow\(2/);
assert.match(client, /QR_TIMEOUT/);
assert.match(client, /stopQr\(root\)/);
assert.match(client, /window\.notyf\(escapeHtml\(text\), type \|\| 'info'\)/);
assert.match(client, /notify\('请先完成人机验证', 'warning'\)/);
assert.match(client, /form\.noValidate = true/);
@@ -182,3 +188,47 @@ test('shared renderer contracts every configured authentication state', () => {
assert.match(settings, /invitation_mode/);
assert.match(settings, /\['close', 'open', 'must'\]/);
});
test('OAuth frontend is accessible, redirect-aware, and removes retired providers', () => {
const shared = read('templates/parts/auth-content.php');
const modal = read('templates/parts/modal-auth.php');
const client = read('assets/js/auth.js');
const userCenter = read('assets/js/routes/user-center.js');
const bindings = read('inc/helpers/user-center-renderers/settings-bindings.php');
const signCss = read('assets/css/routes/sign.css');
const mainCss = read('assets/css/main.css');
assert.match(modal, /role="dialog"[^>]*aria-modal="true"[^>]*aria-labelledby="aksrt-auth-modal-title"/);
assert.match(modal, /id="aksrt-auth-modal-title"/);
assert.match(client, /focusableModalItems/);
assert.match(client, /lastModalTrigger/);
assert.match(client, /restore\.focus\(\)/);
assert.match(shared, /role="tabpanel"[^>]*data-auth-panel="signin"/);
assert.match(shared, /role="tabpanel"[^>]*data-auth-panel="oauth"/);
assert.match(client, /setAttribute\('role', 'tab'\)/);
assert.match(client, /aria-controls/);
assert.match(client, /aria-selected/);
assert.match(shared, /data-password-show[^>]*aria-label=/);
assert.match(shared, /aria-pressed="false"/);
assert.match(client, /setAttribute\('aria-label', visible \? '隐藏密码' : '显示密码'\)/);
assert.match(client, /setAttribute\('aria-pressed', visible \? 'true' : 'false'\)/);
assert.match(shared, /data-auth-qr-status[^>]*role="status"[^>]*aria-live="polite"/);
assert.match(shared, /data-provider=/);
assert.match(shared, /aria-label="<\?php echo esc_attr\(sprintf/);
assert.match(client, /searchParams\.set\('redirect_to', redirect\)/);
assert.match(client, /oauth_action/);
assert.match(client, /oauth_provider/);
assert.match(client, /oauth_error/);
assert.match(client, /oauthErrorMessages/);
assert.doesNotMatch(client, /notify\([^\n;]*searchParams\.get\('oauth_error'/);
assert.match(userCenter, /errorMessages\[currentUrl\.searchParams\.get\('oauth_error'\)/);
assert.match(userCenter, /result\.status === 409/);
assert.match(userCenter, /d\.message \|\| \(d\.data && d\.data\.message\)/);
assert.match(bindings, /\$binding\['provider'\] \?\? \$binding\['key'\]/);
assert.match(bindings, /\$binding\['start_url'\] \?\? \$binding\['url'\]/);
assert.match(bindings, /data-provider=/);
assert.doesNotMatch(signCss + mainCss, /social-login-item[^\n{]*(?:baidu|alipay)|social-login-item\.(?:baidu|alipay)/i);
assert.match(mainCss, /\.pay-box \.but\.baidu/);
});
@@ -0,0 +1,177 @@
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 { execFileSync } = require('node:child_process')
const root = path.resolve(__dirname, '..', '..')
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
const walkFiles = (dir, extensions) => {
const result = []
for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
const relative = path.join(dir, entry.name)
if (entry.isDirectory()) result.push(...walkFiles(relative, extensions))
else if (extensions.some((ext) => entry.name.endsWith(ext))) result.push(relative)
}
return result
}
const oauthScope = [
'src/Auth/OAuth.php',
'src/Auth/OAuthProviderCatalog.php',
'src/Auth/OAuthProviderBroker.php',
'src/Auth/OAuthAccountService.php',
'src/Auth/OAuthProxyClient.php',
'src/Auth/OAuthProxyServer.php',
'src/Auth/CLoginClient.php',
'src/Admin/Schema/SchemaUsers.php',
'assets/admin/src/schema/sections/users/oauth.js',
].map(read).join('\n')
test('OAuth PHP behavior harness passes', () => {
const output = execFileSync('php', [path.join(root, 'tests/widget-system/oauth-security-harness.php')], { encoding: 'utf8' })
assert.match(output, /OAuth security harness passed/)
})
test('Baidu and Alipay are absent from the OAuth surface', () => {
for (const needle of [
'oauth_baidu_s',
'oauth_alipay_s',
'get_baidu_user_data',
'get_alipay_user_data',
'OAuthLogin\\Baidu',
'OAuthLogin\\Alipay',
"'baidu' => [",
"'alipay' => [",
]) {
assert.equal(oauthScope.includes(needle), false, `unexpected OAuth reference: ${needle}`)
}
})
test('Alipay payment remains available', () => {
const functions = read('functions.php')
const paymentSchema = read('src/Admin/Schema/SchemaPayment.php')
const gateway = read('src/Payment/Gateways/AlipayGateway.php')
assert.match(functions, /AlipayGateway/)
assert.match(paymentSchema, /alipay_appid/)
assert.match(paymentSchema, /alipay_privatekey/)
assert.match(gateway, /class AlipayGateway/)
})
test('OAuth uses one-time transactions and no PHP sessions', () => {
const auth = fs.readdirSync(path.join(root, 'src', 'Auth'))
.filter((name) => name.endsWith('.php'))
.map((name) => read(path.join('src', 'Auth', name)))
.join('\n')
assert.match(auth, /OAuthTransactionStore::issue/)
assert.match(auth, /OAuthTransactionStore::claim/)
assert.equal(auth.includes('session_start('), false)
assert.equal(auth.includes('$_SESSION'), false)
})
test('provider catalog is the canonical provider list', () => {
const catalog = read('src/Auth/OAuthProviderCatalog.php')
for (const provider of ['github', 'gitee', 'qq', 'google', 'weibo', 'weixin']) {
assert.match(catalog, new RegExp(`'${provider}'\\s*=>`))
}
assert.match(catalog, /CLOGIN_TYPES = \['qq', 'weixin', 'weibo'\]/)
assert.match(catalog, /oauth_s/)
assert.match(catalog, /oauth_bind_type/)
})
test('verified email is required for email auto-linking', () => {
const account = read('src/Auth/OAuthAccountService.php')
const broker = read('src/Auth/OAuthProviderBroker.php')
assert.match(account, /!empty\(\$identity\['email_verified'\]\)/)
assert.match(broker, /primary.*verified/s)
assert.match(broker, /email_verified.*Google|Google.*email_verified/s)
})
test('OAuth secrets are marked and masked by the schema provider', () => {
const phpSchema = read('src/Admin/Schema/SchemaUsers.php')
const jsSchema = read('assets/admin/src/schema/sections/users/oauth.js')
const provider = read('src/Admin/SchemaProvider.php')
for (const key of [
'oauth_proxy_secret',
'github_appkey',
'gitee_appkey',
'qq_appkey',
'google_client_secret',
'weibo_appkey',
'weixin_appsecret',
'weixingzh_token',
'weixingzh_appsecret',
'clogin_key',
]) {
assert.match(phpSchema, new RegExp(`${key}[^\\n]+secret`))
assert.match(jsSchema, new RegExp(`${key}[^\\n]+secret`))
}
assert.match(provider, /\*\*\*/)
assert.match(provider, /!empty\(\$field\['secret'\]\)/)
})
test('PHP and JavaScript OAuth schema field keys stay in parity', () => {
const php = read('src/Admin/Schema/SchemaUsers.php')
const js = read('assets/admin/src/schema/sections/users/oauth.js')
const start = php.indexOf("'oauth' => [")
const phpSection = php.slice(start, php.indexOf("'sms' => [", start))
const phpKeys = [...phpSection.matchAll(/\['key' => '([^']+)', 'label'/g)].map((match) => match[1])
const jsKeys = [...js.matchAll(/\{ key: '([^']+)', label:/g)].map((match) => match[1])
assert.deepEqual([...new Set(phpKeys)].sort(), [...new Set(jsKeys)].sort())
})
test('OAuth result handler maps safe errors and preserves unrelated URL state', () => {
const notices = []
let replaced = ''
const listeners = {}
const location = new URL('https://site.test/sign/?oauth_action=error&oauth_provider=github&oauth_error=%3Cscript%3E&keep=1#section')
const documentElement = { dataset: {}, contains: () => true }
const document = {
readyState: 'complete',
documentElement,
visibilityState: 'visible',
querySelectorAll: () => [],
querySelector: () => null,
addEventListener: (name, fn) => { listeners[name] = fn },
activeElement: null,
body: { classList: { add() {}, remove() {} } },
}
const window = {
aksrtData: {},
URL,
location,
history: { replaceState: (_state, _title, value) => { replaced = value } },
notyf: (message, type) => notices.push({ message, type }),
matchMedia: () => ({ matches: false }),
setTimeout,
clearTimeout,
setInterval,
clearInterval,
localStorage: { getItem: () => null, setItem() {} },
}
vm.runInNewContext(read('assets/js/auth.js'), {
window,
document,
URL,
FormData: class {},
fetch: () => Promise.reject(new Error('unexpected fetch')),
console,
CustomEvent: class {},
AbortController,
Date,
Math,
Promise,
})
assert.deepEqual(notices, [{ message: '第三方授权失败,请重试', type: 'danger' }])
assert.equal(replaced, '/sign/?keep=1#section')
assert.equal(replaced.includes('script'), false)
})
test('obsolete OAuth admin routes and components are removed', () => {
const controller = read('src/Admin/UserController.php')
const usersApi = read('assets/admin/src/api/modules/users.js')
assert.equal(controller.includes("'/settings/oauth'"), false)
assert.equal(controller.includes("'/settings/oauth-proxy'"), false)
assert.equal(usersApi.includes('getOAuthSettings'), false)
assert.equal(usersApi.includes('saveOAuthSettings'), false)
assert.equal(fs.existsSync(path.join(root, 'assets/admin/src/components/OAuthProviderForm.vue')), false)
assert.equal(fs.existsSync(path.join(root, 'assets/admin/src/pages/users/UsersSectionPage.vue')), false)
})
@@ -0,0 +1,156 @@
<?php
/**
* Minimal executable harness for OAuth transaction, proxy, URL, and secret behavior.
*/
declare(strict_types=1);
namespace {
define('ABSPATH', __DIR__ . '/');
define('MINUTE_IN_SECONDS', 60);
define('MONTH_IN_SECONDS', 2592000);
define('ARRAY_A', 'ARRAY_A');
$GLOBALS['wp_options'] = [];
$GLOBALS['wp_cache'] = [];
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)); }
function esc_url_raw(string $value): string { return trim($value); }
function wp_unslash($value) { return $value; }
function wp_parse_url(string $url) { return parse_url($url); }
function wp_generate_uuid4(): string { return '00000000-0000-4000-8000-' . substr(bin2hex(random_bytes(6)), 0, 12); }
function wp_salt(string $scheme = 'auth'): string { return 'harness-salt-' . $scheme; }
function is_ssl(): bool { return true; }
function maybe_serialize($value): string { return serialize($value); }
function maybe_unserialize($value) { $result = @unserialize((string) $value); return $result === false && $value !== serialize(false) ? $value : $result; }
function wp_cache_delete(string $key, string $group = ''): void { unset($GLOBALS['wp_cache'][$group][$key]); }
function get_option(string $key, $default = false) { return array_key_exists($key, $GLOBALS['wp_options']) ? $GLOBALS['wp_options'][$key] : $default; }
function add_option(string $key, $value, string $deprecated = '', bool $autoload = true): bool {
if (array_key_exists($key, $GLOBALS['wp_options'])) return false;
$GLOBALS['wp_options'][$key] = $value;
return true;
}
function update_option(string $key, $value, $autoload = null): bool {
$changed = !array_key_exists($key, $GLOBALS['wp_options']) || $GLOBALS['wp_options'][$key] !== $value;
$GLOBALS['wp_options'][$key] = $value;
return $changed;
}
function delete_option(string $key): bool {
if (!array_key_exists($key, $GLOBALS['wp_options'])) return false;
unset($GLOBALS['wp_options'][$key]);
return true;
}
function get_template_directory_uri(): string { return 'https://example.com/theme'; }
function get_theme_file_uri(string $path = ''): string { return 'https://example.com/theme/' . ltrim($path, '/'); }
function get_theme_file_path(string $path = ''): string { return dirname(__DIR__, 2) . '/' . ltrim($path, '/'); }
function sanitize_email(string $value): string { return filter_var($value, FILTER_VALIDATE_EMAIL) ? $value : ''; }
function esc_html__(string $value, string $domain = ''): string { return $value; }
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 query($prepared): int {
[$query, $args] = $prepared;
if (str_starts_with(ltrim($query), 'DELETE')) {
[$key, $serialized] = $args;
if (!array_key_exists($key, $GLOBALS['wp_options']) || serialize($GLOBALS['wp_options'][$key]) !== $serialized) return 0;
unset($GLOBALS['wp_options'][$key]);
return 1;
}
if (str_starts_with(ltrim($query), 'UPDATE')) {
[$new, $key, $old] = $args;
if (!array_key_exists($key, $GLOBALS['wp_options']) || serialize($GLOBALS['wp_options'][$key]) !== $old) return 0;
$GLOBALS['wp_options'][$key] = unserialize($new);
return 1;
}
return 0;
}
public function get_results($prepared, $format): array { return []; }
}
$GLOBALS['wpdb'] = new HarnessWpdb();
}
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) . '/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/Admin/Schema/SchemaUsers.php';
require dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php';
function check(bool $condition, string $message): void {
if (!$condition) throw new RuntimeException($message);
}
$_COOKIE['aksrt_oauth_browser'] = str_repeat('A', 43);
$token = \AKSRT\Auth\OAuthTransactionStore::issue('direct', ['provider' => 'github'], 60, true);
check(\AKSRT\Auth\OAuthTransactionStore::peek($token, 'direct', true) !== null, 'transaction peek failed');
check(\AKSRT\Auth\OAuthTransactionStore::peek($token, 'other', true) === null, 'transaction kind mismatch accepted');
$_COOKIE['aksrt_oauth_browser'] = str_repeat('B', 43);
check(\AKSRT\Auth\OAuthTransactionStore::claim($token, 'direct', true) === null, 'wrong browser claimed transaction');
$_COOKIE['aksrt_oauth_browser'] = str_repeat('A', 43);
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');
$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;
}
check(\AKSRT\Auth\OAuthTransactionStore::peek($expired, 'direct', true) === null, 'expired transaction accepted');
$request = [
'v' => '2', 'request_id' => str_repeat('C', 43), 'provider' => 'github', 'action' => 'login',
'agent_back_url' => 'https://example.com/oauth/proxy/callback/', 'ts' => (string) time(),
];
$secret = 'test-secret-value';
$requestSign = \AKSRT\Auth\OAuthProxyProtocol::sign_request($request, $secret);
check(\AKSRT\Auth\OAuthProxyProtocol::verify_request($request, $secret, $requestSign), 'request signature failed');
check(!\AKSRT\Auth\OAuthProxyProtocol::verify_callback($request, $secret, $requestSign), 'direction-separated HMAC failed');
$tampered = $request; $tampered['provider'] = 'qq';
check(!\AKSRT\Auth\OAuthProxyProtocol::verify_request($tampered, $secret, $requestSign), 'tampered request accepted');
check(!\AKSRT\Auth\OAuthProxyProtocol::timestamp_valid((string) (time() - 1000)), 'expired timestamp accepted');
check(\AKSRT\Auth\OAuthUrlPolicy::public_https('http://example.com') === '', 'HTTP URL accepted');
check(\AKSRT\Auth\OAuthUrlPolicy::public_https('https://user@example.com') === '', 'userinfo URL accepted');
check(\AKSRT\Auth\OAuthUrlPolicy::public_https('https://127.0.0.1') === '', 'loopback URL accepted');
check(\AKSRT\Auth\OAuthUrlPolicy::public_https('https://10.0.0.1') === '', 'private URL accepted');
check(\AKSRT\Auth\OAuthProxyProtocol::callback_url('https://example.com/wrong') === '', 'wrong callback path accepted');
$provider = \AKSRT\Admin\SchemaProvider::get_instance();
$base = $provider->get_defaults('users', 'oauth');
$base['oauth_s'] = true;
$base['oauth_weixingzh_s'] = true;
$base['weixingzh_gzh_type'] = 'service';
$base['weixingzh_appid'] = 'appid';
$base['weixingzh_appsecret'] = 'secret';
$base['weixingzh_token'] = '***';
$GLOBALS['wp_options']['aksrt_settings'] = [];
check(!$provider->validate('users', $base, 'oauth')['valid'], 'masked missing token treated as configured');
$GLOBALS['wp_options']['aksrt_settings'] = ['weixingzh_token' => 'stored-token'];
check($provider->validate('users', $base, 'oauth')['valid'], 'stored token not preserved by mask validation');
$provider->write_settings('users', ['weixingzh_token' => '***'], 'oauth');
check($GLOBALS['wp_options']['aksrt_settings']['weixingzh_token'] === 'stored-token', 'masked token overwrote stored secret');
$provider->write_settings('users', ['weixingzh_token' => 'replacement'], 'oauth');
check($GLOBALS['wp_options']['aksrt_settings']['weixingzh_token'] === 'replacement', 'new token failed to replace secret');
$provider->write_settings('users', ['__clear_secrets' => ['weixingzh_token']], 'oauth');
check(!isset($GLOBALS['wp_options']['aksrt_settings']['weixingzh_token']), 'explicit secret clear failed');
$GLOBALS['wp_options']['aksrt_settings']['weixingzh_token'] = 'final-token';
$clearData = $base;
$clearData['weixingzh_token'] = '***';
$clearData['__clear_secrets'] = ['weixingzh_token'];
check(!$provider->validate('users', $clearData, 'oauth')['valid'], 'active provider allowed required secret clear');
check($provider->read_settings('users', 'oauth')['weixingzh_token'] === '***', 'secret read was not masked');
echo "OAuth security harness passed.\n";
}
+1 -1
View File
@@ -352,7 +352,7 @@ test('settings actions use rehydrated fixed-id notyf flows', () => {
assert.match(userCenter, /notyf\([^;]+?'danger'[^;]+?toastId\);/);
assert.match(userCenter, /register\('aksrt-settings',[\s\S]*?initSettings\(root\);[\s\S]*?initBindPhone\(root\);/);
assert.match(userCenter, /aksrtSettingsInitialized/);
assert.match(userCenter, /oauth_bound[\s\S]*?notyf\([^;]+?'success'\)[\s\S]*?history\.replaceState/);
assert.match(userCenter, /oauth_action[\s\S]*?notyf\(message, type\)[\s\S]*?history\.replaceState/);
assert.doesNotMatch(userCenter, /oauth_unbound/);
assert.match(userCenter, /'aksrt-settings-phone-code-toast'/);