AuthService.php 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. <?php
  2. namespace App\Services;
  3. use App\Http\Api\UserApi;
  4. use App\Models\AiModel;
  5. use Firebase\JWT\JWT;
  6. use Firebase\JWT\Key;
  7. use Illuminate\Http\Request;
  8. use Illuminate\Support\Str;
  9. class AuthService
  10. {
  11. /**
  12. * 人类用户登录 token 的有效期(秒)。
  13. */
  14. private const USER_TOKEN_TTL = 60 * 60 * 24 * 365;
  15. /**
  16. * AI 模型身份 token 的有效期(秒)。
  17. *
  18. * 模型 token 会被复制到外部客户端(如 wikipali-write Skill)的凭据文件里,
  19. * 泄漏面比人类登录 token 大得多,故取远短的 30 天。
  20. */
  21. private const AI_MODEL_TOKEN_TTL = 60 * 60 * 24 * 30;
  22. /**
  23. * 模型身份 token 的 typ 标记。带此标记的 token 每次校验都要比对版本号,
  24. * 人类 token 不带,避免为每个请求多查一次库。
  25. */
  26. private const AI_MODEL_TOKEN_TYPE = 'ai-model';
  27. public static function getUserToken(string $userUid)
  28. {
  29. // 先判模型:UserApi::getByUuid() 查不到用户时会回落到 AiAssistantApi,
  30. // 分不清「模型」和「查无此人」,而这两者签出的 token 完全不同。
  31. $aiModel = AiModel::where('uid', $userUid)->first();
  32. if ($aiModel) {
  33. return self::encode([
  34. 'uid' => $aiModel->uid,
  35. 'id' => 0,
  36. 'typ' => self::AI_MODEL_TOKEN_TYPE,
  37. 'ver' => (int) $aiModel->token_version,
  38. ], self::AI_MODEL_TOKEN_TTL);
  39. }
  40. $user = UserApi::getByUuid($userUid);
  41. if (! $user || ! isset($user['sn'])) {
  42. // 查无此人时 UserApi 返回的是 id=0 的占位结构,不能拿它签 token
  43. return null;
  44. }
  45. return self::encode([
  46. 'uid' => $user['id'],
  47. 'id' => $user['sn'],
  48. ], self::USER_TOKEN_TTL);
  49. }
  50. public static function getJwtKey()
  51. {
  52. return config('mint.app.jwt_secrets_key');
  53. }
  54. public static function getToken(Request $request)
  55. {
  56. $token = $request->bearerToken();
  57. return $token;
  58. }
  59. public static function current(Request $request)
  60. {
  61. $token = $request->bearerToken();
  62. if ($token) {
  63. try {
  64. $jwt = JWT::decode($token, new Key(self::getJwtKey(), 'HS512'));
  65. } catch (\Exception $e) {
  66. return false;
  67. }
  68. if ($jwt->exp < time()) {
  69. // 过期
  70. return false;
  71. }
  72. if (! self::modelTokenIsValid($jwt)) {
  73. return false;
  74. }
  75. // 有效的token
  76. return ['user_uid' => $jwt->uid, 'user_id' => $jwt->id];
  77. }
  78. // 这里曾经有一条 cookie 回落:拿不到 bearer 就读 $_COOKIE['user_uid'] 并
  79. // 直接当成已登录用户返回。那是 v1(api-v8)时代的遗留,而且是个认证绕过——
  80. // 它不验签名、不验过期、不查库,浏览器里手工种一个 user_uid 就能冒充任何人。
  81. // api-v13 与两个前端都已不再种这个 cookie(只种 timezone 和 language),
  82. // 所以这条分支是只读不写的孤儿路径,2026-09-24 删除。
  83. //
  84. // 仍有少数 v2 控制器绕开本方法直接读 $_COOKIE,删它们是各自独立的工作:
  85. // SentenceController@index(view=fulltext)
  86. // ProgressChapterController@index
  87. // LikeController@delete
  88. // UserDictController@index(view=user)与 @delete
  89. return false;
  90. }
  91. /**
  92. * 校验模型身份 token 是否已被撤销。
  93. *
  94. * 撤销即把 ai_models.token_version 自增,旧 token 里的 ver 随即对不上。
  95. * 模型被删除同样视为失效。人类 token 直接放行,不查库;唯一的例外是
  96. * root 管理员(自增主键 id 恰好是 0),见方法内 id === 0 分支的说明。
  97. */
  98. private static function modelTokenIsValid(object $jwt): bool
  99. {
  100. if (isset($jwt->typ) && $jwt->typ === self::AI_MODEL_TOKEN_TYPE) {
  101. $version = AiModel::where('uid', $jwt->uid)->value('token_version');
  102. return $version !== null && (int) $version === (int) ($jwt->ver ?? 0);
  103. }
  104. // 人类 token(自增主键 id > 0)直接放行,不查库。
  105. if ((int) ($jwt->id ?? 0) !== 0) {
  106. return true;
  107. }
  108. // id === 0 的 token 有两种来源,不能再只凭 id 判死:
  109. // 1. root 管理员的人类 token —— 其自增主键 id 恰好是 0(历史种子数据);
  110. // 2. 引入版本号之前签出的旧模型 token(typ 缺失、id 恒为 0)—— 无法撤销。
  111. // 旧模型 token 的 uid 一定落在 ai_models 表,root 的 uid 不在其中,
  112. // 故用「uid 是否命中 ai_models」区分:命中 → 旧模型 token,作废;未命中 → 人类,放行。
  113. // ai_models.uid 是 uuid 列,uid 不是合法 UUID 时查库会报类型错误,先挡掉。
  114. if (! Str::isUuid((string) $jwt->uid)) {
  115. return false;
  116. }
  117. return ! AiModel::where('uid', $jwt->uid)->exists();
  118. }
  119. /**
  120. * @param array<string, mixed> $claims
  121. */
  122. private static function encode(array $claims, int $ttl): string
  123. {
  124. return JWT::encode(array_merge([
  125. 'nbf' => time(),
  126. 'exp' => time() + $ttl,
  127. ], $claims), self::getJwtKey(), 'HS512');
  128. }
  129. }