129 lines
5.7 KiB
PHP
129 lines
5.7 KiB
PHP
<?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);
|
||
}
|
||
}
|
||
}
|