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,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\Session;
|
||||
|
||||
use App\Administration\Domain\Model\Session\DeviceInfo;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class DeviceInfoTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
#[DataProvider('userAgentProvider')]
|
||||
public function fromUserAgentParsesCorrectly(
|
||||
string $userAgent,
|
||||
string $expectedDevice,
|
||||
string $expectedBrowser,
|
||||
string $expectedOs,
|
||||
): void {
|
||||
$deviceInfo = DeviceInfo::fromUserAgent($userAgent);
|
||||
|
||||
self::assertSame($expectedDevice, $deviceInfo->device);
|
||||
self::assertSame($expectedBrowser, $deviceInfo->browser);
|
||||
self::assertSame($expectedOs, $deviceInfo->os);
|
||||
self::assertSame($userAgent, $deviceInfo->rawUserAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, string, string, string}>
|
||||
*/
|
||||
public static function userAgentProvider(): iterable
|
||||
{
|
||||
yield 'Chrome on Windows' => [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Desktop',
|
||||
'Chrome 120',
|
||||
'Windows 10',
|
||||
];
|
||||
|
||||
yield 'Firefox on macOS' => [
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0',
|
||||
'Desktop',
|
||||
'Firefox 121',
|
||||
'macOS 10.15',
|
||||
];
|
||||
|
||||
yield 'Safari on iPhone' => [
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
|
||||
'Mobile',
|
||||
'Safari 17',
|
||||
'iOS 17.2',
|
||||
];
|
||||
|
||||
yield 'Chrome on Android' => [
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.144 Mobile Safari/537.36',
|
||||
'Mobile',
|
||||
'Chrome 120',
|
||||
'Android 14',
|
||||
];
|
||||
|
||||
yield 'Safari on iPad' => [
|
||||
'Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
|
||||
'Tablet',
|
||||
'Safari 17',
|
||||
'iPadOS 17.2',
|
||||
];
|
||||
|
||||
yield 'Edge on Windows' => [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0',
|
||||
'Desktop',
|
||||
'Edge 120',
|
||||
'Windows 10',
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fromUserAgentHandlesEmptyString(): void
|
||||
{
|
||||
$deviceInfo = DeviceInfo::fromUserAgent('');
|
||||
|
||||
self::assertSame('Inconnu', $deviceInfo->device);
|
||||
self::assertSame('Inconnu', $deviceInfo->browser);
|
||||
self::assertSame('Inconnu', $deviceInfo->os);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fromUserAgentHandlesUnknownUserAgent(): void
|
||||
{
|
||||
$deviceInfo = DeviceInfo::fromUserAgent('Some Random Bot/1.0');
|
||||
|
||||
self::assertSame('Inconnu', $deviceInfo->device);
|
||||
self::assertSame('Inconnu', $deviceInfo->browser);
|
||||
self::assertSame('Inconnu', $deviceInfo->os);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reconstituteRestoresFromStorage(): void
|
||||
{
|
||||
$deviceInfo = DeviceInfo::reconstitute(
|
||||
device: 'Desktop',
|
||||
browser: 'Chrome 120',
|
||||
os: 'Windows 10',
|
||||
rawUserAgent: 'Mozilla/5.0...',
|
||||
);
|
||||
|
||||
self::assertSame('Desktop', $deviceInfo->device);
|
||||
self::assertSame('Chrome 120', $deviceInfo->browser);
|
||||
self::assertSame('Windows 10', $deviceInfo->os);
|
||||
self::assertSame('Mozilla/5.0...', $deviceInfo->rawUserAgent);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isMobileReturnsTrueForMobileDevices(): void
|
||||
{
|
||||
$mobile = DeviceInfo::fromUserAgent(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15',
|
||||
);
|
||||
$desktop = DeviceInfo::fromUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0',
|
||||
);
|
||||
|
||||
self::assertTrue($mobile->isMobile());
|
||||
self::assertFalse($desktop->isMobile());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\Session;
|
||||
|
||||
use App\Administration\Domain\Model\Session\Location;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class LocationTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function fromIpCreatesLocationWithCountryAndCity(): void
|
||||
{
|
||||
$location = Location::fromIp('192.168.1.1', 'France', 'Paris');
|
||||
|
||||
self::assertSame('France', $location->country);
|
||||
self::assertSame('Paris', $location->city);
|
||||
self::assertSame('192.168.1.1', $location->ip);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fromIpCreatesLocationWithCountryOnly(): void
|
||||
{
|
||||
$location = Location::fromIp('10.0.0.1', 'Germany', null);
|
||||
|
||||
self::assertSame('Germany', $location->country);
|
||||
self::assertNull($location->city);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function unknownCreatesUnknownLocation(): void
|
||||
{
|
||||
$location = Location::unknown();
|
||||
|
||||
self::assertNull($location->country);
|
||||
self::assertNull($location->city);
|
||||
self::assertNull($location->ip);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function formatReturnsCountryAndCity(): void
|
||||
{
|
||||
$location = Location::fromIp('192.168.1.1', 'France', 'Paris');
|
||||
|
||||
self::assertSame('France, Paris', $location->format());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function formatReturnsCountryOnlyWhenNoCityAvailable(): void
|
||||
{
|
||||
$location = Location::fromIp('10.0.0.1', 'Germany', null);
|
||||
|
||||
self::assertSame('Germany', $location->format());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function formatReturnsInconnuWhenUnknown(): void
|
||||
{
|
||||
$location = Location::unknown();
|
||||
|
||||
self::assertSame('Inconnu', $location->format());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function formatReturnsInconnuForPrivateIpRanges(): void
|
||||
{
|
||||
$location = Location::fromIp('192.168.1.1', null, null);
|
||||
|
||||
self::assertSame('Inconnu', $location->format());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reconstituteRestoresFromStorage(): void
|
||||
{
|
||||
$location = Location::reconstitute(
|
||||
ip: '8.8.8.8',
|
||||
country: 'United States',
|
||||
city: 'Mountain View',
|
||||
);
|
||||
|
||||
self::assertSame('8.8.8.8', $location->ip);
|
||||
self::assertSame('United States', $location->country);
|
||||
self::assertSame('Mountain View', $location->city);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\Session;
|
||||
|
||||
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\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SessionTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
#[Test]
|
||||
public function createGeneratesSessionWithCorrectData(): void
|
||||
{
|
||||
$familyId = TokenFamilyId::generate();
|
||||
$userId = UserId::generate();
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$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('2026-01-31 10:00:00');
|
||||
|
||||
$session = Session::create(
|
||||
$familyId,
|
||||
$userId,
|
||||
$tenantId,
|
||||
$deviceInfo,
|
||||
$location,
|
||||
$createdAt,
|
||||
);
|
||||
|
||||
self::assertTrue($session->familyId->equals($familyId));
|
||||
self::assertTrue($session->userId->equals($userId));
|
||||
self::assertTrue($session->tenantId->equals($tenantId));
|
||||
self::assertSame($deviceInfo, $session->deviceInfo);
|
||||
self::assertSame($location, $session->location);
|
||||
self::assertEquals($createdAt, $session->createdAt);
|
||||
self::assertEquals($createdAt, $session->lastActivityAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function updateActivityUpdatesLastActivityTimestamp(): void
|
||||
{
|
||||
$session = $this->createSession();
|
||||
$newActivityAt = new DateTimeImmutable('2026-01-31 14:30:00');
|
||||
|
||||
$updatedSession = $session->updateActivity($newActivityAt);
|
||||
|
||||
self::assertEquals($newActivityAt, $updatedSession->lastActivityAt);
|
||||
self::assertEquals($session->createdAt, $updatedSession->createdAt);
|
||||
self::assertTrue($session->familyId->equals($updatedSession->familyId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isCurrentReturnsTrueForMatchingFamilyId(): void
|
||||
{
|
||||
$familyId = TokenFamilyId::generate();
|
||||
$session = Session::create(
|
||||
$familyId,
|
||||
UserId::generate(),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
DeviceInfo::fromUserAgent('Mozilla/5.0'),
|
||||
Location::unknown(),
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
self::assertTrue($session->isCurrent($familyId));
|
||||
self::assertFalse($session->isCurrent(TokenFamilyId::generate()));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function belongsToUserReturnsTrueForMatchingUserId(): void
|
||||
{
|
||||
$userId = UserId::generate();
|
||||
$session = Session::create(
|
||||
TokenFamilyId::generate(),
|
||||
$userId,
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
DeviceInfo::fromUserAgent('Mozilla/5.0'),
|
||||
Location::unknown(),
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
self::assertTrue($session->belongsToUser($userId));
|
||||
self::assertFalse($session->belongsToUser(UserId::generate()));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reconstituteRestoresSessionFromStorage(): void
|
||||
{
|
||||
$familyId = TokenFamilyId::generate();
|
||||
$userId = UserId::generate();
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$deviceInfo = DeviceInfo::fromUserAgent('Mozilla/5.0');
|
||||
$location = Location::fromIp('10.0.0.1', 'Germany', 'Berlin');
|
||||
$createdAt = new DateTimeImmutable('2026-01-20 08:00:00');
|
||||
$lastActivityAt = new DateTimeImmutable('2026-01-31 16:00:00');
|
||||
|
||||
$session = Session::reconstitute(
|
||||
$familyId,
|
||||
$userId,
|
||||
$tenantId,
|
||||
$deviceInfo,
|
||||
$location,
|
||||
$createdAt,
|
||||
$lastActivityAt,
|
||||
);
|
||||
|
||||
self::assertTrue($session->familyId->equals($familyId));
|
||||
self::assertEquals($createdAt, $session->createdAt);
|
||||
self::assertEquals($lastActivityAt, $session->lastActivityAt);
|
||||
}
|
||||
|
||||
private function createSession(): Session
|
||||
{
|
||||
return Session::create(
|
||||
TokenFamilyId::generate(),
|
||||
UserId::generate(),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
DeviceInfo::fromUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0'),
|
||||
Location::fromIp('192.168.1.1', 'France', 'Paris'),
|
||||
new DateTimeImmutable('2026-01-31 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user