From 1b1d9db6262715f113540bc3f0997294c2300c2b Mon Sep 17 00:00:00 2001 From: myrmidex Date: Thu, 18 Jun 2026 01:37:59 +0200 Subject: [PATCH] 30 - Add cookie-session auth: json_login, /api/me, logout --- config/packages/framework.yaml | 8 +- config/packages/security.yaml | 70 +++---- src/Controller/ApiLoginController.php | 34 +++ src/Security/LogoutSubscriber.php | 22 ++ tests/Functional/AuthenticationTest.php | 262 ++++++++++++++++++++++++ 5 files changed, 358 insertions(+), 38 deletions(-) create mode 100644 src/Controller/ApiLoginController.php create mode 100644 src/Security/LogoutSubscriber.php create mode 100644 tests/Functional/AuthenticationTest.php diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 7e1ee1f..cc5f88e 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -3,7 +3,13 @@ framework: secret: '%env(APP_SECRET)%' # Note that the session will be started ONLY if you read or write from it. - session: true + session: + # Same-origin SPA cookie session: not readable by JS, sent on same-site + # navigations. cookie_secure: auto → Secure flag only over HTTPS (prod), + # so local HTTP dev still works. + cookie_httponly: true + cookie_samesite: lax + cookie_secure: auto #esi: true #fragments: true diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 68f1e5c..3503ba1 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -1,43 +1,39 @@ security: - # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords - password_hashers: - Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' - # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider - providers: - # used to reload user from session & other features (e.g. switch_user) - app_user_provider: - entity: - class: App\Entity\User - property: email + # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider + providers: + # used to reload user from session & other features (e.g. switch_user) + app_user_provider: + entity: + class: App\Entity\User + property: email - firewalls: - dev: - # Ensure dev tools and static assets are always allowed - pattern: ^/(_profiler|_wdt|assets|build)/ - security: false - main: - lazy: true - provider: app_user_provider + firewalls: + dev: + # Ensure dev tools and static assets are always allowed + pattern: ^/(_profiler|_wdt|assets|build)/ + security: false + main: + lazy: true + provider: app_user_provider + json_login: + check_path: api_login + logout: + path: /api/logout - # Activate different ways to authenticate: - # https://symfony.com/doc/current/security.html#the-firewall - - # https://symfony.com/doc/current/security/impersonating_user.html - # switch_user: true - - # Note: Only the *first* matching rule is applied - access_control: - # - { path: ^/admin, roles: ROLE_ADMIN } - # - { path: ^/profile, roles: ROLE_USER } + access_control: + - {path: ^/api/me, roles: ROLE_USER} when@test: - security: - password_hashers: - # Password hashers are resource-intensive by design to ensure security. - # In tests, it's safe to reduce their cost to improve performance. - Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: - algorithm: auto - cost: 4 # Lowest possible value for bcrypt - time_cost: 3 # Lowest possible value for argon - memory_cost: 10 # Lowest possible value for argon + security: + password_hashers: + # Password hashers are resource-intensive by design to ensure security. + # In tests, it's safe to reduce their cost to improve performance. + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: + algorithm: auto + cost: 4 # Lowest possible value for bcrypt + time_cost: 3 # Lowest possible value for argon + memory_cost: 10 # Lowest possible value for argon diff --git a/src/Controller/ApiLoginController.php b/src/Controller/ApiLoginController.php new file mode 100644 index 0000000..0eada05 --- /dev/null +++ b/src/Controller/ApiLoginController.php @@ -0,0 +1,34 @@ +json([], JsonResponse::HTTP_UNAUTHORIZED); + } + + return $this->json([ + 'id' => $user->getId(), + 'email' => $user->getEmail(), + ]); + } + + #[Route('/api/me', name: 'api_me', methods: ['GET'])] + public function me(#[CurrentUser] User $user): JsonResponse + { + return $this->json([ + 'id' => $user->getId(), + 'email' => $user->getEmail(), + ]); + } +} diff --git a/src/Security/LogoutSubscriber.php b/src/Security/LogoutSubscriber.php new file mode 100644 index 0000000..19f2b0a --- /dev/null +++ b/src/Security/LogoutSubscriber.php @@ -0,0 +1,22 @@ + 'onLogout', + ]; + } + + public function onLogout(LogoutEvent $event): void + { + $event->setResponse(new Response(null, Response::HTTP_NO_CONTENT)); + } +} diff --git a/tests/Functional/AuthenticationTest.php b/tests/Functional/AuthenticationTest.php new file mode 100644 index 0000000..22216e8 --- /dev/null +++ b/tests/Functional/AuthenticationTest.php @@ -0,0 +1,262 @@ +client = static::createClient(); + + $factory = new PasswordHasherFactory([ + User::class => new NativePasswordHasher(cost: 4), + ]); + $hasher = new UserPasswordHasher($factory); + + $user = new User(); + $user->setEmail(self::EMAIL); + $user->setPassword($hasher->hashPassword($user, self::PASSWORD)); + + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get(EntityManagerInterface::class); + $em->persist($user); + $em->flush(); + } + + public function testItLogsInWithValidCredentialsAndSetsASessionCookie(): void + { + $this->client->jsonRequest('POST', '/api/login', [ + 'username' => self::EMAIL, + 'password' => self::PASSWORD, + ]); + + $loginResponse = $this->client->getResponse(); + + self::assertLessThan( + 300, + $loginResponse->getStatusCode(), + 'A successful login must return a 2xx response.', + ); + + $setCookieHeader = $loginResponse->headers->get('Set-Cookie'); + + self::assertNotNull( + $setCookieHeader, + 'A successful login must set a Set-Cookie header.', + ); + + self::assertMatchesRegularExpression( + '/HttpOnly/i', + $setCookieHeader, + 'The session cookie must be HttpOnly.', + ); + + self::assertMatchesRegularExpression( + '/SameSite=Lax/i', + $setCookieHeader, + 'The session cookie must carry SameSite=Lax.', + ); + + $loginBody = json_decode((string) $loginResponse->getContent(), true); + + self::assertIsArray($loginBody, 'The login response must be a JSON object.'); + + self::assertArrayHasKey('email', $loginBody, 'The login response must include the email field.'); + self::assertSame(self::EMAIL, $loginBody['email'], 'The login response must return the authenticated user\'s email.'); + + self::assertArrayHasKey('id', $loginBody, 'The login response must include the id (uuid) field.'); + self::assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', + (string) $loginBody['id'], + 'The login response id must be a valid UUIDv7.', + ); + + $loginResponseText = (string) $loginResponse->getContent(); + self::assertStringNotContainsString( + '$2', + $loginResponseText, + 'The login response must never expose a bcrypt hash (starts with $2).', + ); + self::assertStringNotContainsString( + '$argon', + $loginResponseText, + 'The login response must never expose an Argon2 hash (starts with $argon).', + ); + + $keys = array_keys($loginBody); + sort($keys); + self::assertSame( + ['email', 'id'], + $keys, + 'The login response body must contain EXACTLY the keys [email, id] — no more, no less.', + ); + + $this->client->jsonRequest('GET', '/api/me'); + + self::assertSame( + 200, + $this->client->getResponse()->getStatusCode(), + 'The session cookie set by login must authenticate a follow-up request to /api/me.', + ); + } + + public function testItRejectsLoginWithInvalidCredentials(): void + { + $this->client->jsonRequest('POST', '/api/login', [ + 'username' => self::EMAIL, + 'password' => 'wrong-password', + ]); + + $response = $this->client->getResponse(); + + self::assertSame( + 401, + $response->getStatusCode(), + 'An invalid password must return 401 Unauthorized.', + ); + + $cookies = $this->client->getCookieJar()->all(); + + self::assertEmpty( + $cookies, + 'A failed login must not set any cookies — the browser cookie jar must remain empty.', + ); + + $this->client->jsonRequest('GET', '/api/me'); + + self::assertSame( + 401, + $this->client->getResponse()->getStatusCode(), + 'After a failed login the client must not hold an authenticated session.', + ); + } + + public function testItRejectsLoginWithNonJsonContentType(): void + { + $this->client->request( + 'POST', + '/api/login', + ['username' => self::EMAIL, 'password' => self::PASSWORD], + ); + + self::assertSame( + 401, + $this->client->getResponse()->getStatusCode(), + 'A non-JSON Content-Type on /api/login must not authenticate — the request must be rejected with 401.', + ); + + $cookies = $this->client->getCookieJar()->all(); + + self::assertEmpty( + $cookies, + 'A rejected non-JSON login must not set any cookies — the browser cookie jar must remain empty.', + ); + } + + public function testItReturns401OnMeWhenNotAuthenticated(): void + { + $this->client->jsonRequest('GET', '/api/me'); + + self::assertSame( + 401, + $this->client->getResponse()->getStatusCode(), + '/api/me must return 401 when no session is present.', + ); + } + + public function testItReturnsCurrentUserOnMeAndLogoutClearsTheSession(): void + { + // --- Step 1: log in --- + $this->client->jsonRequest('POST', '/api/login', [ + 'username' => self::EMAIL, + 'password' => self::PASSWORD, + ]); + + self::assertLessThan( + 300, + $this->client->getResponse()->getStatusCode(), + 'Login must succeed before testing /api/me.', + ); + + // --- Step 2: GET /api/me while authenticated --- + $this->client->jsonRequest('GET', '/api/me'); + + $meResponse = $this->client->getResponse(); + + self::assertSame(200, $meResponse->getStatusCode(), '/api/me must return 200 for an authenticated user.'); + + $body = json_decode((string) $meResponse->getContent(), true); + + self::assertIsArray($body, '/api/me must return a JSON object.'); + + self::assertArrayHasKey('email', $body, '/api/me response must include the email field.'); + self::assertSame(self::EMAIL, $body['email'], '/api/me must return the authenticated user\'s email.'); + + self::assertArrayHasKey('id', $body, '/api/me response must include the id (uuid) field.'); + self::assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', + (string) $body['id'], + '/api/me id must be a valid UUIDv7.', + ); + + $responseText = (string) $meResponse->getContent(); + self::assertStringNotContainsString( + '$2', + $responseText, + '/api/me must never expose a bcrypt hash (starts with $2).', + ); + self::assertStringNotContainsString( + '$argon', + $responseText, + '/api/me must never expose an Argon2 hash (starts with $argon).', + ); + + $this->client->jsonRequest('POST', '/api/logout'); + + $logoutResponse = $this->client->getResponse(); + + self::assertSame( + 204, + $logoutResponse->getStatusCode(), + 'POST /api/logout must return 204 No Content — not a redirect.', + ); + + self::assertSame( + '', + $logoutResponse->getContent(), + 'A 204 response must have an empty body.', + ); + + $this->client->jsonRequest('GET', '/api/me'); + + self::assertSame( + 401, + $this->client->getResponse()->getStatusCode(), + '/api/me must return 401 after logout — the session must be invalidated.', + ); + } + + public function testItReturnsNoContentWhenLoggingOutWithoutASession(): void + { + $this->client->jsonRequest('POST', '/api/logout'); + + self::assertSame( + 204, + $this->client->getResponse()->getStatusCode(), + 'POST /api/logout without an active session must still return 204 — logout is idempotent.', + ); + } +}