2
0

AgentGrantService.php 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. <?php
  2. namespace App\Services\V3;
  3. use App\Http\Api\ChannelApi;
  4. use App\Models\AiModel;
  5. use Firebase\JWT\JWT;
  6. use Firebase\JWT\Key;
  7. use Illuminate\Auth\Access\AuthorizationException;
  8. use Illuminate\Validation\ValidationException;
  9. /**
  10. * agent 协作:人类授权 AI 模型代自己写入(`/v3/agent-grants`),以及写入时认出「谁授权、谁代写」。
  11. *
  12. * 流程(仿 git 的 Co-Authored-By,记录见根目录 CLAUDE.md「agent 协作」):
  13. * 1. 人类用自己的登录 token 为「自己名下的某个模型 + 某个 channel(可限 book)」签一张授权;
  14. * 2. 模型用自己的模型 token 登录,写入时在 `X-Agent-Grant` 头里带上这张授权;
  15. * 3. 服务端记 editor = 授权人(对内容负责)、co_editor = 模型,权限按授权人判,
  16. * 并且只在授权覆盖的 channel / book 内有效。
  17. *
  18. * 与 v2 `POST /v2/access-token` 的区别:
  19. * - 授权人、模型、范围全由服务端写进 token,不收客户端拼的载荷;
  20. * - 绑定具体模型(act),换个身份拿去用无效;
  21. * - 带模型的 token_version(ver):撤销模型 token 时,它的授权一并失效;
  22. * - 用独立的签名密钥,v2 签出的 access token 在这里一律无效(v2 的载荷由客户端决定,
  23. * 可以伪造任何声明)。
  24. */
  25. class AgentGrantService
  26. {
  27. /** 授权有效期:一次批量写入作业足够,又把泄漏窗口限制住(与 v2 access token 相同) */
  28. public const TTL = 60 * 60 * 24 * 7;
  29. /** 写入请求里携带授权的请求头 */
  30. public const HEADER = 'X-Agent-Grant';
  31. private const TYPE = 'agent-grant';
  32. /**
  33. * 人类为自己名下的模型签发 channel 写入授权。
  34. *
  35. * @param array{ai_model: string, target_type: string, target_id: string, book?: int} $data
  36. * @return array{token: string, grantor: string, ai_model: string, target_type: string, target_id: string, book: int, expires_at: string}
  37. */
  38. public function issue(string $userUid, array $data): array
  39. {
  40. if ($this->isModel($userUid)) {
  41. // 授权必须出自人类:模型不能给自己(或别的模型)签授权
  42. throw new AuthorizationException(__('messages.agent_grant_by_model'));
  43. }
  44. $model = AiModel::where('uid', $data['ai_model'])->first(['uid', 'owner_id', 'token_version']);
  45. if (! $model) {
  46. throw ValidationException::withMessages(['ai_model' => __('messages.agent_grant_model_not_found')]);
  47. }
  48. // 与 v2 取模型 token 同一口径(AiModelController::canEdit):模型归属于当前用户
  49. if ($model->owner_id !== $userUid) {
  50. throw new AuthorizationException(__('messages.agent_grant_not_owner'));
  51. }
  52. if (! ChannelApi::userCanEdit($userUid, $data['target_id'])) {
  53. throw new AuthorizationException(__('messages.agent_grant_no_power'));
  54. }
  55. $book = (int) ($data['book'] ?? 0);
  56. $expiresAt = time() + self::TTL;
  57. $token = JWT::encode([
  58. 'typ' => self::TYPE,
  59. 'grantor' => $userUid,
  60. 'act' => $model->uid,
  61. 'ver' => (int) $model->token_version,
  62. 'res_type' => $data['target_type'],
  63. 'res_id' => $data['target_id'],
  64. 'book' => $book,
  65. 'nbf' => time(),
  66. 'exp' => $expiresAt,
  67. ], $this->key(), 'HS512');
  68. return [
  69. 'token' => $token,
  70. 'grantor' => $userUid,
  71. 'ai_model' => $model->uid,
  72. 'target_type' => $data['target_type'],
  73. 'target_id' => $data['target_id'],
  74. 'book' => $book,
  75. 'expires_at' => gmdate('Y-m-d\TH:i:s\Z', $expiresAt),
  76. ];
  77. }
  78. /**
  79. * 写入请求的署名:人类直接写 → editor 是自己;模型代写 → editor 是授权人、co_editor 是模型。
  80. *
  81. * 模型没带授权一律 403:v3 的写入必须有一个对内容负责的人。人类带了授权 422(授权只给模型用)。
  82. *
  83. * @return array{editor: string, co_editor: string|null, scope: array{channel: string, book: int}|null}
  84. */
  85. public function actor(string $userUid, ?string $grant): array
  86. {
  87. $isModel = $this->isModel($userUid);
  88. if (! $isModel) {
  89. if ($grant !== null && $grant !== '') {
  90. throw ValidationException::withMessages([self::HEADER => __('messages.agent_grant_human')]);
  91. }
  92. return ['editor' => $userUid, 'co_editor' => null, 'scope' => null];
  93. }
  94. if ($grant === null || $grant === '') {
  95. throw new AuthorizationException(__('messages.agent_grant_required'));
  96. }
  97. try {
  98. $claims = JWT::decode($grant, new Key($this->key(), 'HS512'));
  99. } catch (\Throwable) {
  100. // 伪造、过期、未生效、密钥不对,一律当作没有授权
  101. throw new AuthorizationException(__('messages.agent_grant_invalid'));
  102. }
  103. $version = AiModel::where('uid', $userUid)->value('token_version');
  104. if (($claims->typ ?? null) !== self::TYPE
  105. || ($claims->act ?? null) !== $userUid
  106. || (int) ($claims->ver ?? -1) !== (int) $version
  107. || ($claims->res_type ?? null) !== 'channel'
  108. || empty($claims->grantor)) {
  109. throw new AuthorizationException(__('messages.agent_grant_invalid'));
  110. }
  111. return [
  112. 'editor' => (string) $claims->grantor,
  113. 'co_editor' => $userUid,
  114. 'scope' => ['channel' => (string) $claims->res_id, 'book' => (int) ($claims->book ?? 0)],
  115. ];
  116. }
  117. /**
  118. * 授权是否覆盖这次写入落到的 channel / book。人类直接写(scope 为 null)恒为真。
  119. *
  120. * @param array{channel: string, book: int}|null $scope
  121. */
  122. public function covers(?array $scope, ?string $channelUid, ?int $book): bool
  123. {
  124. if ($scope === null) {
  125. return true;
  126. }
  127. return $channelUid === $scope['channel'] && ($scope['book'] === 0 || $book === $scope['book']);
  128. }
  129. private function isModel(string $uid): bool
  130. {
  131. return AiModel::where('uid', $uid)->exists();
  132. }
  133. /** 与登录 token 分开的签名密钥:授权 token 不能被当成登录 token 用,反之亦然 */
  134. private function key(): string
  135. {
  136. return hash('sha512', config('mint.app.jwt_secrets_key').'|'.self::TYPE);
  137. }
  138. }