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; }