Files
zyt/server/app/mcp/service/SsoService.php
T
2026-10-10 10:55:32 +08:00

129 lines
5.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}
}