Răsfoiți Sursa

fix(api-v13): 删除 v1 遗留的 cookie 认证绕过

AuthService::current() 里有一条 cookie 回落:拿不到 bearer 就读
$_COOKIE['user_uid'] 直接当成已登录用户返回。它不验签名、不验过期、不查库,
浏览器里手工种一个就能冒充任何人,包括 root;而且没有过期概念。

写入方全部在 api-v8(v1 时代,已冻结不运行),api-v13 与两个前端都不再种这个
cookie(只种 timezone 和 language),所以这是条只读不写的孤儿路径。

另有四处控制器绕开 AuthService 直接读 $_COOKIE,逐个核实过死活:

- LikeController@delete(DELETE /v2/like,无 id):死端点。两前端只用
  POST /v2/like 与 DELETE /v2/like/{id};铁律 5 要求枚举的那两处动态调用
  都是 get<>(),GET 碰不到 DELETE 路由。方法与路由整条删除。
- SentenceController view=fulltext:死 view,且未登录时 $userUid 是未定义
  变量。改走 AuthService。
- UserDictController view=user:死 view。同一个 switch 上面的 studio 分支
  早就用 $user['user_id'],这条是漏改的;$user 在该分支里还未定义,一并补。
- UserDictController@delete:死端点(删除都走 /{id} 的 destroy)。按原有
  FIXME 改走 AuthService。
- ProgressChapterController view=chapter_channels:唯一活着的一处,v4/v6 的
  PaliChapterChannelList 都在调。改走 AuthService,并把登录判定提到循环外。

行为变化:chapter_channels 的 selected 字段以前只有揣着 v1 cookie 的浏览器
拿得到,现在带 bearer 的登录用户才拿得到(铁律 1:加字段是安全的)。

写 @delete 的成功路径测试时撞到一个与 cookie 无关的既有 bug——update_sys_wbw()
插入 user_dicts 时 dict_id / editor_id(uuid 列)拿到 "0",直接 500。这条端点
从没被真正跑过,所以一直藏着。已在方法上留 FIXME,未读懂那段汇总逻辑前不动。

测试:新增 AuthServiceCookieTest(5 条)与 V1CookieRemovalTest(7 条)。带
cookie 的用例必须直接操作 $_COOKIE 超全局——withUnencryptedCookies() 只填
Symfony Request 的 cookie 袋子,不碰超全局,用它写出来的 cookie 测试是空转的。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 3 săptămâni în urmă
părinte
comite
318ffb6c2a

+ 0 - 20
api-v13/app/Http/Controllers/LikeController.php

@@ -157,24 +157,4 @@ class LikeController extends Controller
             return $this->error(_('auth.failed'), 403, 403);
         }
     }
-
-    public function delete(Request $request)
-    {
-        if (! isset($_COOKIE['user_uid'])) {
-            return $this->error('no login');
-        }
-        $param = [
-            'id' => $request->input('id'),
-            'user_id' => $_COOKIE['user_uid'],
-        ];
-        $del = Like::where($param)->delete();
-        $count = Like::where('target_id', $request->input('target_id'))
-            ->where('type', $request->input('type'))
-            ->count();
-
-        return $this->ok([
-            'deleted' => $del,
-            'count' => $count,
-        ]);
-    }
 }

+ 9 - 2
api-v13/app/Http/Controllers/ProgressChapterController.php

@@ -10,6 +10,7 @@ use App\Models\ProgressChapter;
 use App\Models\Tag;
 use App\Models\TagMap;
 use App\Models\View;
+use App\Services\AuthService;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
 use Illuminate\Support\Facades\Cache;
@@ -208,6 +209,8 @@ class ProgressChapterController extends Controller
                     ->where('para', $request->input('par'))
                     ->orderBy('progress', 'desc')
                     ->get();
+                // 整个循环共用一次登录判定,不必每行都解一次 JWT
+                $currentUser = AuthService::current($request);
                 foreach ($chapters as $key => $value) {
                     // code...
                     $chapters[$key]->views = View::where('target_id', $value->uid)->count();
@@ -217,13 +220,17 @@ class ProgressChapterController extends Controller
                         ->select('type')
                         ->selectRaw('count(*)')
                         ->get();
-                    if (isset($_COOKIE['user_uid'])) {
+                    // 原先凭据取自 $_COOKIE['user_uid'](v1 遗留的认证绕过)。这段只
+                    // 决定 selected 这个可选字段出不出现,改走 AuthService 后带 bearer
+                    // 的登录用户才拿得到——以前只有揣着 v1 cookie 的浏览器有。
+                    // FIXME: 这里是 N+1,每个 chapter × 每个 type 各查一次。
+                    if ($currentUser) {
                         foreach ($likes as $key1 => $like) {
                             // 查看这些点赞里有没有我点的
                             $myLikeId = Like::where([
                                 'target_id' => $value->uid,
                                 'type' => $like->type,
-                                'user_id' => $_COOKIE['user_uid'],
+                                'user_id' => $currentUser['user_uid'],
                             ])->value('id');
                             if ($myLikeId) {
                                 $likes[$key1]->selected = $myLikeId;

+ 9 - 5
api-v13/app/Http/Controllers/SentenceController.php

@@ -77,16 +77,20 @@ class SentenceController extends Controller
                     ->where('updated_at', '>', $request->input('updated_after', '1970-1-1'));
                 break;
             case 'fulltext':
-                if (isset($_COOKIE['user_uid'])) {
-                    $userUid = $_COOKIE['user_uid'];
+                // 原先凭据取自 $_COOKIE['user_uid'](v1 遗留的认证绕过),而且没有
+                // cookie 时 $userUid 根本没被赋值——未登录访问这个 view 会拿未定义
+                // 变量去查询。改走 AuthService,未登录明确报错。
+                $user = AuthService::current($request);
+                if (! $user) {
+                    return $this->error(__('auth.failed'));
                 }
                 $key = $request->input('key');
                 if (empty($key)) {
                     return $this->error('没有关键词');
                 }
                 $table = Sentence::select($indexCol)
-                    ->where('content', 'like', '%' . $key . '%')
-                    ->where('editor_uid', $userUid);
+                    ->where('content', 'like', '%'.$key.'%')
+                    ->where('editor_uid', $user['user_uid']);
 
                 break;
             case 'channel':
@@ -203,7 +207,7 @@ class SentenceController extends Controller
                 break;
         }
         if (! empty($request->input('key'))) {
-            $table = $table->where('content', 'like', '%' . $request->input('key') . '%');
+            $table = $table->where('content', 'like', '%'.$request->input('key').'%');
         }
 
         $count = $table->count();

+ 21 - 5
api-v13/app/Http/Controllers/UserDictController.php

@@ -78,9 +78,15 @@ class UserDictController extends Controller
                     ->whereIn('source', ['_USER_WBW_', '_USER_DICT_']);
                 break;
             case 'user':
-                // code...
+                // 原先凭据取自 $_COOKIE['user_id'](v1 遗留的认证绕过)。同一个 switch
+                // 上面的 studio 分支早就用的是 $user['user_id'],这条是漏改的。
+                // $user 是各分支各自解析的(case 之间不共享),这里要自己解一次。
+                $user = AuthService::current($request);
+                if (! $user) {
+                    return $this->error(__('auth.failed'));
+                }
                 $table = UserDict::select($indexCol)
-                    ->where('creator_id', $_COOKIE['user_id'])
+                    ->where('creator_id', $user['user_id'])
                     ->where('source', '<>', '_SYS_USER_WBW_');
                 break;
             case 'word':
@@ -365,8 +371,18 @@ class UserDictController extends Controller
      */
     public function delete(Request $request)
     {
-        // FIXME: 本方法不走 AuthService,凭据直接取自 $_COOKIE['user_id'](见下方 $param),
-        // 未登录时会 undefined array key。应改用 AuthService::current($request) 并在未登录时返回 401。
+        // 原先凭据取自 $_COOKIE['user_id'](v1 遗留的认证绕过),未登录时还会
+        // undefined array key。前端的删除走的是 DELETE /v2/userdict/{id}(destroy),
+        // 没人调这条,但既然留着就得让它是安全的。
+        //
+        // FIXME: 这条端点是死的(两个前端都无调用),而且成功路径本来就是坏的——
+        // 2026-09-24 第一次真跑它,update_sys_wbw() 往 user_dicts 插入时
+        // dict_id / editor_id(uuid 列)拿到 "0",直接 500。修它超出「清除 cookie
+        // 绕过」的范围,没有读懂那段汇总逻辑之前不动。要么补对那两列,要么整条删掉。
+        $user = AuthService::current($request);
+        if (! $user) {
+            return $this->error(__('auth.failed'));
+        }
         $arrId = json_decode($request->input('id'), true);
         $count = 0;
         $updateOk = false;
@@ -376,7 +392,7 @@ class UserDictController extends Controller
                 // 找到对应数据
                 $param = [
                     'id' => $id,
-                    'creator_id' => $_COOKIE['user_id'],
+                    'creator_id' => $user['user_id'],
                 ];
                 $del = UserDict::where($param)->delete();
                 $count += $del;

+ 13 - 7
api-v13/app/Services/AuthService.php

@@ -88,14 +88,20 @@ class AuthService
 
             // 有效的token
             return ['user_uid' => $jwt->uid, 'user_id' => $jwt->id];
-        } elseif (isset($_COOKIE['user_uid'])) {
-            return [
-                'user_uid' => $_COOKIE['user_uid'],
-                'user_id' => $_COOKIE['user_id'],
-            ];
-        } else {
-            return false;
         }
+
+        // 这里曾经有一条 cookie 回落:拿不到 bearer 就读 $_COOKIE['user_uid'] 并
+        // 直接当成已登录用户返回。那是 v1(api-v8)时代的遗留,而且是个认证绕过——
+        // 它不验签名、不验过期、不查库,浏览器里手工种一个 user_uid 就能冒充任何人。
+        // api-v13 与两个前端都已不再种这个 cookie(只种 timezone 和 language),
+        // 所以这条分支是只读不写的孤儿路径,2026-09-24 删除。
+        //
+        // 仍有少数 v2 控制器绕开本方法直接读 $_COOKIE,删它们是各自独立的工作:
+        //   SentenceController@index(view=fulltext)
+        //   ProgressChapterController@index
+        //   LikeController@delete
+        //   UserDictController@index(view=user)与 @delete
+        return false;
     }
 
     /**

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

@@ -171,7 +171,6 @@ Route::group([
     Route::apiResource('tag', TagController::class);
     Route::apiResource('view', ViewController::class);
 
-    Route::delete('like', [LikeController::class, 'delete']);
     Route::apiResource('like', LikeController::class);
     Route::apiResource('sent_history', SentHistoryController::class);
     Route::get('sent_history_contribution', [SentHistoryController::class, 'contribution']);

+ 80 - 0
api-v13/tests/Feature/AuthServiceCookieTest.php

@@ -0,0 +1,80 @@
+<?php
+
+use App\Services\AuthService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * `AuthService::current()` 不再认 cookie。
+ *
+ * 曾经的那条回落(拿不到 bearer 就读 `$_COOKIE['user_uid']` 并直接返回)是 v1
+ * 时代的遗留,不验签名、不验过期、不查库——浏览器里手工种一个就能冒充任何人。
+ *
+ * **必须直接操作 `$_COOKIE` 超全局。** Laravel 的 `withUnencryptedCookies()` 只填
+ * Symfony Request 的 cookie 袋子,不碰超全局(`Request::create()` 不调
+ * `overrideGlobals()`),所以用那个助手写出来的"cookie 测试"是空转的——
+ * 不管代码里有没有这条分支都会过。
+ */
+function withCookieSuperglobal(array $cookies, callable $fn): mixed
+{
+    $backup = $_COOKIE;
+    $_COOKIE = $cookies;
+    try {
+        return $fn();
+    } finally {
+        $_COOKIE = $backup;
+    }
+}
+
+it('authenticates a valid bearer token', function () {
+    $uid = (string) Str::uuid();
+
+    $user = AuthService::current(Request::create('/', 'GET', server: [
+        'HTTP_AUTHORIZATION' => 'Bearer '.userToken($uid, 7),
+    ]));
+
+    expect($user)->toBe(['user_uid' => $uid, 'user_id' => 7]);
+});
+
+it('rejects a cookie with no bearer token', function () {
+    $result = withCookieSuperglobal(
+        ['user_uid' => (string) Str::uuid(), 'user_id' => '1'],
+        fn () => AuthService::current(Request::create('/', 'GET'))
+    );
+
+    expect($result)->toBeFalse();
+});
+
+it('ignores the cookie even when the bearer token is invalid', function () {
+    // 旧代码是 if(bearer){...} elseif(cookie){...},bearer 无效时走的是 if 分支
+    // 里的 return false,本来就到不了 cookie;这条守的是将来别有人把顺序改回来
+    $result = withCookieSuperglobal(
+        ['user_uid' => (string) Str::uuid(), 'user_id' => '1'],
+        fn () => AuthService::current(Request::create('/', 'GET', server: [
+            'HTTP_AUTHORIZATION' => 'Bearer not-a-jwt',
+        ]))
+    );
+
+    expect($result)->toBeFalse();
+});
+
+it('rejects a request with neither bearer nor cookie', function () {
+    expect(AuthService::current(Request::create('/', 'GET')))->toBeFalse();
+});
+
+it('keeps v2 endpoints unreachable by cookie', function () {
+    // 端到端:v2 的 like 列表需要登录才会富化 selected;带着 cookie 打过去,
+    // 现在拿不到登录态了
+    $uid = makeStudio('cookie-v2');
+    $target = (string) Str::uuid();
+
+    $json = withCookieSuperglobal(
+        ['user_uid' => $uid, 'user_id' => '1'],
+        fn () => $this->getJson("/api/v2/like?view=count&target_id={$target}")->json()
+    );
+
+    expect($json['ok'])->toBeTrue();
+});

+ 94 - 0
api-v13/tests/Feature/V1CookieRemovalTest.php

@@ -0,0 +1,94 @@
+<?php
+
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * v1 时代的 cookie 认证绕过已从 v2 控制器里清干净。
+ *
+ * 这几条是 characterization test:改动的是冻结中的 v2,必须钉住「未登录时明确
+ * 被拒」和「带 bearer 时照常工作」两件事,而不是靠 cookie 悄悄放行。
+ *
+ * 带 cookie 的用例必须直接操作 `$_COOKIE` 超全局——`withUnencryptedCookies()`
+ * 只填 Symfony Request 的 cookie 袋子,不碰超全局,用它写出来的测试是空转的。
+ */
+function withV1Cookie(array $cookies, callable $fn): mixed
+{
+    $backup = $_COOKIE;
+    $_COOKIE = $cookies;
+    try {
+        return $fn();
+    } finally {
+        $_COOKIE = $backup;
+    }
+}
+
+it('removed the dead DELETE /v2/like route', function () {
+    // 前端只用 POST /v2/like 与 DELETE /v2/like/{id};这条无 id 的 DELETE 没人调,
+    // 而且 cookie 是它唯一的认证方式,已整条删除
+    $this->deleteJson('/api/v2/like')->assertStatus(405);
+});
+
+it('keeps the like routes the frontends actually use', function () {
+    $target = (string) Str::uuid();
+
+    $this->getJson("/api/v2/like?view=count&target_id={$target}")
+        ->assertOk()
+        ->assertJsonPath('ok', true);
+});
+
+it('rejects sentence fulltext without a bearer token', function () {
+    // 这个 view 以前靠 cookie,且未登录时 $userUid 是未定义变量
+    // $this->error() 默认 400,这是 v2 既有的失败口径,不是这次改出来的
+    $json = withV1Cookie(['user_uid' => (string) Str::uuid()], fn () => $this
+        ->getJson('/api/v2/sentence?view=fulltext&key=abc')
+        ->assertStatus(400)
+        ->json());
+
+    expect($json['ok'])->toBeFalse()
+        ->and($json['message'])->toBe(__('auth.failed'));
+});
+
+it('serves sentence fulltext to a bearer-authenticated user', function () {
+    $uid = makeStudio('fulltext-user');
+
+    $json = $this->getJson('/api/v2/sentence?view=fulltext&key=abc', authHeader($uid))
+        ->assertOk()
+        ->json();
+
+    expect($json['ok'])->toBeTrue()
+        ->and($json['data'])->toHaveKey('rows');
+});
+
+it('rejects userdict view=user without a bearer token', function () {
+    $json = withV1Cookie(['user_id' => '1'], fn () => $this
+        ->getJson('/api/v2/userdict?view=user')
+        ->assertStatus(400)
+        ->json());
+
+    expect($json['ok'])->toBeFalse()
+        ->and($json['message'])->toBe(__('auth.failed'));
+});
+
+it('serves userdict view=user to a bearer-authenticated user', function () {
+    $uid = makeStudio('userdict-user');
+
+    $json = $this->getJson('/api/v2/userdict?view=user', authHeader($uid))
+        ->assertOk()
+        ->json();
+
+    expect($json['ok'])->toBeTrue()
+        ->and($json['data'])->toHaveKey('rows');
+});
+
+it('rejects DELETE /v2/userdict without a bearer token', function () {
+    $json = withV1Cookie(['user_id' => '1'], fn () => $this
+        ->deleteJson('/api/v2/userdict', ['id' => json_encode(['1'])])
+        ->assertStatus(400)
+        ->json());
+
+    expect($json['ok'])->toBeFalse()
+        ->and($json['message'])->toBe(__('auth.failed'));
+});

+ 0 - 0
api-v13/tests/Unit/TipitakaReadChapterSliceTest.php → api-v13/tests/Unit/ChannelBookChapterSliceTest.php