Files
zyt/server/app/mcp/service/GrantService.php
T
2026-09-24 09:45:44 +08:00

199 lines
8.9 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\adminapi\logic\LoginLogic;
use app\common\model\auth\Admin;
use think\facade\Cache;
use think\facade\Config;
use think\facade\Db;
use think\Request;
/**
* AI 授权:用后台账号密码一次性换取只读令牌;每次调用实时校验令牌与账号状态。
* 独立于后台登录会话(zyt_admin_session),不会挤掉浏览器、医生工作站或企微客服端的登录。
*/
class GrantService
{
public const STATUS_ACTIVE = 1;
public const STATUS_REVOKED = 2;
public const STATUS_EXPIRED = 3;
/**
* 校验账号密码及各项门禁,通过后签发令牌。失败抛 McpException(reason 见接口约定)。
*/
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);
if ($account === '' || $password === '' || mb_strlen($account) > 64 || strlen($password) > 128) {
throw new McpException('请输入正确的账号和密码', 'invalid_request');
}
if (!RateLimiter::hit('grant_ip_' . md5($ip), McpConfig::grantAttemptsPerIp(), 600)) {
throw new McpException('尝试次数过多,请稍后再试', 'locked');
}
$lockKey = 'ai_mcp_grant_fail_' . md5(mb_strtolower($account));
$failures = (int) Cache::get($lockKey, 0);
if ($failures >= McpConfig::lockFailures()) {
throw new McpException('密码连续' . McpConfig::lockFailures() . '次错误,请' . McpConfig::lockMinutes() . '分钟后重试', 'locked');
}
$admin = Admin::where('account', '=', $account)->findOrEmpty();
$salt = (string) Config::get('project.unique_identification');
$ok = !$admin->isEmpty() && (string) $admin['password'] !== ''
&& hash_equals((string) $admin['password'], create_password($password, $salt));
if (!$ok) {
Cache::set($lockKey, $failures + 1, McpConfig::lockMinutes() * 60);
// 账号不存在与密码错误给同样的提示,避免被用来探测账号
throw new McpException('账号或密码错误', 'invalid_credentials');
}
Cache::delete($lockKey);
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');
}
$now = time();
$token = TokenService::generate();
$expire = $now + McpConfig::tokenTtlDays() * 86400;
Db::startTrans();
try {
// 同一客户端实例重新绑定时,旧授权自动作废
Db::name('ai_grant')
->where(['admin_id' => $admin['id'], 'client' => $client, 'client_instance' => $instance, 'status' => self::STATUS_ACTIVE])
->update(['status' => self::STATUS_REVOKED, 'revoke_time' => $now, 'revoke_reason' => 'rebind', 'update_time' => $now]);
$grantId = (int) Db::name('ai_grant')->insertGetId([
'admin_id' => $admin['id'],
'token_hash' => TokenService::hash($token),
'token_prefix' => TokenService::displayPrefix($token),
'client' => $client,
'client_instance' => $instance,
'label' => $label,
'scopes' => 'zyt.read',
'pwd_fp' => self::passwordFingerprint($admin),
'status' => self::STATUS_ACTIVE,
'expire_time' => $expire,
'idle_days' => McpConfig::tokenIdleDays(),
'last_used_time' => $now,
'last_used_ip' => $ip,
'created_ip' => $ip,
'create_time' => $now,
'update_time' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
$identity = new Identity(self::find($grantId), $admin);
return [
'grant_id' => $grantId,
'token' => $token,
'token_prefix' => TokenService::displayPrefix($token),
'expire_at' => $expire,
'idle_days' => McpConfig::tokenIdleDays(),
'admin' => $identity->publicProfile(),
];
}
/**
* 按 Bearer 令牌识别调用人。令牌无效、过期、闲置超期、账号停用/删除/改密、失去 AI 权限时抛 401。
*/
public static function authenticate(Request $request): Identity
{
$token = TokenService::fromRequest($request);
if ($token === '') {
throw McpException::unauthorized('缺少有效的授权令牌');
}
$grant = Db::name('ai_grant')->where('token_hash', TokenService::hash($token))->find();
if (!$grant || (int) $grant['status'] !== self::STATUS_ACTIVE) {
throw McpException::unauthorized();
}
$now = time();
$idleLimit = (int) $grant['last_used_time'] + (int) $grant['idle_days'] * 86400;
if ((int) $grant['expire_time'] <= $now || $idleLimit <= $now) {
self::close((int) $grant['id'], self::STATUS_EXPIRED, 'expired');
throw McpException::unauthorized('授权已过期,请在行知重新绑定甄养堂账号', 'expired');
}
$admin = Admin::where('id', '=', $grant['admin_id'])->findOrEmpty();
if ($admin->isEmpty()) {
self::close((int) $grant['id'], self::STATUS_REVOKED, 'admin_deleted');
throw McpException::unauthorized('甄养堂账号已删除');
}
if (!hash_equals((string) $grant['pwd_fp'], self::passwordFingerprint($admin))) {
self::close((int) $grant['id'], self::STATUS_REVOKED, 'password_changed');
throw McpException::unauthorized('甄养堂账号密码已修改,请重新绑定', 'password_changed');
}
try {
self::assertAdminUsable($admin);
} catch (McpException $e) {
if ($e->reason === 'disabled') {
self::close((int) $grant['id'], self::STATUS_REVOKED, 'admin_disabled');
}
throw new McpException($e->getMessage(), $e->reason, 401);
}
$ip = $request->ip();
if ($now - (int) $grant['last_used_time'] >= 60 || $grant['last_used_ip'] !== $ip) {
Db::name('ai_grant')->where('id', $grant['id'])->update(['last_used_time' => $now, 'last_used_ip' => $ip, 'update_time' => $now]);
}
return new Identity($grant, $admin);
}
public static function find(int $grantId): array
{
return Db::name('ai_grant')->where('id', $grantId)->find() ?: [];
}
public static function close(int $grantId, int $status, string $reason, int $by = 0): void
{
$now = time();
Db::name('ai_grant')->where(['id' => $grantId, 'status' => self::STATUS_ACTIVE])->update([
'status' => $status,
'revoke_time' => $now,
'revoke_by' => $by,
'revoke_reason' => substr($reason, 0, 64),
'update_time' => $now,
]);
}
public static function publicGrant(array $grant): array
{
return [
'grant_id' => (int) $grant['id'],
'expire_at' => (int) $grant['expire_time'],
'idle_days' => (int) $grant['idle_days'],
'last_used_at' => (int) $grant['last_used_time'],
];
}
/** 停用、企微强制绑定、AI 权限点:签发和每次调用都检查 */
private static function assertAdminUsable(Admin $admin): void
{
if ((int) $admin['disable'] === 1) {
throw new McpException('甄养堂账号已停用', 'disabled');
}
if (LoginLogic::adminMustBindWorkWechat(['root' => $admin['root'], 'work_wechat_userid' => $admin['work_wechat_userid'] ?? ''])) {
throw new McpException('请先在甄养堂后台绑定企业微信,再使用 AI 助手', 'need_bind_wecom');
}
if ((int) $admin['root'] !== 1) {
$perm = PermissionService::normalize('ai.mcp/access');
if (!PermissionService::isRegistered('ai.mcp/access') || !isset(PermissionService::adminPerms((int) $admin['id'])[$perm])) {
throw new McpException('该账号未开通“AI 助手查询”权限,请联系甄养堂管理员', 'no_ai_permission');
}
}
}
/** 密码指纹:改密后与签发时不一致,授权随即失效(不需要修改后台任何改密代码) */
private static function passwordFingerprint(Admin $admin): string
{
return hash('sha256', $admin['id'] . ':' . (string) $admin['password']);
}
}