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']); } }