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
372 lines
14 KiB
PHP
372 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Administration\Infrastructure\Api\Controller;
|
|
|
|
use App\Administration\Domain\Event\ToutesSessionsInvalidees;
|
|
use App\Administration\Domain\Exception\SessionNotFoundException;
|
|
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
|
|
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
|
|
use App\Administration\Domain\Model\RefreshToken\RefreshTokenId;
|
|
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\RefreshTokenRepository;
|
|
use App\Administration\Domain\Repository\SessionRepository;
|
|
use App\Administration\Infrastructure\Api\Controller\SessionsController;
|
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
|
use App\Shared\Domain\Clock;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use DateTimeImmutable;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\MockObject\MockObject;
|
|
use PHPUnit\Framework\TestCase;
|
|
use stdClass;
|
|
use Symfony\Bundle\SecurityBundle\Security;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
use Symfony\Component\Messenger\Envelope;
|
|
use Symfony\Component\Messenger\MessageBusInterface;
|
|
|
|
final class SessionsControllerTest extends TestCase
|
|
{
|
|
private Security&MockObject $security;
|
|
private SessionRepository&MockObject $sessionRepository;
|
|
private RefreshTokenRepository&MockObject $refreshTokenRepository;
|
|
private MessageBusInterface&MockObject $eventBus;
|
|
private Clock&MockObject $clock;
|
|
private SessionsController $controller;
|
|
|
|
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440000';
|
|
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
private const string OTHER_TENANT_ID = 'b2c3d4e5-f6a7-8901-bcde-f12345678901';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->security = $this->createMock(Security::class);
|
|
$this->sessionRepository = $this->createMock(SessionRepository::class);
|
|
$this->refreshTokenRepository = $this->createMock(RefreshTokenRepository::class);
|
|
$this->eventBus = $this->createMock(MessageBusInterface::class);
|
|
$this->clock = $this->createMock(Clock::class);
|
|
|
|
$this->controller = new SessionsController(
|
|
$this->security,
|
|
$this->sessionRepository,
|
|
$this->refreshTokenRepository,
|
|
$this->eventBus,
|
|
$this->clock,
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function revokeReturnsNotFoundWhenFamilyIdIsMalformed(): void
|
|
{
|
|
$this->mockAuthenticatedUser();
|
|
|
|
// Assert: SessionRepository::getByFamilyId() should NEVER be called
|
|
// because we fail before any business logic (InvalidArgumentException on UUID parse)
|
|
$this->sessionRepository
|
|
->expects($this->never())
|
|
->method('getByFamilyId');
|
|
|
|
$this->expectException(NotFoundHttpException::class);
|
|
$this->expectExceptionMessage('Session not found');
|
|
|
|
$request = Request::create('/api/me/sessions/not-a-uuid', 'DELETE');
|
|
$this->controller->revoke('not-a-uuid', $request);
|
|
}
|
|
|
|
#[Test]
|
|
public function revokeReturnsNotFoundWhenSessionDoesNotExist(): void
|
|
{
|
|
$this->mockAuthenticatedUser();
|
|
|
|
$familyId = TokenFamilyId::generate();
|
|
|
|
$this->sessionRepository
|
|
->expects($this->once())
|
|
->method('getByFamilyId')
|
|
->with($this->callback(static fn (TokenFamilyId $id) => $id->equals($familyId)))
|
|
->willThrowException(new SessionNotFoundException($familyId));
|
|
|
|
$this->expectException(NotFoundHttpException::class);
|
|
$this->expectExceptionMessage('Session not found');
|
|
|
|
$request = Request::create('/api/me/sessions/' . $familyId, 'DELETE');
|
|
$this->controller->revoke((string) $familyId, $request);
|
|
}
|
|
|
|
#[Test]
|
|
public function listReturnsSessionsForCurrentUserAndTenant(): void
|
|
{
|
|
$this->mockAuthenticatedUser();
|
|
|
|
$familyId = TokenFamilyId::generate();
|
|
$userId = UserId::fromString(self::USER_ID);
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$now = new DateTimeImmutable('2024-01-15 10:00:00');
|
|
|
|
$session = Session::create(
|
|
familyId: $familyId,
|
|
userId: $userId,
|
|
tenantId: $tenantId,
|
|
deviceInfo: DeviceInfo::reconstitute('Desktop', 'Chrome 120', 'Windows 10', 'Mozilla/5.0'),
|
|
location: Location::fromIp('192.168.1.1', 'France', 'Paris'),
|
|
createdAt: $now,
|
|
);
|
|
|
|
$this->sessionRepository
|
|
->expects($this->once())
|
|
->method('findAllByUserId')
|
|
->with($this->callback(static fn (UserId $id) => $id->equals($userId)))
|
|
->willReturn([$session]);
|
|
|
|
$request = Request::create('/api/me/sessions', 'GET');
|
|
$response = $this->controller->list($request);
|
|
|
|
$data = json_decode($response->getContent(), true);
|
|
|
|
self::assertSame(200, $response->getStatusCode());
|
|
self::assertCount(1, $data['sessions']);
|
|
self::assertSame((string) $familyId, $data['sessions'][0]['family_id']);
|
|
self::assertSame('Desktop', $data['sessions'][0]['device']);
|
|
self::assertSame('Chrome 120', $data['sessions'][0]['browser']);
|
|
self::assertSame('Windows 10', $data['sessions'][0]['os']);
|
|
self::assertSame('France, Paris', $data['sessions'][0]['location']);
|
|
self::assertFalse($data['sessions'][0]['is_current']);
|
|
}
|
|
|
|
#[Test]
|
|
public function listFilterOutSessionsFromOtherTenants(): void
|
|
{
|
|
$this->mockAuthenticatedUser();
|
|
|
|
$userId = UserId::fromString(self::USER_ID);
|
|
$currentTenantId = TenantId::fromString(self::TENANT_ID);
|
|
$otherTenantId = TenantId::fromString(self::OTHER_TENANT_ID);
|
|
$now = new DateTimeImmutable('2024-01-15 10:00:00');
|
|
|
|
$sessionSameTenant = Session::create(
|
|
familyId: TokenFamilyId::generate(),
|
|
userId: $userId,
|
|
tenantId: $currentTenantId,
|
|
deviceInfo: DeviceInfo::reconstitute('Desktop', 'Chrome 120', 'Windows 10', 'Mozilla/5.0'),
|
|
location: Location::unknown(),
|
|
createdAt: $now,
|
|
);
|
|
|
|
$sessionOtherTenant = Session::create(
|
|
familyId: TokenFamilyId::generate(),
|
|
userId: $userId,
|
|
tenantId: $otherTenantId,
|
|
deviceInfo: DeviceInfo::reconstitute('Mobile', 'Safari 17', 'iOS 17', 'iPhone'),
|
|
location: Location::unknown(),
|
|
createdAt: $now,
|
|
);
|
|
|
|
$this->sessionRepository
|
|
->method('findAllByUserId')
|
|
->willReturn([$sessionSameTenant, $sessionOtherTenant]);
|
|
|
|
$request = Request::create('/api/me/sessions', 'GET');
|
|
$response = $this->controller->list($request);
|
|
|
|
$data = json_decode($response->getContent(), true);
|
|
|
|
// Should only return the session from the current tenant
|
|
self::assertCount(1, $data['sessions']);
|
|
self::assertSame('Desktop', $data['sessions'][0]['device']);
|
|
}
|
|
|
|
#[Test]
|
|
public function listMarksCurrentSessionCorrectly(): void
|
|
{
|
|
$this->mockAuthenticatedUser();
|
|
|
|
$currentFamilyId = TokenFamilyId::generate();
|
|
$otherFamilyId = TokenFamilyId::generate();
|
|
$userId = UserId::fromString(self::USER_ID);
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$now = new DateTimeImmutable('2024-01-15 10:00:00');
|
|
|
|
$currentSession = Session::create(
|
|
familyId: $currentFamilyId,
|
|
userId: $userId,
|
|
tenantId: $tenantId,
|
|
deviceInfo: DeviceInfo::reconstitute('Desktop', 'Chrome 120', 'Windows 10', 'UA1'),
|
|
location: Location::unknown(),
|
|
createdAt: $now,
|
|
);
|
|
|
|
$otherSession = Session::create(
|
|
familyId: $otherFamilyId,
|
|
userId: $userId,
|
|
tenantId: $tenantId,
|
|
deviceInfo: DeviceInfo::reconstitute('Mobile', 'Safari 17', 'iOS 17', 'UA2'),
|
|
location: Location::unknown(),
|
|
createdAt: $now,
|
|
);
|
|
|
|
$this->sessionRepository
|
|
->method('findAllByUserId')
|
|
->willReturn([$currentSession, $otherSession]);
|
|
|
|
// Create a real RefreshToken to identify the current session
|
|
$refreshToken = RefreshToken::reconstitute(
|
|
id: RefreshTokenId::generate(),
|
|
familyId: $currentFamilyId,
|
|
userId: $userId,
|
|
tenantId: $tenantId,
|
|
deviceFingerprint: DeviceFingerprint::fromString('test-fingerprint'),
|
|
issuedAt: $now,
|
|
expiresAt: $now->modify('+1 day'),
|
|
rotatedFrom: null,
|
|
isRotated: false,
|
|
);
|
|
|
|
$this->refreshTokenRepository
|
|
->method('find')
|
|
->willReturn($refreshToken);
|
|
|
|
// Create request with refresh token cookie
|
|
$tokenValue = $refreshToken->toTokenString();
|
|
$request = Request::create('/api/me/sessions', 'GET');
|
|
$request->cookies->set('refresh_token', $tokenValue);
|
|
|
|
$response = $this->controller->list($request);
|
|
$data = json_decode($response->getContent(), true);
|
|
|
|
// Current session should be first (sorted) and marked as current
|
|
self::assertCount(2, $data['sessions']);
|
|
self::assertTrue($data['sessions'][0]['is_current']);
|
|
self::assertSame((string) $currentFamilyId, $data['sessions'][0]['family_id']);
|
|
self::assertFalse($data['sessions'][1]['is_current']);
|
|
}
|
|
|
|
#[Test]
|
|
public function revokeAllDeletesAllSessionsExceptCurrent(): void
|
|
{
|
|
$this->mockAuthenticatedUser();
|
|
|
|
$currentFamilyId = TokenFamilyId::generate();
|
|
$deletedFamilyId1 = TokenFamilyId::generate();
|
|
$deletedFamilyId2 = TokenFamilyId::generate();
|
|
$userId = UserId::fromString(self::USER_ID);
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$now = new DateTimeImmutable('2024-01-15 10:00:00');
|
|
|
|
$this->clock->method('now')->willReturn($now);
|
|
|
|
// Create a real RefreshToken for current session identification
|
|
$refreshToken = RefreshToken::reconstitute(
|
|
id: RefreshTokenId::generate(),
|
|
familyId: $currentFamilyId,
|
|
userId: $userId,
|
|
tenantId: $tenantId,
|
|
deviceFingerprint: DeviceFingerprint::fromString('test-fingerprint'),
|
|
issuedAt: $now,
|
|
expiresAt: $now->modify('+1 day'),
|
|
rotatedFrom: null,
|
|
isRotated: false,
|
|
);
|
|
$this->refreshTokenRepository->method('find')->willReturn($refreshToken);
|
|
|
|
// Mock deleteAllExcept
|
|
$this->sessionRepository
|
|
->expects($this->once())
|
|
->method('deleteAllExcept')
|
|
->with(
|
|
$this->callback(static fn (UserId $id) => $id->equals($userId)),
|
|
$this->callback(static fn (TokenFamilyId $id) => $id->equals($currentFamilyId)),
|
|
)
|
|
->willReturn([$deletedFamilyId1, $deletedFamilyId2]);
|
|
|
|
// Expect invalidateFamily to be called for each deleted session
|
|
$this->refreshTokenRepository
|
|
->expects($this->exactly(2))
|
|
->method('invalidateFamily');
|
|
|
|
// Expect event to be dispatched
|
|
$this->eventBus
|
|
->expects($this->once())
|
|
->method('dispatch')
|
|
->with($this->isInstanceOf(ToutesSessionsInvalidees::class))
|
|
->willReturn(new Envelope(new stdClass()));
|
|
|
|
$tokenValue = $refreshToken->toTokenString();
|
|
$request = Request::create('/api/me/sessions', 'DELETE');
|
|
$request->cookies->set('refresh_token', $tokenValue);
|
|
$request->headers->set('User-Agent', 'Test Browser');
|
|
|
|
$response = $this->controller->revokeAll($request);
|
|
$data = json_decode($response->getContent(), true);
|
|
|
|
self::assertSame(200, $response->getStatusCode());
|
|
self::assertSame('All other sessions revoked', $data['message']);
|
|
self::assertSame(2, $data['revoked_count']);
|
|
}
|
|
|
|
#[Test]
|
|
public function revokeAllDoesNotDispatchEventWhenNoSessionsRevoked(): void
|
|
{
|
|
$this->mockAuthenticatedUser();
|
|
|
|
$currentFamilyId = TokenFamilyId::generate();
|
|
$userId = UserId::fromString(self::USER_ID);
|
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
$now = new DateTimeImmutable('2024-01-15 10:00:00');
|
|
|
|
$this->clock->method('now')->willReturn($now);
|
|
|
|
// Create a real RefreshToken
|
|
$refreshToken = RefreshToken::reconstitute(
|
|
id: RefreshTokenId::generate(),
|
|
familyId: $currentFamilyId,
|
|
userId: $userId,
|
|
tenantId: $tenantId,
|
|
deviceFingerprint: DeviceFingerprint::fromString('test-fingerprint'),
|
|
issuedAt: $now,
|
|
expiresAt: $now->modify('+1 day'),
|
|
rotatedFrom: null,
|
|
isRotated: false,
|
|
);
|
|
$this->refreshTokenRepository->method('find')->willReturn($refreshToken);
|
|
|
|
// No sessions to delete
|
|
$this->sessionRepository
|
|
->method('deleteAllExcept')
|
|
->willReturn([]);
|
|
|
|
// Event should NOT be dispatched
|
|
$this->eventBus
|
|
->expects($this->never())
|
|
->method('dispatch');
|
|
|
|
$tokenValue = $refreshToken->toTokenString();
|
|
$request = Request::create('/api/me/sessions', 'DELETE');
|
|
$request->cookies->set('refresh_token', $tokenValue);
|
|
|
|
$response = $this->controller->revokeAll($request);
|
|
$data = json_decode($response->getContent(), true);
|
|
|
|
self::assertSame(200, $response->getStatusCode());
|
|
self::assertSame(0, $data['revoked_count']);
|
|
}
|
|
|
|
private function mockAuthenticatedUser(): void
|
|
{
|
|
$securityUser = new SecurityUser(
|
|
UserId::fromString(self::USER_ID),
|
|
'test@example.com',
|
|
'hashed_password',
|
|
TenantId::fromString(self::TENANT_ID),
|
|
['ROLE_USER'],
|
|
);
|
|
|
|
$this->security->method('getUser')->willReturn($securityUser);
|
|
}
|
|
}
|