diff --git a/packages/admin-panel/src/Controller/LoginController.php b/packages/admin-panel/src/Controller/LoginController.php index a26911c8c..7d6a239c9 100644 --- a/packages/admin-panel/src/Controller/LoginController.php +++ b/packages/admin-panel/src/Controller/LoginController.php @@ -76,6 +76,7 @@ public function authenticate( return $this->view->render('admin-panel::auth/login', [ 'loginUrl' => $this->adminConfig->getRoutePrefix() . '/login', + 'csrfToken' => $this->csrfTokenManager->get(), 'error' => 'Invalid email or password.', ]); } diff --git a/packages/admin-panel/tests/Feature/LoginRerenderCsrfTest.php b/packages/admin-panel/tests/Feature/LoginRerenderCsrfTest.php new file mode 100644 index 000000000..f70d26bd8 --- /dev/null +++ b/packages/admin-panel/tests/Feature/LoginRerenderCsrfTest.php @@ -0,0 +1,150 @@ +renderToString($template, $data)); + } + + public function renderToString( + string $template, + array $data = [], + ): string { + $token = htmlspecialchars((string) ($data['csrfToken'] ?? ''), ENT_QUOTES); + $error = htmlspecialchars((string) ($data['error'] ?? ''), ENT_QUOTES); + + return '
' . $error . '
' + . '
'; + } +} + +readonly class PlainEncryptor implements EncryptorInterface +{ + public function encrypt( + string $value, + string $aad = '', + ): string { + return bin2hex($value); + } + + public function decrypt( + string $encrypted, + string $aad = '', + ): string { + return (string) hex2bin($encrypted); + } +} + +readonly class StaticAdminConfig implements AdminConfigInterface +{ + public function getRoutePrefix(): string + { + return '/admin'; + } + + public function getName(): string + { + return 'Admin'; + } +} + +/** + * Accepts only the admin@example.com / correct-password pair. + */ +class PasswordCheckingGuard extends FakeGuard +{ + public function attempt( + array $credentials, + ): bool { + return $credentials['email'] === 'admin@example.com' + && $credentials['password'] === 'correct-password'; + } +} + +function tokenFromForm( + Response $response, +): string { + preg_match('/name="_token" value="([^"]*)"/', $response->body(), $matches); + + return $matches[1] ?? ''; +} + +it( + 're-renders the login form with the session CSRF token after a failed login so the retry is accepted', + function (): void { + $session = new FakeSession(); + $session->start(); + $tokenManager = new CsrfTokenManager($session, new PlainEncryptor()); + $csrf = new CsrfMiddleware( + tokenManager: $tokenManager, + session: $session, + sessionConfig: new SessionConfig(new FakeConfigRepository([ + 'session.cookie.name' => 'marko_session', + 'session.cookie.path' => '/', + 'session.cookie.domain' => '', + 'session.cookie.secure' => true, + ])), + ); + $controller = new LoginController( + view: new TokenFieldView(), + adminGuard: new FixedAdminGuardResolver(new PasswordCheckingGuard(name: 'admin')), + adminConfig: new StaticAdminConfig(), + csrfTokenManager: $tokenManager, + ); + + $form = $csrf->handle( + new Request(server: ['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/admin/login']), + fn (Request $request): Response => $controller->showLoginForm($request), + ); + $initialToken = tokenFromForm($form); + + $failed = $csrf->handle( + new Request( + server: ['REQUEST_METHOD' => 'POST', 'REQUEST_URI' => '/admin/login'], + post: ['_token' => $initialToken, 'email' => 'admin@example.com', 'password' => 'wrong-password'], + ), + fn (Request $request): Response => $controller->authenticate($request), + ); + $rerenderedToken = tokenFromForm($failed); + + $retry = $csrf->handle( + new Request( + server: ['REQUEST_METHOD' => 'POST', 'REQUEST_URI' => '/admin/login'], + post: ['_token' => $rerenderedToken, 'email' => 'admin@example.com', 'password' => 'correct-password'], + ), + fn (Request $request): Response => $controller->authenticate($request), + ); + + expect($initialToken)->not->toBe('') + ->and($failed->statusCode())->toBe(200) + ->and($failed->body())->toContain('Invalid email or password.') + ->and($rerenderedToken)->toBe($initialToken) + ->and($retry->statusCode())->toBe(302) + ->and($retry->headers()['Location'])->toBe('/admin'); + }, +); diff --git a/packages/admin-panel/tests/Unit/Controller/LoginControllerTest.php b/packages/admin-panel/tests/Unit/Controller/LoginControllerTest.php index 0e2af2d6b..410ac3d66 100644 --- a/packages/admin-panel/tests/Unit/Controller/LoginControllerTest.php +++ b/packages/admin-panel/tests/Unit/Controller/LoginControllerTest.php @@ -286,6 +286,7 @@ function loginCsrfMiddleware(): CsrfMiddleware ->and($view->lastTemplate)->toBe('admin-panel::auth/login') ->and($view->lastData)->toHaveKey('error') ->and($view->lastData['error'])->toBe('Invalid email or password.') + ->and($view->lastData['csrfToken'])->toBe('test-csrf-token') ->and($view->lastData)->toHaveKey('loginUrl') ->and($view->lastData['loginUrl'])->toBe('/admin/login'); });