更新
This commit is contained in:
@@ -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 代码 + 行知登录页,账号密码与一键两种方式)也已通过。
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\mcp\controller;
|
||||||
|
|
||||||
|
use app\BaseController;
|
||||||
|
use app\common\model\auth\Admin;
|
||||||
|
use app\mcp\service\AuditLogger;
|
||||||
|
use app\mcp\service\GrantService;
|
||||||
|
use app\mcp\service\Guard;
|
||||||
|
use app\mcp\service\McpConfig;
|
||||||
|
use app\mcp\service\McpException;
|
||||||
|
use app\mcp\service\RateLimiter;
|
||||||
|
use app\mcp\service\SsoService;
|
||||||
|
use think\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用甄养堂账号登录行知(授权码方式,流程见 SsoService):
|
||||||
|
* GET /mcp/sso/authorize 登录确认页(用户浏览器)
|
||||||
|
* POST /mcp/sso/whois 页面用:浏览器里已登录的后台账号是谁
|
||||||
|
* POST /mcp/sso/approve 页面用:以已登录的后台账号确认
|
||||||
|
* POST /mcp/sso/login 页面用:输入账号密码确认
|
||||||
|
* POST /mcp/sso/token 行知服务器用:授权码 + 客户端密钥 → 账号信息和只读令牌
|
||||||
|
* 前四个面向用户浏览器,不按来源 IP 限制(用户不在 ALLOWED_IPS 里);token 只给行知服务器,经过 Guard。
|
||||||
|
*/
|
||||||
|
class SsoController extends BaseController
|
||||||
|
{
|
||||||
|
public function authorize(): Response
|
||||||
|
{
|
||||||
|
if ($this->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 !== ''
|
||||||
|
? '<p class="fatal">' . htmlspecialchars($error, ENT_QUOTES, 'UTF-8') . '</p>'
|
||||||
|
: 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'
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<title>甄养堂账号登录 · 行知</title>
|
||||||
|
<style nonce="__NONCE__">
|
||||||
|
[hidden]{display:none!important}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;min-height:100vh;padding:24px 16px;background:#f3f5f8;color:#1f2933;
|
||||||
|
font:14px/1.6 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei","Helvetica Neue",Arial,sans-serif}
|
||||||
|
main{width:100%;max-width:380px;margin:8vh auto 0;background:#fff;border-radius:16px;box-shadow:0 10px 30px rgba(16,24,40,.08);padding:28px 26px 22px}
|
||||||
|
.brand{display:flex;align-items:center;gap:10px;margin-bottom:18px;color:#0f6e56;font-weight:600}
|
||||||
|
.brand i{width:30px;height:30px;border-radius:9px;background:#0f6e56;color:#fff;display:flex;align-items:center;justify-content:center;font-style:normal}
|
||||||
|
h1{font-size:20px;margin:0 0 6px}
|
||||||
|
.sub{margin:0 0 18px;color:#667085;font-size:13px}
|
||||||
|
.hint{margin:0 0 10px;color:#475467;font-size:13px}
|
||||||
|
.who{display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid #e4e7ec;border-radius:12px}
|
||||||
|
.who b{width:40px;height:40px;border-radius:50%;background:#e7f4ef;color:#0f6e56;display:flex;align-items:center;justify-content:center;font-size:16px}
|
||||||
|
.who strong{display:block;font-size:15px}.who span{color:#667085;font-size:13px}
|
||||||
|
label{display:block;margin:12px 0 0;font-size:13px;color:#344054}
|
||||||
|
input{display:block;width:100%;height:42px;margin-top:6px;padding:0 12px;border:1px solid #d0d5dd;border-radius:10px;font-size:15px;background:#fff;color:#1f2933}
|
||||||
|
input:focus{outline:none;border-color:#0f6e56;box-shadow:0 0 0 3px rgba(15,110,86,.15)}
|
||||||
|
button{display:block;width:100%;height:44px;margin-top:18px;border:0;border-radius:10px;background:#0f6e56;color:#fff;font-size:15px;cursor:pointer}
|
||||||
|
button:disabled{opacity:.6;cursor:default}
|
||||||
|
button.link{height:auto;margin-top:12px;background:none;color:#0f6e56;font-size:13px}
|
||||||
|
.error{min-height:20px;margin-top:12px;color:#b42318;font-size:13px}
|
||||||
|
.fatal{margin:6px 0 0;color:#b42318}
|
||||||
|
.note{margin:14px 0 0;color:#98a2b3;font-size:12px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="brand"><i>甄</i>甄养堂账号</div>
|
||||||
|
<h1>登录「行知」</h1>
|
||||||
|
__BODY__
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
|
||||||
|
private const FORM = <<<'HTML'
|
||||||
|
<p class="sub">使用甄养堂账号登录行知 AI 工作助手。登录后,行知会按你的甄养堂权限只读查询业务数据。</p>
|
||||||
|
<section id="session" hidden>
|
||||||
|
<p class="hint">你已登录甄养堂后台:</p>
|
||||||
|
<div class="who"><b id="who-initial"></b><div><strong id="who-name"></strong><span id="who-account"></span></div></div>
|
||||||
|
<button type="button" id="approve">以此账号登录</button>
|
||||||
|
<button type="button" class="link" id="other">使用其他账号</button>
|
||||||
|
</section>
|
||||||
|
<form id="form" hidden>
|
||||||
|
<label>甄养堂账号<input id="account" name="account" autocomplete="username" maxlength="64" required></label>
|
||||||
|
<label>密码<input id="password" name="password" type="password" autocomplete="current-password" maxlength="128" required></label>
|
||||||
|
<button type="submit" id="submit">登录</button>
|
||||||
|
</form>
|
||||||
|
<div class="error" id="error" role="alert"></div>
|
||||||
|
<button type="button" class="link" id="cancel">取消,返回行知</button>
|
||||||
|
<p class="note">密码只在甄养堂校验,不会发给行知。不是你本人发起的登录,请直接关闭此页。</p>
|
||||||
|
<script nonce="__NONCE__">
|
||||||
|
(function () {
|
||||||
|
var ctx = __CTX__;
|
||||||
|
function el(id) { return document.getElementById(id); }
|
||||||
|
function show(name) { el('session').hidden = name !== 'session'; el('form').hidden = name !== 'form'; }
|
||||||
|
function fail(text) { el('error').textContent = text; }
|
||||||
|
function busy(on) { var all = document.querySelectorAll('button'); for (var i = 0; i < all.length; i++) all[i].disabled = on; }
|
||||||
|
function post(action, extra) {
|
||||||
|
var body = { client_id: ctx.client_id, redirect_uri: ctx.redirect_uri, state: ctx.state };
|
||||||
|
for (var key in extra) body[key] = extra[key];
|
||||||
|
return fetch('/mcp/sso/' + action, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), credentials: 'omit', cache: 'no-store' })
|
||||||
|
.then(function (response) {
|
||||||
|
return response.json().catch(function () { return null; }).then(function (data) {
|
||||||
|
if (!data || data.code !== 1) throw new Error((data && data.msg) || ('请求失败(HTTP ' + response.status + ')'));
|
||||||
|
return data.data || {};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function saved() {
|
||||||
|
try {
|
||||||
|
var item = JSON.parse(window.localStorage.getItem('like_admin_token') || 'null');
|
||||||
|
if (!item || typeof item.value !== 'string') return '';
|
||||||
|
if (item.expire && item.expire < Math.round(Date.now() / 1000)) return '';
|
||||||
|
return item.value;
|
||||||
|
} catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
function go(result) { window.location.replace(result.redirect); }
|
||||||
|
function stop(error) { busy(false); fail(error.message || '登录失败,请稍后重试'); }
|
||||||
|
var session = saved();
|
||||||
|
el('approve').addEventListener('click', function () { busy(true); fail(''); post('approve', { session: session }).then(go).catch(stop); });
|
||||||
|
el('other').addEventListener('click', function () { session = ''; fail(''); show('form'); el('account').focus(); });
|
||||||
|
el('cancel').addEventListener('click', function () { window.location.replace(ctx.cancel); });
|
||||||
|
el('form').addEventListener('submit', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
busy(true); fail('');
|
||||||
|
post('login', { account: el('account').value.trim(), password: el('password').value }).then(go).catch(function (error) {
|
||||||
|
stop(error); el('password').value = ''; el('password').focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!session) { show('form'); el('account').focus(); return; }
|
||||||
|
post('whois', { session: session }).then(function (who) {
|
||||||
|
var name = who.name || who.account || '';
|
||||||
|
el('who-initial').textContent = name.slice(0, 1) || '?';
|
||||||
|
el('who-name').textContent = name;
|
||||||
|
el('who-account').textContent = who.account || '';
|
||||||
|
show('session');
|
||||||
|
el('approve').focus();
|
||||||
|
}).catch(function () { session = ''; show('form'); el('account').focus(); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
@@ -27,11 +27,20 @@ class GrantService
|
|||||||
*/
|
*/
|
||||||
public static function issue(array $input, string $ip): array
|
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';
|
$client = substr(trim((string) ($input['client'] ?? 'xingzhi')), 0, 32) ?: 'xingzhi';
|
||||||
$instance = substr(trim((string) ($input['client_instance'] ?? '')), 0, 64);
|
$instance = substr(trim((string) ($input['client_instance'] ?? '')), 0, 64);
|
||||||
$label = mb_substr(trim((string) ($input['label'] ?? '')), 0, 100);
|
$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) {
|
if ($account === '' || $password === '' || mb_strlen($account) > 64 || strlen($password) > 128) {
|
||||||
throw new McpException('请输入正确的账号和密码', 'invalid_request');
|
throw new McpException('请输入正确的账号和密码', 'invalid_request');
|
||||||
}
|
}
|
||||||
@@ -57,9 +66,16 @@ class GrantService
|
|||||||
|
|
||||||
self::assertAdminUsable($admin);
|
self::assertAdminUsable($admin);
|
||||||
if (McpConfig::requirePasswordChanged() && array_key_exists('is_paw', $admin->getData()) && (int) $admin['is_paw'] !== 1) {
|
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();
|
$now = time();
|
||||||
$token = TokenService::generate();
|
$token = TokenService::generate();
|
||||||
$expire = $now + McpConfig::tokenTtlDays() * 86400;
|
$expire = $now + McpConfig::tokenTtlDays() * 86400;
|
||||||
@@ -197,8 +213,8 @@ class GrantService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 停用、企微强制绑定、AI 权限点:签发和每次调用都检查 */
|
/** 停用、企微强制绑定、AI 权限点:签发、每次调用和登录行知都检查 */
|
||||||
private static function assertAdminUsable(Admin $admin): void
|
public static function assertAdminUsable(Admin $admin): void
|
||||||
{
|
{
|
||||||
if ((int) $admin['disable'] === 1) {
|
if ((int) $admin['disable'] === 1) {
|
||||||
throw new McpException('甄养堂账号已停用', 'disabled');
|
throw new McpException('甄养堂账号已停用', 'disabled');
|
||||||
|
|||||||
@@ -139,11 +139,41 @@ class McpConfig
|
|||||||
return self::int('console_ttl_minutes', 120, 10, 480);
|
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)
|
private static function raw(string $key)
|
||||||
{
|
{
|
||||||
return env('ai_mcp.' . $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
|
private static function bool(string $key, bool $default): bool
|
||||||
{
|
{
|
||||||
$value = self::raw($key);
|
$value = self::raw($key);
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\mcp\service;
|
||||||
|
|
||||||
|
use app\common\enum\AdminTerminalEnum;
|
||||||
|
use app\common\model\auth\Admin;
|
||||||
|
use think\facade\Cache;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用甄养堂后台账号登录行知(授权码方式,入口见 SsoController):
|
||||||
|
* 1. 行知把浏览器带到 /mcp/sso/authorize(client_id、redirect_uri、state);
|
||||||
|
* 2. 确认身份:浏览器里已登录后台的(localStorage like_admin_token,只认电脑端、手机端的正常登录)点一下确认,
|
||||||
|
* 否则输入账号密码,与“绑定甄养堂账号”同一套校验(限流、连续错误锁定、停用、企微、AI 权限点、初始密码);
|
||||||
|
* 3. 带一次性授权码(2 分钟内、只能用一次)回到行知登记过的地址;
|
||||||
|
* 4. 行知服务器用客户端密钥在 /mcp/sso/token 换取账号信息和只读令牌(与绑定甄养堂账号签发的授权相同)。
|
||||||
|
* 配置:.env [AI_MCP] 的 SSO_ENABLED、SSO_CLIENT_ID、SSO_CLIENT_SECRET、SSO_REDIRECT_URIS(McpConfig::sso*)。
|
||||||
|
*/
|
||||||
|
class SsoService
|
||||||
|
{
|
||||||
|
public const CODE_TTL = 120;
|
||||||
|
|
||||||
|
/** 只接受后台的正常登录;AI 浏览器(ConsoleService::TERMINAL)、企微客服端等会话不能用来确认登录 */
|
||||||
|
private const SESSION_TERMINALS = [AdminTerminalEnum::PC, AdminTerminalEnum::MOBILE];
|
||||||
|
|
||||||
|
public static function enabled(): bool
|
||||||
|
{
|
||||||
|
return McpConfig::enabled() && McpConfig::ssoEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** client_id 与 redirect_uri 必须与配置完全一致,否则既不显示登录框也不跳转(授权码不会被送到别处) */
|
||||||
|
public static function checkClient(string $clientId, string $redirectUri): void
|
||||||
|
{
|
||||||
|
$expected = McpConfig::ssoClientId();
|
||||||
|
if (!self::enabled() || $expected === '' || !hash_equals($expected, $clientId) || !in_array($redirectUri, McpConfig::ssoRedirectUris(), true)) {
|
||||||
|
throw new McpException('这个登录请求的来源未在甄养堂登记,请从行知重新发起登录', 'invalid_client', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function checkSecret(string $secret): void
|
||||||
|
{
|
||||||
|
$expected = McpConfig::ssoClientSecret();
|
||||||
|
if (strlen($expected) < 32 || !hash_equals($expected, $secret)) {
|
||||||
|
throw new McpException('客户端密钥不正确', 'invalid_client', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 浏览器里已登录的后台会话对应的账号;门禁与账号密码登录相同 */
|
||||||
|
public static function sessionAdmin(string $token): Admin
|
||||||
|
{
|
||||||
|
$session = preg_match('/^[0-9a-f]{32}$/', $token) === 1
|
||||||
|
? Db::name('admin_session')->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
Reference in New Issue
Block a user