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
+128
View File
@@ -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);
}
}
}