30 - Add cookie-session auth: json_login, /api/me, logout
This commit is contained in:
parent
7c893c9988
commit
1b1d9db626
5 changed files with 358 additions and 38 deletions
|
|
@ -3,7 +3,13 @@ framework:
|
||||||
secret: '%env(APP_SECRET)%'
|
secret: '%env(APP_SECRET)%'
|
||||||
|
|
||||||
# Note that the session will be started ONLY if you read or write from it.
|
# 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
|
#esi: true
|
||||||
#fragments: true
|
#fragments: true
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,39 @@
|
||||||
security:
|
security:
|
||||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||||
password_hashers:
|
password_hashers:
|
||||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||||
|
|
||||||
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
|
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
|
||||||
providers:
|
providers:
|
||||||
# used to reload user from session & other features (e.g. switch_user)
|
# used to reload user from session & other features (e.g. switch_user)
|
||||||
app_user_provider:
|
app_user_provider:
|
||||||
entity:
|
entity:
|
||||||
class: App\Entity\User
|
class: App\Entity\User
|
||||||
property: email
|
property: email
|
||||||
|
|
||||||
firewalls:
|
firewalls:
|
||||||
dev:
|
dev:
|
||||||
# Ensure dev tools and static assets are always allowed
|
# Ensure dev tools and static assets are always allowed
|
||||||
pattern: ^/(_profiler|_wdt|assets|build)/
|
pattern: ^/(_profiler|_wdt|assets|build)/
|
||||||
security: false
|
security: false
|
||||||
main:
|
main:
|
||||||
lazy: true
|
lazy: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
|
json_login:
|
||||||
|
check_path: api_login
|
||||||
|
logout:
|
||||||
|
path: /api/logout
|
||||||
|
|
||||||
# Activate different ways to authenticate:
|
access_control:
|
||||||
# https://symfony.com/doc/current/security.html#the-firewall
|
- {path: ^/api/me, roles: ROLE_USER}
|
||||||
|
|
||||||
# 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 }
|
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
security:
|
security:
|
||||||
password_hashers:
|
password_hashers:
|
||||||
# Password hashers are resource-intensive by design to ensure security.
|
# Password hashers are resource-intensive by design to ensure security.
|
||||||
# In tests, it's safe to reduce their cost to improve performance.
|
# In tests, it's safe to reduce their cost to improve performance.
|
||||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
|
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
|
||||||
algorithm: auto
|
algorithm: auto
|
||||||
cost: 4 # Lowest possible value for bcrypt
|
cost: 4 # Lowest possible value for bcrypt
|
||||||
time_cost: 3 # Lowest possible value for argon
|
time_cost: 3 # Lowest possible value for argon
|
||||||
memory_cost: 10 # Lowest possible value for argon
|
memory_cost: 10 # Lowest possible value for argon
|
||||||
|
|
|
||||||
34
src/Controller/ApiLoginController.php
Normal file
34
src/Controller/ApiLoginController.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||||
|
|
||||||
|
final class ApiLoginController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('/api/login', name: 'api_login', methods: ['POST'])]
|
||||||
|
public function login(#[CurrentUser] ?User $user): JsonResponse
|
||||||
|
{
|
||||||
|
if (null === $user) {
|
||||||
|
return $this->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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/Security/LogoutSubscriber.php
Normal file
22
src/Security/LogoutSubscriber.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Security\Http\Event\LogoutEvent;
|
||||||
|
|
||||||
|
final class LogoutSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
LogoutEvent::class => 'onLogout',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onLogout(LogoutEvent $event): void
|
||||||
|
{
|
||||||
|
$event->setResponse(new Response(null, Response::HTTP_NO_CONTENT));
|
||||||
|
}
|
||||||
|
}
|
||||||
262
tests/Functional/AuthenticationTest.php
Normal file
262
tests/Functional/AuthenticationTest.php
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Functional;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
|
||||||
|
|
||||||
|
final class AuthenticationTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private const EMAIL = 'auth-test@example.com';
|
||||||
|
private const PASSWORD = 'correct-horse-battery-staple';
|
||||||
|
|
||||||
|
private KernelBrowser $client;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue