diff --git a/docs/plans/ai-mcp-sso-2026-10-10.md b/docs/plans/ai-mcp-sso-2026-10-10.md new file mode 100644 index 000000000..c466b4fc1 --- /dev/null +++ b/docs/plans/ai-mcp-sso-2026-10-10.md @@ -0,0 +1,53 @@ +# 用甄养堂账号登录行知(AI 模块单点登录,2026-10-10) + +行知(AI 工作助手)的登录页增加“使用甄养堂账号登录”:跳到甄养堂的登录确认页,确认后回到行知,行知首次登录自动开通成员账号,并同时完成“绑定甄养堂账号”(只读授权)。密码只在甄养堂输入和校验,不经过行知。 + +## 改动范围(都在 AI 模块 `server/app/mcp` 内,原有后台接口、Logic、中间件、配置未改) + +| 文件 | 说明 | +|---|---| +| `controller/SsoController.php`(新增) | `GET /mcp/sso/authorize` 登录确认页;`POST /mcp/sso/whois`、`/approve`、`/login` 页面用;`POST /mcp/sso/token` 行知服务器换取结果 | +| `service/SsoService.php`(新增) | 客户端与回调地址校验、浏览器里已登录后台会话的识别、一次性授权码(2 分钟、只能用一次,文件锁防并发兑换) | +| `service/GrantService.php`(AI 模块自身) | `issue()` 拆成 `verifyPassword()` + `issueFor()`,`assertAdminUsable()` 改为 public;绑定接口 `/mcp/auth/grant` 的行为不变 | +| `service/McpConfig.php`(AI 模块自身) | 新增 `ssoEnabled / ssoClientId / ssoClientSecret / ssoRedirectUris` | +| `server/tests/AiMcpSsoTest.php`(新增) | 见“测试” | + +不需要执行新的数据库迁移;不新增权限点(沿用 `ai.mcp/access`)。 + +## 流程与门禁 + +1. 行知把浏览器带到 `/mcp/sso/authorize?client_id&redirect_uri&state`。`client_id`、`redirect_uri` 必须与 .env 登记的完全一致,否则只显示原因、不显示登录框、不跳转。 +2. 确认身份,两种方式: + - 浏览器里已登录甄养堂后台(localStorage `like_admin_token`):显示“你已登录甄养堂后台:某某”,点“以此账号登录”。只认电脑端、手机端的正常登录(terminal 1/2),AI 后台浏览器(8)、企微客服端(7/9)和过期会话一律不认。 + - 输入账号密码:与“绑定甄养堂账号”同一套校验(按 IP 限流、连续错误锁定、停用、企微强制绑定、`ai.mcp/access` 权限点、未改初始密码)。 +3. 带一次性授权码回到行知;行知服务器用客户端密钥调用 `/mcp/sso/token`(经过 `ALLOWED_IPS` / Origin 检查),得到账号信息和只读令牌(与绑定签发的授权相同,同一行知实例的旧授权自动作废)。 +4. 页面:严格 CSP(脚本只认本页 nonce、`frame-ancestors 'none'`、`form-action 'none'`)、`X-Frame-Options: DENY`、`Cache-Control: no-store`、`Referrer-Policy: no-referrer`,并覆盖全局中间件的 `Access-Control-Allow-Origin: *`。 +5. 审计:`zyt_ai_access_log` 的 `sso.approve`、`sso.login`(拒绝时)、`sso.token`。 + +## 上线步骤 + +1. 同步上面四个文件到生产(`SsoController.php`、`SsoService.php`、`GrantService.php`、`McpConfig.php`)。 +2. 在行知管理端 → 组织连接器 → 甄养堂业务系统 → “账号登录行知” → “生成密钥”,把显示的四行加到生产 `.env` 的 `[AI_MCP]` 段(与 `ENABLED = true` 同一段),例如: + + ```ini + SSO_ENABLED = true + SSO_CLIENT_ID = xingzhi + SSO_CLIENT_SECRET = (行知管理端生成,只显示一次) + SSO_REDIRECT_URIS = http://xz.zhenyangtang.cn:8787/api/auth/sso/zyt/callback + ``` + +3. 重新加载 PHP(PHP-FPM / 宝塔里重载或重启 PHP),然后在行知管理端勾选“开启:登录页显示这个按钮”并保存。 +4. 如设置了 `ALLOWED_IPS`,需包含行知服务器的出口 IP(`/mcp/sso/token` 由行知服务器调用);登录确认页本身面向员工浏览器,不受这项限制。 +5. 停用:把 `SSO_ENABLED` 改为 false(或删掉这几行)并重载 PHP;行知侧也可在管理端取消勾选。 + +## 测试 + +```bash +source ~/.cache/zyt-tools/scripts/zyt_env.sh # 本地 PHP 8.2 + MariaDB(zyt_mcp_test) +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 +# 服务端与测试进程使用同一组 SSO 配置 +AI_MCP_TEST_MYSQL=1 AI_MCP_TEST_BASE_URL=http://127.0.0.1:8099 $PHP server/tests/AiMcpSsoTest.php +``` + +覆盖:确认页只对登记的客户端显示(安全响应头、未登记地址不回显)、账号密码确认的各项门禁、一键确认只认电脑端/手机端会话、授权码要客户端密钥并与回调地址一致且只能用一次、确认后被停用的账号兑换失败、换来的令牌可直接使用并替换同一实例的旧授权、审计、原有绑定接口不受影响。原有 `AiMcpUnitTest`、`AiMcpHttpContractTest`、`AiMcpConsoleTest`、`AiMcpPerfTest` 回归通过。与行知的端到端联调(真实 zyt 代码 + 行知登录页,账号密码与一键两种方式)也已通过。 diff --git a/server/app/mcp/controller/SsoController.php b/server/app/mcp/controller/SsoController.php new file mode 100644 index 000000000..2471c633b --- /dev/null +++ b/server/app/mcp/controller/SsoController.php @@ -0,0 +1,305 @@ +request->method(true) !== 'GET') { + return response('', 405)->header(['Allow' => 'GET']); + } + $query = $this->request->get(); + $clientId = self::text($query, 'client_id'); + $redirectUri = self::text($query, 'redirect_uri'); + $state = self::text($query, 'state'); + if (!SsoService::enabled()) { + return $this->page('甄养堂尚未开启“用甄养堂账号登录行知”,请联系管理员。', 503); + } + try { + SsoService::checkClient($clientId, $redirectUri); + } catch (McpException $e) { + return $this->page($e->getMessage() . '。', 400); + } + if ($state === '' || strlen($state) > 200) { + return $this->page('登录请求不完整,请从行知重新发起登录。', 400); + } + return $this->page('', 200, [ + 'client_id' => $clientId, + 'redirect_uri' => $redirectUri, + 'state' => $state, + 'cancel' => SsoService::redirectWith($redirectUri, ['error' => 'access_denied', 'state' => $state]), + ]); + } + + public function whois(): Response + { + return $this->browser('', function (array $in) { + $admin = SsoService::sessionAdmin(self::text($in, 'session')); + return ['name' => (string) $admin['name'], 'account' => (string) $admin['account']]; + }); + } + + public function approve(): Response + { + return $this->browser('sso.approve', function (array $in, string $ip) { + return $this->granted(SsoService::sessionAdmin(self::text($in, 'session')), $in, 'session', $ip); + }); + } + + public function login(): Response + { + return $this->browser('sso.login', function (array $in, string $ip) { + $admin = GrantService::verifyPassword(self::text($in, 'account'), self::text($in, 'password'), $ip, '再登录行知'); + return $this->granted($admin, $in, 'password', $ip); + }); + } + + public function token(): Response + { + if (!McpConfig::enabled()) { + return Guard::envelope(0, 'AI 助手接口未启用', ['reason' => 'feature_disabled'], 503, 1); + } + if ($this->request->method(true) !== 'POST') { + return response('', 405)->header(['Allow' => 'POST']); + } + $guard = Guard::check($this->request); + if ($guard !== null) { + return Guard::envelope(0, $guard[1], ['reason' => $guard[2]], 200, 1); + } + $in = $this->input(); + $ip = $this->request->ip(); + $clientId = self::text($in, 'client_id'); + $redirectUri = self::text($in, 'redirect_uri'); + try { + if (!RateLimiter::hit('sso_token_' . md5($ip), 300, 600)) { + throw new McpException('请求过于频繁,请稍后再试', 'locked', 429); + } + SsoService::checkClient($clientId, $redirectUri); + SsoService::checkSecret(self::text($in, 'client_secret')); + [$admin, $via] = SsoService::redeem(self::text($in, 'code'), $clientId, $redirectUri); + $instance = substr(trim(self::text($in, 'client_instance')), 0, 64); + $label = mb_substr(trim(self::text($in, 'label')), 0, 100); + $data = GrantService::issueFor($admin, $clientId, $instance, $label, $ip); + AuditLogger::log(['grant_id' => $data['grant_id'], 'admin_id' => $admin['id'], 'tool' => 'sso.token', + 'arguments' => ['client' => $clientId, 'client_instance' => $instance, 'via' => $via], 'status' => 'ok', 'ip' => $ip]); + return Guard::envelope(1, '登录成功', $data); + } catch (McpException $e) { + AuditLogger::log(['tool' => 'sso.token', 'arguments' => ['client' => $clientId], 'status' => 'denied', 'message' => $e->reason, 'ip' => $ip]); + return Guard::envelope(0, $e->getMessage(), ['reason' => $e->reason], $e->httpStatus >= 400 ? $e->httpStatus : 400, 1); + } + } + + /** 页面接口共用:方法、开关与来源登记、按 IP 限流、统一的返回格式;$tool 非空时记录拒绝 */ + private function browser(string $tool, callable $handle): Response + { + if ($this->request->method(true) !== 'POST') { + return response('', 405)->header(['Allow' => 'POST']); + } + $in = $this->input(); + $ip = $this->request->ip(); + try { + if (!RateLimiter::hit('sso_ip_' . md5($ip), 120, 600)) { + throw new McpException('尝试次数过多,请稍后再试', 'locked'); + } + SsoService::checkClient(self::text($in, 'client_id'), self::text($in, 'redirect_uri')); + return $this->json(1, '', $handle($in, $ip)); + } catch (McpException $e) { + if ($tool !== '') { + AuditLogger::log(['tool' => $tool, 'arguments' => ['account' => self::text($in, 'account')], 'status' => 'denied', + 'message' => $e->reason, 'ip' => $ip]); + } + return $this->json(0, $e->getMessage(), ['reason' => $e->reason]); + } + } + + /** 已确认身份:签发一次性授权码,返回回到行知的地址 */ + private function granted(Admin $admin, array $in, string $via, string $ip): array + { + $clientId = self::text($in, 'client_id'); + $redirectUri = self::text($in, 'redirect_uri'); + $state = self::text($in, 'state'); + if ($state === '' || strlen($state) > 200) { + throw new McpException('登录请求不完整,请从行知重新发起登录', 'invalid_request'); + } + $code = SsoService::issueCode($admin, $clientId, $redirectUri, $via); + AuditLogger::log(['admin_id' => $admin['id'], 'tool' => 'sso.approve', 'arguments' => ['client' => $clientId, 'via' => $via], 'status' => 'ok', 'ip' => $ip]); + return ['redirect' => SsoService::redirectWith($redirectUri, ['code' => $code, 'state' => $state])]; + } + + /** 只取字符串参数(数组等一律当作空),避免类型转换告警 */ + private static function text(array $in, string $key): string + { + $value = $in[$key] ?? ''; + return is_string($value) || is_int($value) ? (string) $value : ''; + } + + private function input(): array + { + $in = json_decode((string) $this->request->getInput(), true); + return is_array($in) ? $in : []; + } + + /** 本页只给同源脚本用:覆盖全局中间件的 Access-Control-Allow-Origin: * */ + private function headers(): array + { + return [ + 'Cache-Control' => 'no-store', + 'X-Content-Type-Options' => 'nosniff', + 'Referrer-Policy' => 'no-referrer', + 'Access-Control-Allow-Origin' => $this->request->domain(), + ]; + } + + private function json(int $code, string $msg, array $data = []): Response + { + return json(['code' => $code, 'show' => $code === 1 ? 0 : 1, 'msg' => $msg, 'data' => $data ?: new \stdClass()])->header($this->headers()); + } + + /** 登录确认页($error 非空时只显示原因,不带登录框和脚本) */ + private function page(string $error, int $status, array $ctx = []): Response + { + $nonce = base64_encode(random_bytes(16)); + $body = $error !== '' + ? '

' . htmlspecialchars($error, ENT_QUOTES, 'UTF-8') . '

' + : strtr(self::FORM, ['__NONCE__' => $nonce, '__CTX__' => json_encode($ctx, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]); + $html = strtr(self::LAYOUT, ['__NONCE__' => $nonce, '__BODY__' => $body]); + return response($html, $status, array_merge($this->headers(), [ + 'Content-Type' => 'text/html; charset=utf-8', + 'Content-Security-Policy' => "default-src 'none'; script-src 'nonce-{$nonce}'; style-src 'nonce-{$nonce}'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + 'X-Frame-Options' => 'DENY', + ])); + } + + private const LAYOUT = <<<'HTML' + + + + + + +甄养堂账号登录 · 行知 + + + +
+
甄甄养堂账号
+

登录「行知」

+__BODY__ +
+ + +HTML; + + private const FORM = <<<'HTML' +

使用甄养堂账号登录行知 AI 工作助手。登录后,行知会按你的甄养堂权限只读查询业务数据。

+ + + + +

密码只在甄养堂校验,不会发给行知。不是你本人发起的登录,请直接关闭此页。

+ +HTML; +} diff --git a/server/app/mcp/service/GrantService.php b/server/app/mcp/service/GrantService.php index 30bc7ee80..58f641911 100644 --- a/server/app/mcp/service/GrantService.php +++ b/server/app/mcp/service/GrantService.php @@ -27,11 +27,20 @@ class GrantService */ public static function issue(array $input, string $ip): array { - $account = trim((string) ($input['account'] ?? '')); - $password = (string) ($input['password'] ?? ''); $client = substr(trim((string) ($input['client'] ?? 'xingzhi')), 0, 32) ?: 'xingzhi'; $instance = substr(trim((string) ($input['client_instance'] ?? '')), 0, 64); $label = mb_substr(trim((string) ($input['label'] ?? '')), 0, 100); + $admin = self::verifyPassword((string) ($input['account'] ?? ''), (string) ($input['password'] ?? ''), $ip, '再绑定 AI 助手'); + return self::issueFor($admin, $client, $instance, $label, $ip); + } + + /** + * 账号密码校验(按 IP 限流、连续错误锁定)加各项门禁,通过返回后台账号。授权接口与登录行知(SsoService)共用。 + * $then:初始密码未修改时提示“请先在甄养堂后台修改初始密码,{$then}”。 + */ + public static function verifyPassword(string $account, string $password, string $ip, string $then): Admin + { + $account = trim($account); if ($account === '' || $password === '' || mb_strlen($account) > 64 || strlen($password) > 128) { throw new McpException('请输入正确的账号和密码', 'invalid_request'); } @@ -57,9 +66,16 @@ class GrantService self::assertAdminUsable($admin); if (McpConfig::requirePasswordChanged() && array_key_exists('is_paw', $admin->getData()) && (int) $admin['is_paw'] !== 1) { - throw new McpException('请先在甄养堂后台修改初始密码,再绑定 AI 助手', 'need_change_password'); + throw new McpException('请先在甄养堂后台修改初始密码,' . $then, 'need_change_password'); } + return $admin; + } + /** + * 给已通过校验的后台账号签发只读令牌;同一客户端实例的旧授权作废。 + */ + public static function issueFor(Admin $admin, string $client, string $instance, string $label, string $ip): array + { $now = time(); $token = TokenService::generate(); $expire = $now + McpConfig::tokenTtlDays() * 86400; @@ -197,8 +213,8 @@ class GrantService ]; } - /** 停用、企微强制绑定、AI 权限点:签发和每次调用都检查 */ - private static function assertAdminUsable(Admin $admin): void + /** 停用、企微强制绑定、AI 权限点:签发、每次调用和登录行知都检查 */ + public static function assertAdminUsable(Admin $admin): void { if ((int) $admin['disable'] === 1) { throw new McpException('甄养堂账号已停用', 'disabled'); diff --git a/server/app/mcp/service/McpConfig.php b/server/app/mcp/service/McpConfig.php index 26c35f4b5..990c5fdd2 100644 --- a/server/app/mcp/service/McpConfig.php +++ b/server/app/mcp/service/McpConfig.php @@ -139,11 +139,41 @@ class McpConfig return self::int('console_ttl_minutes', 120, 10, 480); } + /** 允许用甄养堂账号登录行知(SsoController;还需要 ENABLED 和下面三项) */ + public static function ssoEnabled(): bool + { + return self::bool('sso_enabled', false); + } + + /** 行知的客户端标识,与行知管理端里填写的一致 */ + public static function ssoClientId(): string + { + return self::str('sso_client_id'); + } + + /** 行知服务器换取登录结果时出示的密钥(行知管理端生成,至少 32 位) */ + public static function ssoClientSecret(): string + { + return self::str('sso_client_secret'); + } + + /** 登录后允许回到的行知地址(逗号分隔,必须完全一致),例如 http://xz.zhenyangtang.cn:8787/api/auth/sso/zyt/callback */ + public static function ssoRedirectUris(): array + { + return self::list('sso_redirect_uris'); + } + private static function raw(string $key) { return env('ai_mcp.' . $key); } + private static function str(string $key): string + { + $value = self::raw($key); + return is_string($value) || is_int($value) || is_float($value) ? trim((string) $value) : ''; + } + private static function bool(string $key, bool $default): bool { $value = self::raw($key); diff --git a/server/app/mcp/service/SsoService.php b/server/app/mcp/service/SsoService.php new file mode 100644 index 000000000..a13e69399 --- /dev/null +++ b/server/app/mcp/service/SsoService.php @@ -0,0 +1,128 @@ +where('token', $token)->where('expire_time', '>', time())->whereIn('terminal', self::SESSION_TERMINALS)->find() + : null; + $admin = $session ? Admin::where('id', '=', (int) $session['admin_id'])->findOrEmpty() : null; + if ($admin === null || $admin->isEmpty()) { + throw new McpException('后台登录已失效,请输入账号密码', 'session_invalid', 401); + } + GrantService::assertAdminUsable($admin); + if (McpConfig::requirePasswordChanged() && array_key_exists('is_paw', $admin->getData()) && (int) $admin['is_paw'] !== 1) { + throw new McpException('请先在甄养堂后台修改初始密码,再登录行知', 'need_change_password'); + } + return $admin; + } + + /** 一次性授权码:只存哈希,绑定账号、客户端和回调地址 */ + public static function issueCode(Admin $admin, string $clientId, string $redirectUri, string $via): string + { + $code = bin2hex(random_bytes(32)); + Cache::set(self::codeKey($code), [ + 'admin_id' => (int) $admin['id'], + 'client_id' => $clientId, + 'redirect_uri' => $redirectUri, + 'via' => $via, + 'issued' => time(), + ], self::CODE_TTL); + return $code; + } + + /** + * 兑换授权码(只能用一次),返回 [后台账号, 确认方式];账号此刻仍须满足各项门禁。 + */ + public static function redeem(string $code, string $clientId, string $redirectUri): array + { + $data = preg_match('/^[0-9a-f]{64}$/', $code) === 1 ? self::take(self::codeKey($code)) : null; + if (!is_array($data) || !hash_equals((string) $data['client_id'], $clientId) || $data['redirect_uri'] !== $redirectUri + || time() - (int) $data['issued'] > self::CODE_TTL) { + throw new McpException('登录授权码无效、已过期或已使用,请重新登录', 'invalid_grant', 400); + } + $admin = Admin::where('id', '=', (int) $data['admin_id'])->findOrEmpty(); + if ($admin->isEmpty()) { + throw new McpException('甄养堂账号已删除', 'invalid_grant', 400); + } + GrantService::assertAdminUsable($admin); + return [$admin, (string) $data['via']]; + } + + /** 回到行知的地址:登记的 redirect_uri 加上参数 */ + public static function redirectWith(string $redirectUri, array $params): string + { + return $redirectUri . (str_contains($redirectUri, '?') ? '&' : '?') . http_build_query($params); + } + + private static function codeKey(string $code): string + { + return 'ai_sso_code_' . hash('sha256', $code); + } + + /** 读取并删除;加文件锁,同一授权码并发兑换时只有一个能拿到 */ + private static function take(string $key) + { + $lock = fopen(runtime_path() . 'ai_sso.lock', 'c'); + if ($lock === false) { + return null; + } + try { + flock($lock, LOCK_EX); + $data = Cache::get($key); + if ($data !== null) { + Cache::delete($key); + } + return $data; + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } +} diff --git a/server/tests/AiMcpSsoTest.php b/server/tests/AiMcpSsoTest.php new file mode 100644 index 000000000..8b7fa8b94 --- /dev/null +++ b/server/tests/AiMcpSsoTest.php @@ -0,0 +1,212 @@ + 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, '
alert'), 'state is embedded as JSON'); +[$status, , $html] = $page(['redirect_uri' => 'https://evil.example/cb'] + $ctx); +// 只看页面本身(调试模式下框架会在 HTML 后面追加带请求地址的 Trace 面板,生产环境关闭调试) +$main = preg_match('#
.*?
#s', $html, $found) === 1 ? $found[0] : ''; +aiMcpSsoExpect($status === 400 && $main !== '' && !str_contains($html, ' 'other'] + $ctx); +aiMcpSsoExpect($status === 400 && !str_contains($html, ' ''] + $ctx); +aiMcpSsoExpect($status === 400 && !str_contains($html, ' ['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";