Parcourir la source

feat: 新增 ai-model-token 端点,模型身份 token 收到 30 天并支持撤销

外部客户端(wikipali-write Skill)拿到该 token 后即可以「模型身份」
写入句子,使 editor_uid 记为模型 uid 而非操作者本人。

- GET /v2/ai-model-token/{uid}:签发,仅模型 owner 本人(canEdit);
- DELETE /v2/ai-model-token/{uid}:撤销该模型全部已签出 token。
  两者都记 ops 日志;
- 模型 token TTL 从 365 天收到 30 天(AI_MODEL_TOKEN_TTL),人类登录
  token 的 365 天不变。模型 token 会落到外部客户端的凭据文件里,
  泄漏面比人类登录 token 大得多;
- 撤销靠新增的 ai_models.token_version:自增后旧 token 里的 ver 对不上,
  AuthService::current() 即拒绝。校验只对带 typ=ai-model 的 payload
  查库,人类 token 不额外查;
- getUserToken() 改为先查 ai_models.uid。原来走 UserApi::getByUuid,
  它查不到用户会回落到 AiAssistantApi 的占位结构,分不清「模型」和
  「查无此人」,后者会签出一张 uid=0 的 token。

引入版本号之前签出的模型 token(无 typ/ver、id 恒为 0)一律失效。
名义上是破坏性变更,实际线上无存量凭据:仓库内部的 ai-translate、
Console 命令、AiTaskPrepare 都是每次任务现签现用。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda il y a 1 semaine
Parent
commit
2eb3eef599

+ 86 - 0
api-v13/app/Http/Controllers/AiModelTokenController.php

@@ -0,0 +1,86 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\AiModel;
+use App\Services\AuthService;
+use App\Tools\OpsLog;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+/**
+ * 签发 AI 模型的身份 token。
+ *
+ * 外部客户端(如 wikipali-write Skill)拿到该 token 后,即可以「模型身份」
+ * 调用写入类端点,使 editor_uid 记为模型 uid 而非操作者本人。
+ *
+ * @see docs/wikipali-write-skill-design.md §5.1
+ */
+class AiModelTokenController extends Controller
+{
+    /**
+     * 取得指定 AI 模型的 user token。
+     *
+     * 仅模型 owner 本人可调用(设计决策:模型只挂个人 studio,不支持 group studio)。
+     * 签出的 token 有效期 30 天,可用 destroy() 提前撤销;属高敏感凭据,故记入 ops 日志。
+     */
+    public function show(Request $request, AiModel $aiModel): JsonResponse
+    {
+        $user = AuthService::current($request);
+        if (! $user) {
+            return $this->error(__('auth.failed'), null, 401);
+        }
+        if (! AiModelController::canEdit($user['user_uid'], $aiModel->owner_id)) {
+            return $this->error(__('auth.failed'), null, 403);
+        }
+
+        $token = AuthService::getUserToken($aiModel->uid);
+        if (! $token) {
+            return $this->error('ai model not found', null, 404);
+        }
+
+        OpsLog::debug($user['user_uid'], [
+            'action' => 'ai-model-token.issue',
+            'model_uid' => $aiModel->uid,
+            'model_name' => $aiModel->name,
+        ]);
+
+        return $this->ok([
+            'uid' => $aiModel->uid,
+            'name' => $aiModel->name,
+            'token' => $token,
+        ]);
+    }
+
+    /**
+     * 撤销该模型已签出的全部身份 token。
+     *
+     * 版本号自增后,旧 token 里的 ver 立即对不上(见 AuthService::current())。
+     * 无法只撤销其中一张——凭据泄漏时本就该全部作废。
+     */
+    public function destroy(Request $request, AiModel $aiModel): JsonResponse
+    {
+        $user = AuthService::current($request);
+        if (! $user) {
+            return $this->error(__('auth.failed'), null, 401);
+        }
+        if (! AiModelController::canEdit($user['user_uid'], $aiModel->owner_id)) {
+            return $this->error(__('auth.failed'), null, 403);
+        }
+
+        $aiModel->increment('token_version');
+
+        OpsLog::debug($user['user_uid'], [
+            'action' => 'ai-model-token.revoke',
+            'model_uid' => $aiModel->uid,
+            'model_name' => $aiModel->name,
+            'token_version' => (int) $aiModel->token_version,
+        ]);
+
+        return $this->ok([
+            'uid' => $aiModel->uid,
+            'name' => $aiModel->name,
+            'token_version' => (int) $aiModel->token_version,
+        ]);
+    }
+}

+ 84 - 24
api-v13/app/Services/AuthService.php

@@ -3,43 +3,70 @@
 namespace App\Services;
 namespace App\Services;
 
 
 use App\Http\Api\UserApi;
 use App\Http\Api\UserApi;
-use App\Http\Api\AiAssistantApi;
-
-use Illuminate\Http\Request;
+use App\Models\AiModel;
 use Firebase\JWT\JWT;
 use Firebase\JWT\JWT;
 use Firebase\JWT\Key;
 use Firebase\JWT\Key;
+use Illuminate\Http\Request;
 
 
 class AuthService
 class AuthService
 {
 {
+    /**
+     * 人类用户登录 token 的有效期(秒)。
+     */
+    private const USER_TOKEN_TTL = 60 * 60 * 24 * 365;
+
+    /**
+     * AI 模型身份 token 的有效期(秒)。
+     *
+     * 模型 token 会被复制到外部客户端(如 wikipali-write Skill)的凭据文件里,
+     * 泄漏面比人类登录 token 大得多,故取远短的 30 天。
+     */
+    private const AI_MODEL_TOKEN_TTL = 60 * 60 * 24 * 30;
+
+    /**
+     * 模型身份 token 的 typ 标记。带此标记的 token 每次校验都要比对版本号,
+     * 人类 token 不带,避免为每个请求多查一次库。
+     */
+    private const AI_MODEL_TOKEN_TYPE = 'ai-model';
+
     public static function getUserToken(string $userUid)
     public static function getUserToken(string $userUid)
     {
     {
-        $user = UserApi::getByUuid($userUid);
-        if (!$user) {
-            $user = AiAssistantApi::getByUuid($userUid);
+        // 先判模型:UserApi::getByUuid() 查不到用户时会回落到 AiAssistantApi,
+        // 分不清「模型」和「查无此人」,而这两者签出的 token 完全不同。
+        $aiModel = AiModel::where('uid', $userUid)->first();
+        if ($aiModel) {
+            return self::encode([
+                'uid' => $aiModel->uid,
+                'id' => 0,
+                'typ' => self::AI_MODEL_TOKEN_TYPE,
+                'ver' => (int) $aiModel->token_version,
+            ], self::AI_MODEL_TOKEN_TTL);
         }
         }
-        if ($user) {
-            $ExpTime = time() + 60 * 60 * 24 * 365;
-            $key = self::getJwtKey();
-            $payload = [
-                'nbf' => time(),
-                'exp' => $ExpTime,
-                'uid' => $user['id'],
-                'id' => $user['sn'],
-            ];
-            $jwt = JWT::encode($payload, $key, 'HS512');
-            return $jwt;
+
+        $user = UserApi::getByUuid($userUid);
+        if (! $user || ! isset($user['sn'])) {
+            // 查无此人时 UserApi 返回的是 id=0 的占位结构,不能拿它签 token
+            return null;
         }
         }
-        return null;
+
+        return self::encode([
+            'uid' => $user['id'],
+            'id' => $user['sn'],
+        ], self::USER_TOKEN_TTL);
     }
     }
+
     public static function getJwtKey()
     public static function getJwtKey()
     {
     {
         return config('mint.app.jwt_secrets_key');
         return config('mint.app.jwt_secrets_key');
     }
     }
+
     public static function getToken(Request $request)
     public static function getToken(Request $request)
     {
     {
         $token = $request->bearerToken();
         $token = $request->bearerToken();
+
         return $token;
         return $token;
     }
     }
+
     public static function current(Request $request)
     public static function current(Request $request)
     {
     {
         $token = $request->bearerToken();
         $token = $request->bearerToken();
@@ -51,19 +78,52 @@ class AuthService
                 return false;
                 return false;
             }
             }
             if ($jwt->exp < time()) {
             if ($jwt->exp < time()) {
-                //过期
+                // 过期
+                return false;
+            }
+            if (! self::modelTokenIsValid($jwt)) {
                 return false;
                 return false;
-            } else {
-                //有效的token
-                return ['user_uid' => $jwt->uid, 'user_id' => $jwt->id];
             }
             }
-        } else if (isset($_COOKIE['user_uid'])) {
+
+            // 有效的token
+            return ['user_uid' => $jwt->uid, 'user_id' => $jwt->id];
+        } elseif (isset($_COOKIE['user_uid'])) {
             return [
             return [
                 'user_uid' => $_COOKIE['user_uid'],
                 'user_uid' => $_COOKIE['user_uid'],
-                'user_id' => $_COOKIE['user_id']
+                'user_id' => $_COOKIE['user_id'],
             ];
             ];
         } else {
         } else {
             return false;
             return false;
         }
         }
     }
     }
+
+    /**
+     * 校验模型身份 token 是否已被撤销。
+     *
+     * 撤销即把 ai_models.token_version 自增,旧 token 里的 ver 随即对不上。
+     * 模型被删除同样视为失效。人类 token(id 为用户自增主键,恒 > 0)直接放行,不查库。
+     */
+    private static function modelTokenIsValid(object $jwt): bool
+    {
+        if (isset($jwt->typ) && $jwt->typ === self::AI_MODEL_TOKEN_TYPE) {
+            $version = AiModel::where('uid', $jwt->uid)->value('token_version');
+
+            return $version !== null && (int) $version === (int) ($jwt->ver ?? 0);
+        }
+
+        // 引入版本号之前签出的模型 token(typ 缺失、id 恒为 0)无法撤销,一律作废,
+        // 持有者须重新签发。
+        return (int) ($jwt->id ?? 0) !== 0;
+    }
+
+    /**
+     * @param  array<string, mixed>  $claims
+     */
+    private static function encode(array $claims, int $ttl): string
+    {
+        return JWT::encode(array_merge([
+            'nbf' => time(),
+            'exp' => time() + $ttl,
+        ], $claims), self::getJwtKey(), 'HS512');
+    }
 }
 }

+ 30 - 0
api-v13/database/migrations/2026_08_05_110345_add_token_version_to_ai_models_table.php

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * 模型身份 token 的版本号。owner 撤销时自增,已签出 token 里的 ver 对不上即失效。
+     */
+    public function up(): void
+    {
+        Schema::table('ai_models', function (Blueprint $table) {
+            $table->unsignedInteger('token_version')
+                ->default(1)
+                ->comment('身份 token 版本号,自增即撤销该模型全部已签出 token');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('ai_models', function (Blueprint $table) {
+            $table->dropColumn('token_version');
+        });
+    }
+};

+ 3 - 0
api-v13/routes/api.php

@@ -3,6 +3,7 @@
 use App\Http\Controllers\AccessTokenController;
 use App\Http\Controllers\AccessTokenController;
 use App\Http\Controllers\AiAssistantController;
 use App\Http\Controllers\AiAssistantController;
 use App\Http\Controllers\AiModelController;
 use App\Http\Controllers\AiModelController;
+use App\Http\Controllers\AiModelTokenController;
 use App\Http\Controllers\AiTranslateController;
 use App\Http\Controllers\AiTranslateController;
 use App\Http\Controllers\ApiController;
 use App\Http\Controllers\ApiController;
 use App\Http\Controllers\ArticleController;
 use App\Http\Controllers\ArticleController;
@@ -299,6 +300,8 @@ Route::group([
     Route::apiResource('access-token', AccessTokenController::class);
     Route::apiResource('access-token', AccessTokenController::class);
     Route::apiResource('search-word-slice', SearchWordSliceController::class);
     Route::apiResource('search-word-slice', SearchWordSliceController::class);
     Route::apiResource('ai-model', AiModelController::class);
     Route::apiResource('ai-model', AiModelController::class);
+    Route::get('ai-model-token/{ai_model}', [AiModelTokenController::class, 'show']);
+    Route::delete('ai-model-token/{ai_model}', [AiModelTokenController::class, 'destroy']);
     Route::apiResource('ai-assistant', AiAssistantController::class);
     Route::apiResource('ai-assistant', AiAssistantController::class);
     Route::apiResource('model-log', ModelLogController::class);
     Route::apiResource('model-log', ModelLogController::class);
     Route::apiResource('sentence-attachment', SentenceAttachmentController::class);
     Route::apiResource('sentence-attachment', SentenceAttachmentController::class);

+ 139 - 0
api-v13/tests/Feature/AiModelTokenTest.php

@@ -0,0 +1,139 @@
+<?php
+
+use App\Models\AiModel;
+use Firebase\JWT\JWT;
+use Firebase\JWT\Key;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+it('rejects an anonymous request', function () {
+    $model = AiModel::factory()->create();
+
+    $this->getJson("/api/v2/ai-model-token/{$model->uid}")
+        ->assertStatus(401);
+});
+
+it('rejects a user who does not own the model', function () {
+    $model = AiModel::factory()->create();
+
+    $this->getJson(
+        "/api/v2/ai-model-token/{$model->uid}",
+        authHeader((string) Str::uuid())
+    )->assertStatus(403);
+});
+
+it('issues a token to the owner', function () {
+    $owner = (string) Str::uuid();
+    $model = AiModel::factory()->ownedBy($owner)->create(['name' => 'claude-opus-5']);
+
+    $response = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($owner))
+        ->assertOk()
+        ->assertJsonPath('data.uid', $model->uid)
+        ->assertJsonPath('data.name', 'claude-opus-5');
+
+    // 关键断言:签出的 token 代表「模型」而非发起请求的用户。
+    // 用它写句子时,editor_uid 才会记成模型 uid。
+    $jwt = JWT::decode(
+        $response->json('data.token'),
+        new Key(config('mint.app.jwt_secrets_key'), 'HS512')
+    );
+    expect($jwt->uid)->toBe($model->uid);
+    expect($jwt->exp)->toBeGreaterThan(time());
+});
+
+it('issues a model token that expires in 30 days', function () {
+    $owner = (string) Str::uuid();
+    $model = AiModel::factory()->ownedBy($owner)->create();
+
+    $response = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($owner))
+        ->assertOk();
+
+    $jwt = decodeToken($response->json('data.token'));
+
+    // 30 天,不是人类登录 token 的 365 天
+    expect($jwt->exp - time())->toBeLessThanOrEqual(60 * 60 * 24 * 30)
+        ->and($jwt->exp - time())->toBeGreaterThan(60 * 60 * 24 * 29);
+    expect($jwt->typ)->toBe('ai-model');
+    expect($jwt->ver)->toBe(1);
+});
+
+it('404s on an unknown model', function () {
+    $this->getJson(
+        '/api/v2/ai-model-token/'.Str::uuid(),
+        authHeader((string) Str::uuid())
+    )->assertStatus(404);
+});
+
+it('rejects an anonymous revoke', function () {
+    $model = AiModel::factory()->create();
+
+    $this->deleteJson("/api/v2/ai-model-token/{$model->uid}")
+        ->assertStatus(401);
+});
+
+it('rejects a revoke from a user who does not own the model', function () {
+    $model = AiModel::factory()->create();
+
+    $this->deleteJson(
+        "/api/v2/ai-model-token/{$model->uid}",
+        [],
+        authHeader((string) Str::uuid())
+    )->assertStatus(403);
+
+    expect(AiModel::where('uid', $model->uid)->value('token_version'))->toBe(1);
+});
+
+it('invalidates issued tokens when the owner revokes them', function () {
+    $owner = (string) Str::uuid();
+    $model = AiModel::factory()->ownedBy($owner)->create();
+
+    $token = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($owner))
+        ->assertOk()
+        ->json('data.token');
+
+    expect(currentUid($token))->toBe($model->uid);
+
+    $this->deleteJson("/api/v2/ai-model-token/{$model->uid}", [], authHeader($owner))
+        ->assertOk()
+        ->assertJsonPath('data.token_version', 2);
+
+    // 撤销后旧 token 立刻失效,尽管它的 exp 还在 30 天后
+    expect(currentUid($token))->toBeFalse();
+
+    // 重新签发的 token 带新版本号,可用
+    $fresh = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($owner))
+        ->json('data.token');
+    expect(decodeToken($fresh)->ver)->toBe(2);
+    expect(currentUid($fresh))->toBe($model->uid);
+});
+
+it('rejects a model token whose model has been deleted', function () {
+    $owner = (string) Str::uuid();
+    $model = AiModel::factory()->ownedBy($owner)->create();
+
+    $token = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($owner))
+        ->json('data.token');
+
+    AiModel::where('uid', $model->uid)->delete();
+
+    expect(currentUid($token))->toBeFalse();
+});
+
+it('rejects pre-versioning model tokens', function () {
+    // 引入 token_version 之前签出的模型 token:没有 typ/ver,id 恒为 0,无法撤销
+    $model = AiModel::factory()->create();
+    $legacy = JWT::encode([
+        'nbf' => time(),
+        'exp' => time() + 3600,
+        'uid' => $model->uid,
+        'id' => 0,
+    ], config('mint.app.jwt_secrets_key'), 'HS512');
+
+    expect(currentUid($legacy))->toBeFalse();
+});
+
+it('leaves human tokens alone', function () {
+    expect(currentUid(userToken('a-user-uid', 42)))->toBe('a-user-uid');
+});

+ 144 - 0
api-v13/tests/Feature/WriteAsAiModelTest.php

@@ -0,0 +1,144 @@
+<?php
+
+use App\Models\AiModel;
+use App\Models\Sentence;
+use Firebase\JWT\JWT;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+/**
+ * 端到端串起 wikipali-write Skill 的完整写入链路:
+ * 建模型 → 取 model token → 签 channel access token → 以模型身份写句子。
+ *
+ * 这条链路的意义全在最后一个断言上:句子的 editor_uid 必须是模型 uid,
+ * 而不是发起操作的人类用户,否则 AI 署名与审计就是假的。
+ */
+it('writes a sentence attributed to the ai model, not the human operator', function () {
+    $human = makeStudio('tester');
+    $channel = makeChannel($human);
+    $model = AiModel::factory()->ownedBy($human)->create(['name' => 'claude-opus-5']);
+
+    // 1. 人类身份签出 channel 的 access token
+    $accessToken = $this->postJson('/api/v2/access-token', [
+        'payload' => [[
+            'res_type' => 'channel',
+            'res_id' => $channel,
+            'power' => 'edit',
+            // book 必须是整数:UserCanEdit 用 !== 严格比较,"0" 会恒不等
+            'book' => 0,
+        ]],
+    ], authHeader($human))
+        ->assertOk()
+        ->assertJsonPath('data.count', 1)
+        ->json('data.rows.0.token');
+
+    // 2. 人类身份取模型的身份 token
+    $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
+        ->assertOk()
+        ->json('data.token');
+
+    // 3. 以「模型身份」写句子:Authorization 是 modelToken,句内带 accessToken
+    $this->postJson('/api/v2/sentence', [
+        'sentences' => [[
+            'book_id' => 1,
+            'paragraph' => 10,
+            'word_start' => 0,
+            'word_end' => 12,
+            'channel_uid' => $channel,
+            'content' => '这是 AI 写入的译文',
+            'content_type' => 'markdown',
+            'access_token' => $accessToken,
+        ]],
+    ], ['Authorization' => 'Bearer '.$modelToken])
+        ->assertOk()
+        ->assertJsonPath('data.count', 1);
+
+    $saved = Sentence::where('channel_uid', $channel)->first();
+
+    expect($saved)->not->toBeNull();
+    expect($saved->content)->toBe('这是 AI 写入的译文');
+    // 核心断言:署名归模型
+    expect($saved->editor_uid)->toBe($model->uid);
+    expect($saved->editor_uid)->not->toBe($human);
+});
+
+it('refuses the write when the access token is for a different channel', function () {
+    $human = makeStudio('tester');
+    $channel = makeChannel($human, 'mine');
+    $otherChannel = makeChannel(makeStudio('someone-else'), 'not mine');
+    $model = AiModel::factory()->ownedBy($human)->create();
+
+    $accessToken = $this->postJson('/api/v2/access-token', [
+        'payload' => [[
+            'res_type' => 'channel',
+            'res_id' => $channel,
+            'power' => 'edit',
+            'book' => 0,
+        ]],
+    ], authHeader($human))->json('data.rows.0.token');
+
+    $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
+        ->json('data.token');
+
+    // 拿 A channel 的 token 去写 B channel:逐句静默跳过,count 为 0
+    $this->postJson('/api/v2/sentence', [
+        'sentences' => [[
+            'book_id' => 1,
+            'paragraph' => 10,
+            'word_start' => 0,
+            'word_end' => 12,
+            'channel_uid' => $otherChannel,
+            'content' => 'should not land',
+            'access_token' => $accessToken,
+        ]],
+    ], ['Authorization' => 'Bearer '.$modelToken])
+        ->assertOk()
+        ->assertJsonPath('data.count', 0);
+
+    expect(Sentence::count())->toBe(0);
+});
+
+it('rejects an expired access token with 403 instead of 500', function () {
+    $human = makeStudio('tester');
+    $channel = makeChannel($human);
+    $model = AiModel::factory()->ownedBy($human)->create();
+
+    $accessToken = $this->postJson('/api/v2/access-token', [
+        'payload' => [[
+            'res_type' => 'channel',
+            'res_id' => $channel,
+            'power' => 'edit',
+            'book' => 0,
+        ]],
+    ], authHeader($human))->json('data.rows.0.token');
+
+    $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
+        ->json('data.token');
+
+    // 把时钟拨到 7 天有效期之后。
+    // 注意不能用 $this->travel():那只动 Carbon,而 JWT::decode 读的是 PHP 的 time(),
+    // 得改 JWT::$timestamp 这个专供测试的静态覆盖点。
+    // model token(365 天)在 +8 天时仍然有效,所以这里过期的只有 access token。
+    JWT::$timestamp = time() + 8 * 24 * 60 * 60;
+
+    try {
+        $this->postJson('/api/v2/sentence', [
+            'sentences' => [[
+                'book_id' => 1,
+                'paragraph' => 10,
+                'word_start' => 0,
+                'word_end' => 12,
+                'channel_uid' => $channel,
+                'content' => 'expired',
+                'access_token' => $accessToken,
+            ]],
+        ], ['Authorization' => 'Bearer '.$modelToken])
+            ->assertOk()
+            ->assertJsonPath('data.count', 0);
+    } finally {
+        JWT::$timestamp = null;
+    }
+
+    expect(Sentence::count())->toBe(0);
+});