This commit is contained in:
gr
2026-10-10 10:55:32 +08:00
parent 5543f45850
commit 68b412495d
6 changed files with 749 additions and 5 deletions
+212
View File
@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
/**
* 用甄养堂账号登录行知(/mcp/sso/*)测试:登录确认页只对登记的客户端和回调地址显示(安全响应头、不可嵌入),
* 账号密码确认与“绑定甄养堂账号”同一套门禁,已登录后台的一键确认只认电脑端/手机端会话(AI 浏览器、过期会话不行),
* 授权码要客户端密钥、与回调地址一致、只能用一次,换来的只读令牌可直接使用并替换同一实例的旧授权,审计;
* 以及原有的绑定接口(/mcp/auth/grant)不受影响。
*
* 需要一次性测试库(库名以 _test 结尾,已执行 2026_09_24_ai_mcp.sql)和指向它的运行实例,实例与本测试使用同一组 SSO 配置:
* export PHP_AI_MCP_SSO_ENABLED=true PHP_AI_MCP_SSO_CLIENT_ID=xingzhi
* export PHP_AI_MCP_SSO_CLIENT_SECRET=<至少 32 位> PHP_AI_MCP_SSO_REDIRECT_URIS=http://127.0.0.1:18787/api/auth/sso/zyt/callback
* AI_MCP_TEST_MYSQL=1 AI_MCP_TEST_BASE_URL=http://127.0.0.1:8099 php server/tests/AiMcpSsoTest.php
* 夹具 ID 段:账号 93201-93204、角色 301-302。
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use think\App;
use think\cache\driver\File as FileCache;
use think\facade\Db;
function aiMcpSsoExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
$base = rtrim((string) getenv('AI_MCP_TEST_BASE_URL'), '/');
$secret = (string) getenv('PHP_AI_MCP_SSO_CLIENT_SECRET');
$redirect = trim(explode(',', (string) getenv('PHP_AI_MCP_SSO_REDIRECT_URIS'))[0]);
$clientId = (string) getenv('PHP_AI_MCP_SSO_CLIENT_ID');
if (getenv('AI_MCP_TEST_MYSQL') !== '1' || $base === '' || strlen($secret) < 32 || $redirect === '' || $clientId === '') {
echo "AiMcpSsoTest SKIP (set AI_MCP_TEST_MYSQL=1, AI_MCP_TEST_BASE_URL, PHP_DATABASE_* for a disposable *_test database and the PHP_AI_MCP_SSO_* settings the server runs with)\n";
exit(0);
}
$app = new App(dirname(__DIR__) . DIRECTORY_SEPARATOR);
$app->initialize();
$database = (string) config('database.connections.' . config('database.default') . '.database');
aiMcpSsoExpect(str_ends_with($database, '_test'), "refusing to run on database '{$database}' (name must end with _test)");
aiMcpSsoExpect((int) Db::name('system_menu')->where('perms', 'ai.mcp/access')->count() === 1, 'run 2026_09_24_ai_mcp.sql on the test database first');
// ---------------------------------------------------------------- 夹具
$now = time();
$pwd = create_password('Test@123456', (string) config('project.unique_identification'));
Db::name('system_role')->whereIn('id', [301, 302])->delete();
foreach ([301 => 'AI 查询', 302 => '无 AI 权限'] as $id => $name) {
Db::name('system_role')->insert(['id' => $id, 'name' => $name, 'desc' => 'ai-mcp-sso-test', 'sort' => 0, 'data_scope' => 4, 'create_time' => $now, 'update_time' => $now]);
}
Db::name('system_role_menu')->whereIn('role_id', [301, 302])->delete();
Db::name('system_role_menu')->insert(['role_id' => 301, 'menu_id' => (int) Db::name('system_menu')->where('perms', 'ai.mcp/access')->value('id')]);
// 账号 => [名称, 角色, is_paw, disable]
$admins = [93201 => ['s_doc', '单点医生', 301, 1, 0], 93202 => ['s_noai', '无权限', 302, 1, 0], 93203 => ['s_new', '新账号', 301, 0, 0], 93204 => ['s_off', '已停用', 301, 1, 1]];
Db::name('admin')->whereIn('id', array_keys($admins))->delete();
Db::name('admin_role')->whereIn('admin_id', array_keys($admins))->delete();
Db::name('admin_session')->whereIn('admin_id', array_keys($admins))->delete();
Db::name('ai_grant')->whereIn('admin_id', array_keys($admins))->delete();
Db::name('ai_access_log')->whereIn('admin_id', array_keys($admins))->delete();
Db::name('ai_access_log')->whereIn('tool', ['sso.login', 'sso.approve', 'sso.token'])->where('admin_id', 0)->delete();
foreach ($admins as $id => [$account, $name, $role, $isPaw, $disable]) {
Db::name('admin')->insert(['id' => $id, 'root' => 0, 'name' => $name, 'avatar' => '', 'account' => $account, 'password' => $pwd, 'multipoint_login' => 1,
'is_paw' => $isPaw, 'work_wechat_userid' => '', 'disable' => $disable, 'phone' => '1370000' . substr((string) $id, -4), 'create_time' => $now, 'update_time' => $now]);
Db::name('admin_role')->insert(['admin_id' => $id, 'role_id' => $role]);
}
$pcToken = substr(md5('sso-test-pc-' . $now), 0, 32);
$aiToken = substr(md5('sso-test-ai-' . $now), 0, 32);
$oldToken = substr(md5('sso-test-old-' . $now), 0, 32);
Db::name('admin_session')->insert(['admin_id' => 93201, 'terminal' => 1, 'token' => $pcToken, 'update_time' => $now, 'expire_time' => $now + 3600]);
Db::name('admin_session')->insert(['admin_id' => 93201, 'terminal' => 8, 'token' => $aiToken, 'update_time' => $now, 'expire_time' => $now + 3600]);
Db::name('admin_session')->insert(['admin_id' => 93203, 'terminal' => 1, 'token' => $oldToken, 'update_time' => $now - 7200, 'expire_time' => $now - 60]);
// 服务端(mcp 应用自己的缓存目录)里的错误计数与限流桶,清掉让断言可重复
$cacheOptions = (array) config('cache.stores.file');
$cacheOptions['path'] = app()->getRootPath() . 'runtime' . DIRECTORY_SEPARATOR . 'mcp' . DIRECTORY_SEPARATOR . 'cache';
$serverCache = new FileCache(app(), $cacheOptions);
foreach (array_column($admins, 0) as $account) {
$serverCache->delete('ai_mcp_grant_fail_' . md5($account));
}
foreach (['sso_ip_', 'sso_token_', 'grant_ip_'] as $bucket) {
foreach (['127.0.0.1', '::1'] as $ip) {
$serverCache->delete('ai_mcp_rl_' . $bucket . md5($ip) . '_' . intdiv(time(), 600));
}
}
\think\facade\Cache::clear();
// ---------------------------------------------------------------- HTTP 工具
function aiMcpSsoHttp(string $method, string $url, ?array $body, array $headers = []): array
{
$ch = curl_init($url);
$lines = ['Content-Type: application/json'];
foreach ($headers as $k => $v) {
$lines[] = $k . ': ' . $v;
}
$responseHeaders = [];
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $lines, CURLOPT_TIMEOUT => 60,
CURLOPT_HEADERFUNCTION => static function ($ch, string $line) use (&$responseHeaders): int {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$responseHeaders[strtolower(trim($parts[0]))][] = trim($parts[1]);
}
return strlen($line);
}]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE));
}
$raw = (string) curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return [$status, json_decode($raw, true), $raw, $responseHeaders];
}
$state = 'state-' . bin2hex(random_bytes(8));
$ctx = ['client_id' => $clientId, 'redirect_uri' => $redirect, 'state' => $state];
$page = static fn (array $query) => aiMcpSsoHttp('GET', $base . '/mcp/sso/authorize?' . http_build_query($query), null);
$sso = static fn (string $action, array $body) => (array) (aiMcpSsoHttp('POST', $base . '/mcp/sso/' . $action, $ctx + $body)[1] ?? []);
$codeOf = static function (array $body) use ($redirect, $state): string {
$url = (string) ($body['data']['redirect'] ?? '');
aiMcpSsoExpect(str_starts_with($url, $redirect . '?'), 'redirects back to the registered address: ' . json_encode($body, JSON_UNESCAPED_UNICODE));
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
aiMcpSsoExpect(($query['state'] ?? '') === $state && preg_match('/^[0-9a-f]{64}$/', (string) ($query['code'] ?? '')) === 1, 'with the state and a one-time code');
return (string) $query['code'];
};
$exchange = static fn (string $code, array $override = []) => aiMcpSsoHttp('POST', $base . '/mcp/sso/token',
$override + ['client_id' => $clientId, 'client_secret' => $secret, 'code' => $code, 'redirect_uri' => $redirect, 'client_instance' => 'sso-test', 'label' => '行知 · 登录']);
// ---------------------------------------------------------------- 登录确认页
[$status, , $html, $headers] = $page($ctx);
aiMcpSsoExpect($status === 200 && str_contains($html, '登录「行知」') && str_contains($html, '<form id="form"'), 'the authorize page shows for the registered client');
$csp = implode(';', $headers['content-security-policy'] ?? []);
aiMcpSsoExpect(str_contains($csp, "frame-ancestors 'none'") && str_contains($csp, "form-action 'none'") && preg_match("/script-src 'nonce-[^']+'/", $csp) === 1, 'strict CSP: ' . $csp);
aiMcpSsoExpect(($headers['x-frame-options'][0] ?? '') === 'DENY' && ($headers['cache-control'][0] ?? '') === 'no-store', 'not frameable, not cached');
aiMcpSsoExpect(!in_array('*', $headers['access-control-allow-origin'] ?? [], true), 'no wildcard CORS on the SSO page: ' . json_encode($headers['access-control-allow-origin'] ?? []));
aiMcpSsoExpect(str_contains($html, json_encode($state)) && !str_contains($html, '<script>alert'), 'state is embedded as JSON');
[$status, , $html] = $page(['redirect_uri' => 'https://evil.example/cb'] + $ctx);
// 只看页面本身(调试模式下框架会在 HTML 后面追加带请求地址的 Trace 面板,生产环境关闭调试)
$main = preg_match('#<main>.*?</main>#s', $html, $found) === 1 ? $found[0] : '';
aiMcpSsoExpect($status === 400 && $main !== '' && !str_contains($html, '<form') && !str_contains($main, 'evil.example'), 'an unregistered redirect gets no form and is not echoed');
[$status, , $html] = $page(['client_id' => 'other'] + $ctx);
aiMcpSsoExpect($status === 400 && !str_contains($html, '<form'), 'an unknown client gets no form');
[$status, , $html] = $page(['state' => ''] + $ctx);
aiMcpSsoExpect($status === 400 && !str_contains($html, '<form'), 'a request without state gets no form');
[$status] = aiMcpSsoHttp('POST', $base . '/mcp/sso/authorize', []);
aiMcpSsoExpect($status === 405, 'the page is GET only');
[$status, , $html] = $page(['client_id' => ['x']] + $ctx);
aiMcpSsoExpect($status === 400, 'array parameters are rejected cleanly');
// ---------------------------------------------------------------- 账号密码确认:与绑定同一套门禁
$body = $sso('login', ['account' => 's_doc', 'password' => 'wrong-password']);
aiMcpSsoExpect(($body['code'] ?? null) === 0 && ($body['data']['reason'] ?? '') === 'invalid_credentials', 'wrong password refused: ' . json_encode($body, JSON_UNESCAPED_UNICODE));
$body = $sso('login', ['account' => 's_noai', 'password' => 'Test@123456']);
aiMcpSsoExpect(($body['data']['reason'] ?? '') === 'no_ai_permission', 'accounts without ai.mcp/access cannot log in to 行知');
$body = $sso('login', ['account' => 's_new', 'password' => 'Test@123456']);
aiMcpSsoExpect(($body['data']['reason'] ?? '') === 'need_change_password' && str_contains((string) $body['msg'], '再登录行知'), 'initial passwords must be changed first: ' . json_encode($body, JSON_UNESCAPED_UNICODE));
$body = $sso('login', ['account' => 's_off', 'password' => 'Test@123456']);
aiMcpSsoExpect(($body['data']['reason'] ?? '') === 'disabled', 'disabled accounts are refused');
$body = (array) (aiMcpSsoHttp('POST', $base . '/mcp/sso/login', ['client_id' => $clientId, 'redirect_uri' => 'https://evil.example/cb', 'state' => $state, 'account' => 's_doc', 'password' => 'Test@123456'])[1] ?? []);
aiMcpSsoExpect(($body['data']['reason'] ?? '') === 'invalid_client', 'the page endpoints check the registered redirect too');
$body = (array) (aiMcpSsoHttp('POST', $base . '/mcp/sso/login', ['client_id' => $clientId, 'redirect_uri' => $redirect, 'account' => 's_doc', 'password' => 'Test@123456'])[1] ?? []);
aiMcpSsoExpect(($body['data']['reason'] ?? '') === 'invalid_request', 'a confirmation needs the state');
$passwordCode = $codeOf($sso('login', ['account' => 's_doc', 'password' => 'Test@123456']));
// ---------------------------------------------------------------- 已登录后台:一键确认只认正常登录
$body = $sso('whois', ['session' => $pcToken]);
aiMcpSsoExpect(($body['code'] ?? null) === 1 && ($body['data']['account'] ?? '') === 's_doc' && ($body['data']['name'] ?? '') === '单点医生', 'whois names the PC login: ' . json_encode($body, JSON_UNESCAPED_UNICODE));
aiMcpSsoExpect(($sso('whois', ['session' => $aiToken])['data']['reason'] ?? '') === 'session_invalid', 'the AI back-office browser session cannot confirm a login');
aiMcpSsoExpect(($sso('whois', ['session' => $oldToken])['data']['reason'] ?? '') === 'session_invalid', 'an expired session cannot confirm a login');
aiMcpSsoExpect(($sso('approve', ['session' => 'not-a-token'])['data']['reason'] ?? '') === 'session_invalid', 'garbage sessions are refused');
aiMcpSsoExpect(($sso('approve', ['session' => ['x']])['data']['reason'] ?? '') === 'session_invalid', 'array sessions are refused cleanly');
$sessionCode = $codeOf($sso('approve', ['session' => $pcToken]));
// ---------------------------------------------------------------- 授权码换令牌
[$status, $body] = $exchange($passwordCode, ['client_secret' => str_repeat('x', 40)]);
aiMcpSsoExpect($status === 401 && ($body['data']['reason'] ?? '') === 'invalid_client', 'a wrong client secret is refused: ' . json_encode($body, JSON_UNESCAPED_UNICODE));
[$status, $body] = $exchange($passwordCode, ['redirect_uri' => 'https://evil.example/cb']);
aiMcpSsoExpect($status === 400 && ($body['data']['reason'] ?? '') === 'invalid_client', 'the redirect address must match the registration');
[$status, $body] = $exchange($passwordCode);
aiMcpSsoExpect($status === 200 && ($body['code'] ?? null) === 1 && (int) ($body['data']['admin']['id'] ?? 0) === 93201 && str_starts_with((string) ($body['data']['token'] ?? ''), 'zyt_ai_'),
'the code from the password confirmation gives the account and a read-only token: ' . json_encode($body, JSON_UNESCAPED_UNICODE));
$firstGrant = (int) $body['data']['grant_id'];
$firstToken = (string) $body['data']['token'];
[$status, $body] = $exchange($passwordCode);
aiMcpSsoExpect($status === 400 && ($body['data']['reason'] ?? '') === 'invalid_grant', 'a code works once');
[, $who] = aiMcpSsoHttp('GET', $base . '/mcp/auth/whoami', null, ['Authorization' => 'Bearer ' . $firstToken]);
aiMcpSsoExpect(($who['code'] ?? null) === 1 && (int) ($who['data']['admin']['id'] ?? 0) === 93201, 'the token works like a binding grant');
[$status, $body] = $exchange($sessionCode);
aiMcpSsoExpect($status === 200 && ($body['code'] ?? null) === 1, 'the code from the one-click confirmation works too');
$grantRow = Db::name('ai_grant')->where('id', (int) $body['data']['grant_id'])->find();
aiMcpSsoExpect($grantRow && $grantRow['client'] === $clientId && $grantRow['client_instance'] === 'sso-test' && (int) $grantRow['status'] === 1, 'grant stored for the 行知 instance');
aiMcpSsoExpect((int) Db::name('ai_grant')->where('id', $firstGrant)->value('status') === 2, 'logging in again replaces the instance\'s previous grant');
// 授权码签发后账号被停用:兑换时拒绝
$lateCode = $codeOf($sso('approve', ['session' => $pcToken]));
Db::name('admin')->where('id', 93201)->update(['disable' => 1]);
[$status, $body] = $exchange($lateCode);
aiMcpSsoExpect(($body['data']['reason'] ?? '') === 'disabled', 'an account disabled after confirming cannot redeem: ' . json_encode($body, JSON_UNESCAPED_UNICODE));
Db::name('admin')->where('id', 93201)->update(['disable' => 0]);
[$status] = aiMcpSsoHttp('GET', $base . '/mcp/sso/token', null);
aiMcpSsoExpect($status === 405, 'the token endpoint is POST only');
// ---------------------------------------------------------------- 审计与原有绑定接口
$tools = Db::name('ai_access_log')->where('admin_id', 93201)->column('tool');
aiMcpSsoExpect(in_array('sso.approve', $tools, true) && in_array('sso.token', $tools, true), 'confirmations and exchanges are audited');
aiMcpSsoExpect(Db::name('ai_access_log')->where(['tool' => 'sso.login', 'status' => 'denied'])->count() >= 4, 'refused logins are audited');
aiMcpSsoExpect(Db::name('ai_access_log')->where(['tool' => 'sso.token', 'status' => 'denied'])->count() >= 3, 'refused exchanges are audited');
[, $body] = aiMcpSsoHttp('POST', $base . '/mcp/auth/grant', ['account' => 's_doc', 'password' => 'Test@123456', 'client' => 'xingzhi', 'client_instance' => 'grant-test']);
aiMcpSsoExpect(($body['code'] ?? null) === 1 && str_starts_with((string) ($body['data']['token'] ?? ''), 'zyt_ai_'), 'the binding endpoint still issues grants: ' . json_encode($body, JSON_UNESCAPED_UNICODE));
[, $body] = aiMcpSsoHttp('POST', $base . '/mcp/auth/grant', ['account' => 's_new', 'password' => 'Test@123456', 'client' => 'xingzhi']);
aiMcpSsoExpect(($body['data']['reason'] ?? '') === 'need_change_password' && str_contains((string) $body['msg'], '再绑定 AI 助手'), 'and keeps its own wording');
echo "AiMcpSsoTest OK\n";