diff --git a/bootstrap/app.php b/bootstrap/app.php index 80031c967..ab8564b58 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -46,6 +46,7 @@ use Laravel\Passport\Http\Middleware\CheckTokenForAnyScope; use Laravel\Passport\Http\Middleware\CreateFreshApiToken; use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful; use League\OAuth2\Server\Exception\OAuthServerException; +use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; return Application::configure(basePath: dirname(__DIR__)) @@ -161,7 +162,18 @@ return Application::configure(basePath: dirname(__DIR__)) // A login-flow form submitted from a tab whose CSRF token is stale // (the session was regenerated by a login or a pending step elsewhere) // lands on wherever the session actually is instead of a 419. - $exceptions->render(function (TokenMismatchException $e, Request $request) { + // + // The handler rewrites TokenMismatchException into an HttpException(419) + // (keeping the original as the previous exception) in prepareException() + // before render callbacks run, so this must key on the rewritten type + // and identify the CSRF case via status code + previous exception. + // Returning null for anything else lets the JSON Throwable callback below + // continue to handle XHR/JSON login requests as a 419. + $exceptions->render(function (HttpException $e, Request $request) { + if ($e->getStatusCode() !== 419 || ! $e->getPrevious() instanceof TokenMismatchException) { + return null; + } + if ($request->wantsJson() || ! $request->is('login', 'login/*')) { return null; } diff --git a/tests/Feature/Auth/StaleCsrfLoginRecoveryTest.php b/tests/Feature/Auth/StaleCsrfLoginRecoveryTest.php new file mode 100644 index 000000000..fef4b9fa3 --- /dev/null +++ b/tests/Feature/Auth/StaleCsrfLoginRecoveryTest.php @@ -0,0 +1,114 @@ + 2fa / email verify) regenerates +| the session on each step transition, which invalidates the CSRF token held +| by other tabs / back-button forms. A stale-token POST to a login endpoint +| must be recovered into a friendly redirect back to the correct step instead +| of the stock Symfony 419 "Page Expired" page. +| +| The framework's exception handler rewrites TokenMismatchException into an +| HttpException(419) in prepareException() BEFORE user render callbacks are +| dispatched, so the recovery callback must be keyed on the rewritten type and +| identify the CSRF case via status code + previous exception. These tests +| drive the handler directly because PreventRequestForgery bypasses CSRF while +| running unit tests, so a kernel-level POST can never raise a real mismatch. +| +*/ + +/** + * Render an exception through the application's real exception handler for a + * given request, exercising prepareException() + renderViaCallbacks(). + */ +function renderThroughHandler(Request $request, Throwable $e): Response +{ + app()->instance('request', $request); + + return app(ExceptionHandler::class)->render($request, $e); +} + +/** + * Build a browser POST request to a login endpoint that carries the same kind + * of stale token a second tab would submit. Returns the request together with + * its Laravel session store so callers can seed pending-login state. + * + * @return array{0: Request, 1: Session} + */ +function stalePost(string $uri): array +{ + $session = app('session.store'); + $request = Request::create($uri, 'POST', ['_token' => 'stale-token']); + $request->headers->set('Accept', 'text/html'); + $request->setLaravelSession($session); + + return [$request, $session]; +} + +it('redirects a stale-CSRF browser POST /login to the login form with a message', function () { + [$request] = stalePost('/login'); + + $response = renderThroughHandler($request, new TokenMismatchException('CSRF token mismatch.')); + + expect($response)->toBeInstanceOf(RedirectResponse::class) + ->and($response->getStatusCode())->toBe(302) + ->and($response->getTargetUrl())->toContain('/login'); + + expect(session()->get('errors')?->get('login')) + ->toContain('Your sign-in session expired. Please sign in again.'); +}); + +it('redirects a stale-CSRF browser POST /login/2fa back to the pending step', function () { + $user = User::factory()->create(); + + [$request, $session] = stalePost('/login/2fa'); + $session->put(PendingLoginService::SESSION_KEY, [ + 'user_id' => $user->id, + 'email' => $user->email, + 'remember' => false, + 'step' => PendingLoginService::STEP_2FA, + 'attempts' => 0, + 'expires_at' => now()->addSeconds(PendingLoginService::TTL_SECONDS)->getTimestamp(), + ]); + + $response = renderThroughHandler($request, new TokenMismatchException('CSRF token mismatch.')); + + expect($response)->toBeInstanceOf(RedirectResponse::class) + ->and($response->getTargetUrl())->toContain('step='.PendingLoginService::STEP_2FA); +}); + +it('leaves a JSON/XHR stale-CSRF POST /login as a 419 for the JSON handler', function () { + $request = Request::create('/login', 'POST', ['_token' => 'stale-token']); + $request->headers->set('Accept', 'application/json'); + $request->headers->set('X-Requested-With', 'XMLHttpRequest'); + $request->setLaravelSession(app('session.store')); + + $response = renderThroughHandler($request, new TokenMismatchException('CSRF token mismatch.')); + + expect($response)->not->toBeInstanceOf(RedirectResponse::class) + ->and($response->getStatusCode())->toBe(419); +}); + +it('does not intercept a non-login stale-CSRF POST', function () { + [$request] = stalePost('/some/other/form'); + + $response = renderThroughHandler($request, new TokenMismatchException('CSRF token mismatch.')); + + expect($response)->not->toBeInstanceOf(RedirectResponse::class) + ->and($response->getStatusCode())->toBe(419); +});