Files
Classeo/backend/src/Administration/Infrastructure/Api/Controller/SessionsController.php
Mathias STRASSER b823479658 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
2026-02-03 10:53:31 +01:00

243 lines
9.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Controller;
use App\Administration\Domain\Event\SessionInvalidee;
use App\Administration\Domain\Event\ToutesSessionsInvalidees;
use App\Administration\Domain\Exception\SessionNotFoundException;
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\RefreshTokenRepository;
use App\Administration\Domain\Repository\SessionRepository;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function count;
use DateTimeInterface;
use InvalidArgumentException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
* Sessions management endpoints.
*
* Architecture note: Events (SessionInvalidee, ToutesSessionsInvalidees) are dispatched
* directly from this controller rather than from domain aggregates. This is a documented
* exception to the standard DDD pattern because:
* - Session is a read model without command/aggregate behavior
* - The "invalidate" operation spans multiple aggregates (RefreshToken + Session)
* - The controller orchestrates infrastructure operations, making it the natural
* event emission point for audit trail purposes
*
* @see Story 1.6 - Gestion des sessions
*/
final readonly class SessionsController
{
public function __construct(
private Security $security,
private SessionRepository $sessionRepository,
private RefreshTokenRepository $refreshTokenRepository,
private MessageBusInterface $eventBus,
private Clock $clock,
) {
}
/**
* List all active sessions for the current user.
*/
#[Route('/api/me/sessions', name: 'api_sessions_list', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
$user = $this->getSecurityUser();
$userId = UserId::fromString($user->userId());
$tenantId = TenantId::fromString($user->tenantId());
// Get the current family ID from the refresh token cookie
$currentFamilyId = $this->getCurrentFamilyId($request, $userId);
$sessions = $this->sessionRepository->findAllByUserId($userId);
$sessionsData = [];
foreach ($sessions as $session) {
// Skip sessions from other tenants (defense in depth)
if (!$session->tenantId->equals($tenantId)) {
continue;
}
$isCurrent = $currentFamilyId !== null && $session->isCurrent($currentFamilyId);
$sessionsData[] = [
'family_id' => (string) $session->familyId,
'device' => $session->deviceInfo->device,
'browser' => $session->deviceInfo->browser,
'os' => $session->deviceInfo->os,
'location' => $session->location->format(),
'created_at' => $session->createdAt->format(DateTimeInterface::ATOM),
'last_activity_at' => $session->lastActivityAt->format(DateTimeInterface::ATOM),
'is_current' => $isCurrent,
];
}
// Sort: current session first, then by last activity (most recent first)
usort($sessionsData, static function (array $a, array $b): int {
if ($a['is_current'] && !$b['is_current']) {
return -1;
}
if (!$a['is_current'] && $b['is_current']) {
return 1;
}
return $b['last_activity_at'] <=> $a['last_activity_at'];
});
return new JsonResponse(['sessions' => $sessionsData]);
}
/**
* Revoke a specific session.
*/
#[Route('/api/me/sessions/{familyId}', name: 'api_sessions_revoke', methods: ['DELETE'])]
public function revoke(string $familyId, Request $request): JsonResponse
{
$user = $this->getSecurityUser();
$userId = UserId::fromString($user->userId());
try {
$targetFamilyId = TokenFamilyId::fromString($familyId);
$session = $this->sessionRepository->getByFamilyId($targetFamilyId);
} catch (InvalidArgumentException|SessionNotFoundException) {
throw new NotFoundHttpException('Session not found');
}
// Verify the session belongs to the user (return 404 to not leak existence)
if (!$session->belongsToUser($userId)) {
throw new NotFoundHttpException('Session not found');
}
// Prevent revoking current session via this endpoint (use logout instead)
$currentFamilyId = $this->getCurrentFamilyId($request, $userId);
if ($currentFamilyId !== null && $session->isCurrent($currentFamilyId)) {
throw new AccessDeniedHttpException('Cannot revoke current session. Use logout instead.');
}
// Invalidate the token family (disconnects the device)
$this->refreshTokenRepository->invalidateFamily($targetFamilyId);
// Delete the session
$this->sessionRepository->delete($targetFamilyId);
// Dispatch event for audit trail
$this->eventBus->dispatch(new SessionInvalidee(
userId: $user->userId(),
familyId: (string) $targetFamilyId,
tenantId: TenantId::fromString($user->tenantId()),
ipAddress: $request->getClientIp() ?? 'unknown',
userAgent: $request->headers->get('User-Agent', 'unknown'),
occurredOn: $this->clock->now(),
));
return new JsonResponse(['message' => 'Session revoked'], Response::HTTP_OK);
}
/**
* Revoke all sessions except the current one.
*/
#[Route('/api/me/sessions', name: 'api_sessions_revoke_all', methods: ['DELETE'])]
public function revokeAll(Request $request): JsonResponse
{
$user = $this->getSecurityUser();
$userId = UserId::fromString($user->userId());
// Get the current family ID to exclude it
$currentFamilyId = $this->getCurrentFamilyId($request, $userId);
if ($currentFamilyId === null) {
throw new AccessDeniedHttpException('Unable to identify current session');
}
// Delete all sessions except current
$deletedFamilyIds = $this->sessionRepository->deleteAllExcept($userId, $currentFamilyId);
// Invalidate all corresponding token families
foreach ($deletedFamilyIds as $familyId) {
$this->refreshTokenRepository->invalidateFamily($familyId);
}
// Dispatch event for audit trail (only if sessions were actually revoked)
if (count($deletedFamilyIds) > 0) {
$this->eventBus->dispatch(new ToutesSessionsInvalidees(
userId: $user->userId(),
invalidatedFamilyIds: array_map(static fn (TokenFamilyId $id): string => (string) $id, $deletedFamilyIds),
exceptFamilyId: (string) $currentFamilyId,
tenantId: TenantId::fromString($user->tenantId()),
ipAddress: $request->getClientIp() ?? 'unknown',
userAgent: $request->headers->get('User-Agent', 'unknown'),
occurredOn: $this->clock->now(),
));
}
return new JsonResponse([
'message' => 'All other sessions revoked',
'revoked_count' => count($deletedFamilyIds),
], Response::HTTP_OK);
}
private function getSecurityUser(): SecurityUser
{
$user = $this->security->getUser();
if (!$user instanceof SecurityUser) {
throw new AccessDeniedHttpException('Authentication required');
}
return $user;
}
/**
* Get the current session's family ID from the refresh token cookie.
*
* Security: Validates that the refresh token belongs to the authenticated user
* to prevent cross-account issues in multi-tab scenarios where JWT and cookie
* could belong to different users.
*/
private function getCurrentFamilyId(Request $request, UserId $authenticatedUserId): ?TokenFamilyId
{
$refreshTokenValue = $request->cookies->get('refresh_token');
if ($refreshTokenValue === null) {
return null;
}
try {
$tokenId = RefreshToken::extractIdFromTokenString($refreshTokenValue);
$refreshToken = $this->refreshTokenRepository->find($tokenId);
if ($refreshToken === null) {
return null;
}
// Verify the refresh token belongs to the authenticated user
// This prevents misidentification in multi-tab/account-switch scenarios
if (!$refreshToken->userId->equals($authenticatedUserId)) {
return null;
}
return $refreshToken->familyId;
} catch (InvalidArgumentException) {
return null;
}
}
}