feat: Gestion des sessions utilisateur
Permet aux utilisateurs de visualiser et gérer leurs sessions actives sur différents appareils, avec la possibilité de révoquer des sessions à distance en cas de suspicion d'activité non autorisée. Fonctionnalités : - Liste des sessions actives avec métadonnées (appareil, navigateur, localisation) - Identification de la session courante - Révocation individuelle d'une session - Révocation de toutes les autres sessions - Déconnexion avec nettoyage des cookies sur les deux chemins (legacy et actuel) Sécurité : - Cache frontend scopé par utilisateur pour éviter les fuites entre comptes - Validation que le refresh token appartient à l'utilisateur JWT authentifié - TTL des sessions Redis aligné sur l'expiration du refresh token - Événements d'audit pour traçabilité (SessionInvalidee, ToutesSessionsInvalidees) @see Story 1.6 - Gestion des sessions
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Administration\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
|
||||
use App\Administration\Domain\Model\Session\DeviceInfo;
|
||||
use App\Administration\Domain\Model\Session\Location;
|
||||
use App\Administration\Domain\Model\Session\Session;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\SessionRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function count;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* Tests for sessions endpoints.
|
||||
*
|
||||
* @see Story 1.6 - Gestion des sessions
|
||||
*/
|
||||
final class SessionsEndpointsTest extends ApiTestCase
|
||||
{
|
||||
/**
|
||||
* Opt-in for API Platform 5.0 behavior where kernel boot is explicit.
|
||||
*
|
||||
* @see https://github.com/api-platform/core/issues/6971
|
||||
*/
|
||||
protected static ?bool $alwaysBootKernel = true;
|
||||
|
||||
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
// =========================================================================
|
||||
// Logout endpoint tests
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function logoutEndpointAcceptsPostWithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
// Logout should work even without a valid token (graceful logout)
|
||||
$client->request('POST', '/api/token/logout', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
// Should return 200 OK (graceful logout even without token)
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logoutEndpointReturnsSuccessMessage(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('POST', '/api/token/logout', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertJsonContains(['message' => 'Logout successful']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logoutEndpointDeletesRefreshTokenCookie(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$response = $client->request('POST', '/api/token/logout', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
// Check that Set-Cookie header is present (to delete the cookie)
|
||||
$headers = $response->getHeaders(false);
|
||||
self::assertArrayHasKey('set-cookie', $headers);
|
||||
|
||||
// The cookie should be set to empty with past expiration
|
||||
$setCookieHeader = $headers['set-cookie'][0];
|
||||
self::assertStringContainsString('refresh_token=', $setCookieHeader);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logoutEndpointDeletesRefreshTokenCookieOnApiAndLegacyPaths(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$response = $client->request('POST', '/api/token/logout', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
$headers = $response->getHeaders(false);
|
||||
self::assertArrayHasKey('set-cookie', $headers);
|
||||
|
||||
// Should have 2 Set-Cookie headers for refresh_token deletion
|
||||
$setCookieHeaders = $headers['set-cookie'];
|
||||
self::assertGreaterThanOrEqual(2, count($setCookieHeaders), 'Expected at least 2 Set-Cookie headers');
|
||||
|
||||
// Collect all refresh_token cookie paths
|
||||
$paths = [];
|
||||
foreach ($setCookieHeaders as $header) {
|
||||
if (stripos($header, 'refresh_token=') !== false) {
|
||||
// Extract path with case-insensitive matching
|
||||
if (preg_match('/path=([^;]+)/i', $header, $matches)) {
|
||||
$paths[] = trim($matches[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Must clear both /api (current) and /api/token (legacy) paths
|
||||
self::assertContains('/api', $paths, 'Missing Set-Cookie for Path=/api');
|
||||
self::assertContains('/api/token', $paths, 'Missing Set-Cookie for Path=/api/token (legacy)');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Sessions list endpoint tests - Security
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Without a valid tenant subdomain, the endpoint returns 404.
|
||||
* This is correct security behavior: don't reveal endpoint existence.
|
||||
*/
|
||||
#[Test]
|
||||
public function listSessionsReturns404WithoutTenant(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', '/api/me/sessions', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
// Tenant middleware intercepts and returns 404 (not revealing endpoint)
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
self::assertJsonContains(['message' => 'Resource not found']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Revoke single session endpoint tests - Security
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Without a valid tenant subdomain, the endpoint returns 404.
|
||||
* This is correct security behavior: don't reveal endpoint existence.
|
||||
*/
|
||||
#[Test]
|
||||
public function revokeSessionReturns404WithoutTenant(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('DELETE', '/api/me/sessions/00000000-0000-0000-0000-000000000001', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
// Tenant middleware intercepts and returns 404 (not revealing endpoint)
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
self::assertJsonContains(['message' => 'Resource not found']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Revoke all sessions endpoint tests - Security
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Without a valid tenant subdomain, the endpoint returns 404.
|
||||
* This is correct security behavior: don't reveal endpoint existence.
|
||||
*/
|
||||
#[Test]
|
||||
public function revokeAllSessionsReturns404WithoutTenant(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('DELETE', '/api/me/sessions', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
// Tenant middleware intercepts and returns 404 (not revealing endpoint)
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
self::assertJsonContains(['message' => 'Resource not found']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Authenticated session list tests
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Test that listing sessions requires authentication.
|
||||
* Returns 401 when no JWT token is provided.
|
||||
*/
|
||||
#[Test]
|
||||
public function listSessionsReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', 'http://ecole-alpha.classeo.local/api/me/sessions', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that revoking sessions requires authentication.
|
||||
* Returns 401 when no JWT token is provided.
|
||||
*/
|
||||
#[Test]
|
||||
public function revokeSessionReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$familyId = TokenFamilyId::generate();
|
||||
|
||||
$client->request('DELETE', 'http://ecole-alpha.classeo.local/api/me/sessions/' . $familyId, [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that revoking all sessions requires authentication.
|
||||
* Returns 401 when no JWT token is provided.
|
||||
*/
|
||||
#[Test]
|
||||
public function revokeAllSessionsReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('DELETE', 'http://ecole-alpha.classeo.local/api/me/sessions', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that the session repository correctly stores and retrieves sessions.
|
||||
* This is an integration test of the repository without HTTP layer.
|
||||
*/
|
||||
#[Test]
|
||||
public function sessionRepositoryCanStoreAndRetrieveSessions(): void
|
||||
{
|
||||
$container = static::getContainer();
|
||||
$sessionRepository = $container->get(SessionRepository::class);
|
||||
|
||||
$familyId = TokenFamilyId::generate();
|
||||
$userId = UserId::fromString(self::USER_ID);
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
$session = Session::create(
|
||||
familyId: $familyId,
|
||||
userId: $userId,
|
||||
tenantId: $tenantId,
|
||||
deviceInfo: DeviceInfo::fromUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0'),
|
||||
location: Location::fromIp('192.168.1.1', 'France', 'Paris'),
|
||||
createdAt: new DateTimeImmutable(),
|
||||
);
|
||||
$sessionRepository->save($session, 86400);
|
||||
|
||||
// Retrieve by family ID
|
||||
$retrieved = $sessionRepository->findByFamilyId($familyId);
|
||||
self::assertNotNull($retrieved);
|
||||
self::assertTrue($retrieved->familyId->equals($familyId));
|
||||
self::assertSame('Desktop', $retrieved->deviceInfo->device);
|
||||
self::assertSame('Chrome 120', $retrieved->deviceInfo->browser);
|
||||
|
||||
// Retrieve by user ID
|
||||
$userSessions = $sessionRepository->findAllByUserId($userId);
|
||||
self::assertNotEmpty($userSessions);
|
||||
|
||||
// Cleanup
|
||||
$sessionRepository->delete($familyId);
|
||||
self::assertNull($sessionRepository->findByFamilyId($familyId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that deleteAllExcept removes other sessions but keeps the specified one.
|
||||
*/
|
||||
#[Test]
|
||||
public function sessionRepositoryDeleteAllExceptKeepsSpecifiedSession(): void
|
||||
{
|
||||
$container = static::getContainer();
|
||||
$sessionRepository = $container->get(SessionRepository::class);
|
||||
|
||||
$userId = UserId::fromString(self::USER_ID);
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
// Clean up any existing sessions for this user (from previous test runs)
|
||||
$existingSessions = $sessionRepository->findAllByUserId($userId);
|
||||
foreach ($existingSessions as $existingSession) {
|
||||
$sessionRepository->delete($existingSession->familyId);
|
||||
}
|
||||
|
||||
// Create multiple sessions
|
||||
$keepFamilyId = TokenFamilyId::generate();
|
||||
$deleteFamilyId1 = TokenFamilyId::generate();
|
||||
$deleteFamilyId2 = TokenFamilyId::generate();
|
||||
|
||||
foreach ([$keepFamilyId, $deleteFamilyId1, $deleteFamilyId2] as $familyId) {
|
||||
$session = Session::create(
|
||||
familyId: $familyId,
|
||||
userId: $userId,
|
||||
tenantId: $tenantId,
|
||||
deviceInfo: DeviceInfo::fromUserAgent('Test Browser'),
|
||||
location: Location::unknown(),
|
||||
createdAt: new DateTimeImmutable(),
|
||||
);
|
||||
$sessionRepository->save($session, 86400);
|
||||
}
|
||||
|
||||
// Delete all except one
|
||||
$deletedIds = $sessionRepository->deleteAllExcept($userId, $keepFamilyId);
|
||||
|
||||
self::assertCount(2, $deletedIds);
|
||||
self::assertNotNull($sessionRepository->findByFamilyId($keepFamilyId));
|
||||
self::assertNull($sessionRepository->findByFamilyId($deleteFamilyId1));
|
||||
self::assertNull($sessionRepository->findByFamilyId($deleteFamilyId2));
|
||||
|
||||
// Cleanup
|
||||
$sessionRepository->delete($keepFamilyId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user