diff --git a/bootstrap/app.php b/bootstrap/app.php index 5f4027d..6cd3efe 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,10 @@ redirectGuestsTo('/'); }) ->withExceptions(function (Exceptions $exceptions): void { - $isChatAjaxRequest = static function (Request $request): bool { - return $request->expectsJson() && $request->is( + $isJsonSessionRequest = static function (Request $request): bool { + if ($request->expectsJson() || $request->ajax()) { + return true; + } + + return $request->is( 'room/*/send', 'room/*/heartbeat', 'room/*/leave', @@ -51,25 +60,28 @@ return Application::configure(basePath: dirname(__DIR__)) ); }; - // 聊天室 AJAX 接口:CSRF token 过期(419)时,返回 JSON 提示而非重定向 - // 防止浏览器收到 302 后以 GET 方式重请求只允许 POST 的路由,产生 405 错误 - $exceptions->render(function (TokenMismatchException $e, Request $request) use ($isChatAjaxRequest) { - if ($isChatAjaxRequest($request)) { - return response()->json([ - 'status' => 'error', - 'message' => '页面已过期,请刷新后重试。', - ], 419); + $expiredSessionResponse = static function () { + return response()->json([ + 'status' => 'error', + 'code' => 'SESSION_EXPIRED', + 'message' => '登录状态已失效,请刷新页面后重新登录。', + 'reload' => true, + 'login_url' => route('home'), + ], 419); + }; + + // CSRF token 失效通常意味着页面还停留在旧会话里;JSON 请求统一返回业务提示,避免泄露框架异常堆栈。 + $exceptions->render(function (TokenMismatchException $e, Request $request) use ($expiredSessionResponse, $isJsonSessionRequest) { + if ($isJsonSessionRequest($request)) { + return $expiredSessionResponse(); } }); // Laravel 在某些环境下会先把 TokenMismatchException 包装成 419 HttpException, - // 这里补一层兜底,确保聊天接口始终返回稳定的 JSON,而不是默认 HTML 错误页。 - $exceptions->render(function (HttpExceptionInterface $e, Request $request) use ($isChatAjaxRequest) { - if ($e->getStatusCode() === 419 && $isChatAjaxRequest($request)) { - return response()->json([ - 'status' => 'error', - 'message' => '页面已过期,请刷新后重试。', - ], 419); + // 这里补一层兜底,确保接口始终返回稳定 JSON,而不是默认异常结构。 + $exceptions->render(function (HttpExceptionInterface $e, Request $request) use ($expiredSessionResponse, $isJsonSessionRequest) { + if ($e->getStatusCode() === 419 && $isJsonSessionRequest($request)) { + return $expiredSessionResponse(); } }); })->create(); diff --git a/tests/Feature/ChatControllerTest.php b/tests/Feature/ChatControllerTest.php index 00c48f1..8784ff6 100644 --- a/tests/Feature/ChatControllerTest.php +++ b/tests/Feature/ChatControllerTest.php @@ -25,6 +25,7 @@ use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; +use Illuminate\Session\TokenMismatchException; use Illuminate\Support\Facades\Broadcast; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Redis; @@ -779,10 +780,36 @@ class ChatControllerTest extends TestCase \Illuminate\Testing\TestResponse::fromBaseResponse($response)->assertStatus(419)->assertJson([ 'status' => 'error', - 'message' => '页面已过期,请刷新后重试。', + 'code' => 'SESSION_EXPIRED', + 'message' => '登录状态已失效,请刷新页面后重新登录。', + 'reload' => true, ]); } + /** + * 测试掉线后的普通 JSON 接口遇到 CSRF 失效时,不会泄露框架异常结构。 + */ + public function test_json_token_mismatch_exception_renders_session_expired_response(): void + { + $request = Request::create('/user/profile', 'POST', server: [ + 'HTTP_ACCEPT' => 'application/json', + ]); + + $response = $this->app->make(\Illuminate\Contracts\Debug\ExceptionHandler::class) + ->render($request, new TokenMismatchException('CSRF token mismatch.')); + + \Illuminate\Testing\TestResponse::fromBaseResponse($response) + ->assertStatus(419) + ->assertJson([ + 'status' => 'error', + 'code' => 'SESSION_EXPIRED', + 'message' => '登录状态已失效,请刷新页面后重新登录。', + 'reload' => true, + ]) + ->assertJsonMissingPath('exception') + ->assertJsonMissingPath('trace'); + } + /** * 测试房间公告更新广播中的动态内容会被转义。 */