更新
This commit is contained in:
@@ -27,11 +27,20 @@ class GrantService
|
||||
*/
|
||||
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';
|
||||
$instance = substr(trim((string) ($input['client_instance'] ?? '')), 0, 64);
|
||||
$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) {
|
||||
throw new McpException('请输入正确的账号和密码', 'invalid_request');
|
||||
}
|
||||
@@ -57,9 +66,16 @@ class GrantService
|
||||
|
||||
self::assertAdminUsable($admin);
|
||||
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();
|
||||
$token = TokenService::generate();
|
||||
$expire = $now + McpConfig::tokenTtlDays() * 86400;
|
||||
@@ -197,8 +213,8 @@ class GrantService
|
||||
];
|
||||
}
|
||||
|
||||
/** 停用、企微强制绑定、AI 权限点:签发和每次调用都检查 */
|
||||
private static function assertAdminUsable(Admin $admin): void
|
||||
/** 停用、企微强制绑定、AI 权限点:签发、每次调用和登录行知都检查 */
|
||||
public static function assertAdminUsable(Admin $admin): void
|
||||
{
|
||||
if ((int) $admin['disable'] === 1) {
|
||||
throw new McpException('甄养堂账号已停用', 'disabled');
|
||||
|
||||
@@ -139,11 +139,41 @@ class McpConfig
|
||||
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)
|
||||
{
|
||||
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
|
||||
{
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user