更新
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
<?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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user