Story 1.7 - Implémente un système complet d'audit trail pour tracer toutes les actions sensibles (authentification, modifications de données, exports) avec immuabilité garantie par PostgreSQL. Fonctionnalités principales: - Table audit_log append-only avec contraintes PostgreSQL (RULE) - AuditLogger centralisé avec injection automatique du contexte - Correlation ID pour traçabilité distribuée (HTTP + async) - Handlers pour événements d'authentification - Commande d'archivage des logs anciens - Pas de PII dans les logs (emails/IPs hashés) Infrastructure: - Middlewares Messenger pour propagation du Correlation ID - HTTP middleware pour génération/propagation du Correlation ID - Support multi-tenant avec TenantResolver
332 lines
12 KiB
PHP
332 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Administration\Infrastructure\Security;
|
|
|
|
use App\Administration\Application\Port\GeoLocationService;
|
|
use App\Administration\Application\Service\RefreshTokenManager;
|
|
use App\Administration\Domain\Event\ConnexionReussie;
|
|
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\Administration\Infrastructure\Persistence\InMemory\InMemoryRefreshTokenRepository;
|
|
use App\Administration\Infrastructure\Security\LoginSuccessHandler;
|
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
|
use App\Shared\Domain\Clock;
|
|
use App\Shared\Domain\DomainEvent;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
|
|
use DateTimeImmutable;
|
|
use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationSuccessEvent;
|
|
use Override;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\Messenger\Envelope;
|
|
use Symfony\Component\Messenger\MessageBusInterface;
|
|
use Symfony\Component\Security\Core\User\UserInterface;
|
|
|
|
/**
|
|
* [P0] Tests for LoginSuccessHandler - critical authentication flow.
|
|
*
|
|
* Verifies:
|
|
* - Refresh token creation on successful login
|
|
* - Session creation with device info and geolocation
|
|
* - Rate limiter reset after successful login
|
|
* - ConnexionReussie event dispatch
|
|
* - HttpOnly cookie configuration
|
|
*/
|
|
final class LoginSuccessHandlerTest extends TestCase
|
|
{
|
|
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
|
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
|
private const string EMAIL = 'user@example.com';
|
|
private const string IP_ADDRESS = '192.168.1.100';
|
|
private const string USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
|
|
|
|
private InMemoryRefreshTokenRepository $refreshTokenRepository;
|
|
private RefreshTokenManager $refreshTokenManager;
|
|
private SessionRepository $sessionRepository;
|
|
private GeoLocationService $geoLocationService;
|
|
private LoginRateLimiterInterface $rateLimiter;
|
|
private Clock $clock;
|
|
private RequestStack $requestStack;
|
|
/** @var DomainEvent[] */
|
|
private array $dispatchedEvents = [];
|
|
private LoginSuccessHandler $handler;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->clock = new class implements Clock {
|
|
#[Override]
|
|
public function now(): DateTimeImmutable
|
|
{
|
|
return new DateTimeImmutable('2026-01-28 10:00:00');
|
|
}
|
|
};
|
|
|
|
$this->refreshTokenRepository = new InMemoryRefreshTokenRepository();
|
|
$this->refreshTokenManager = new RefreshTokenManager(
|
|
$this->refreshTokenRepository,
|
|
$this->clock,
|
|
);
|
|
|
|
$this->sessionRepository = $this->createMock(SessionRepository::class);
|
|
$this->geoLocationService = $this->createMock(GeoLocationService::class);
|
|
$this->rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
|
$this->requestStack = new RequestStack();
|
|
|
|
$this->dispatchedEvents = [];
|
|
$eventBus = new class($this->dispatchedEvents) implements MessageBusInterface {
|
|
/** @param DomainEvent[] $events */
|
|
public function __construct(private array &$events)
|
|
{
|
|
}
|
|
|
|
#[Override]
|
|
public function dispatch(object $message, array $stamps = []): Envelope
|
|
{
|
|
$this->events[] = $message;
|
|
|
|
return new Envelope($message);
|
|
}
|
|
};
|
|
|
|
$this->handler = new LoginSuccessHandler(
|
|
$this->refreshTokenManager,
|
|
$this->sessionRepository,
|
|
$this->geoLocationService,
|
|
$this->rateLimiter,
|
|
$eventBus,
|
|
$this->clock,
|
|
$this->requestStack,
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function itCreatesRefreshTokenOnSuccessfulLogin(): void
|
|
{
|
|
// GIVEN: A successful authentication event with SecurityUser
|
|
$request = $this->createRequest();
|
|
$this->requestStack->push($request);
|
|
|
|
$securityUser = $this->createSecurityUser();
|
|
$response = new Response();
|
|
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
|
|
|
|
$this->geoLocationService
|
|
->method('locate')
|
|
->willReturn(Location::unknown());
|
|
|
|
$this->sessionRepository
|
|
->expects($this->once())
|
|
->method('save')
|
|
->with(
|
|
$this->isInstanceOf(Session::class),
|
|
$this->greaterThan(0),
|
|
);
|
|
|
|
// WHEN: Handler processes the event
|
|
$this->handler->onAuthenticationSuccess($event);
|
|
|
|
// THEN: Refresh token cookie is set
|
|
$cookies = $response->headers->getCookies();
|
|
$this->assertCount(1, $cookies);
|
|
$this->assertSame('refresh_token', $cookies[0]->getName());
|
|
$this->assertTrue($cookies[0]->isHttpOnly());
|
|
$this->assertSame('/api', $cookies[0]->getPath());
|
|
|
|
// THEN: Refresh token is saved in repository
|
|
$this->assertTrue(
|
|
$this->refreshTokenRepository->hasActiveSessionsForUser(
|
|
UserId::fromString(self::USER_ID),
|
|
),
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function itCreatesSessionWithDeviceInfoAndLocation(): void
|
|
{
|
|
// GIVEN: A successful authentication with request context
|
|
$request = $this->createRequest();
|
|
$this->requestStack->push($request);
|
|
|
|
$securityUser = $this->createSecurityUser();
|
|
$response = new Response();
|
|
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
|
|
|
|
$expectedLocation = Location::fromIp(self::IP_ADDRESS, 'France', 'Paris');
|
|
|
|
$this->geoLocationService
|
|
->expects($this->once())
|
|
->method('locate')
|
|
->with(self::IP_ADDRESS)
|
|
->willReturn($expectedLocation);
|
|
|
|
$savedSession = null;
|
|
$this->sessionRepository
|
|
->expects($this->once())
|
|
->method('save')
|
|
->willReturnCallback(static function (Session $session, int $ttl) use (&$savedSession): void {
|
|
$savedSession = $session;
|
|
});
|
|
|
|
// WHEN: Handler processes the event
|
|
$this->handler->onAuthenticationSuccess($event);
|
|
|
|
// THEN: Session is created with correct data
|
|
$this->assertNotNull($savedSession);
|
|
$this->assertSame(self::USER_ID, (string) $savedSession->userId);
|
|
}
|
|
|
|
#[Test]
|
|
public function itResetsRateLimiterAfterSuccessfulLogin(): void
|
|
{
|
|
// GIVEN: A successful authentication
|
|
$request = $this->createRequest();
|
|
$this->requestStack->push($request);
|
|
|
|
$securityUser = $this->createSecurityUser();
|
|
$response = new Response();
|
|
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
|
|
|
|
$this->geoLocationService
|
|
->method('locate')
|
|
->willReturn(Location::unknown());
|
|
|
|
// THEN: Rate limiter should be reset for the user's email
|
|
$this->rateLimiter
|
|
->expects($this->once())
|
|
->method('reset')
|
|
->with(self::EMAIL);
|
|
|
|
// WHEN: Handler processes the event
|
|
$this->handler->onAuthenticationSuccess($event);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDispatchesConnexionReussieEvent(): void
|
|
{
|
|
// GIVEN: A successful authentication
|
|
$request = $this->createRequest();
|
|
$this->requestStack->push($request);
|
|
|
|
$securityUser = $this->createSecurityUser();
|
|
$response = new Response();
|
|
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
|
|
|
|
$this->geoLocationService
|
|
->method('locate')
|
|
->willReturn(Location::unknown());
|
|
|
|
// WHEN: Handler processes the event
|
|
$this->handler->onAuthenticationSuccess($event);
|
|
|
|
// THEN: ConnexionReussie event is dispatched
|
|
$loginEvents = array_filter(
|
|
$this->dispatchedEvents,
|
|
static fn ($e) => $e instanceof ConnexionReussie,
|
|
);
|
|
$this->assertCount(1, $loginEvents);
|
|
|
|
$loginEvent = reset($loginEvents);
|
|
$this->assertSame(self::USER_ID, $loginEvent->userId);
|
|
$this->assertSame(self::EMAIL, $loginEvent->email);
|
|
$this->assertSame(self::IP_ADDRESS, $loginEvent->ipAddress);
|
|
}
|
|
|
|
#[Test]
|
|
public function itIgnoresNonSecurityUserAuthentication(): void
|
|
{
|
|
// GIVEN: An authentication event with a non-SecurityUser user
|
|
$request = $this->createRequest();
|
|
$this->requestStack->push($request);
|
|
|
|
$genericUser = $this->createMock(UserInterface::class);
|
|
$response = new Response();
|
|
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $genericUser, $response);
|
|
|
|
// THEN: No operations should be performed
|
|
$this->sessionRepository
|
|
->expects($this->never())
|
|
->method('save');
|
|
|
|
// WHEN: Handler processes the event
|
|
$this->handler->onAuthenticationSuccess($event);
|
|
|
|
// THEN: No cookies added, no events dispatched
|
|
$this->assertEmpty($response->headers->getCookies());
|
|
$this->assertEmpty($this->dispatchedEvents);
|
|
}
|
|
|
|
#[Test]
|
|
public function itIgnoresEventWithoutRequest(): void
|
|
{
|
|
// GIVEN: No request in the stack
|
|
$securityUser = $this->createSecurityUser();
|
|
$response = new Response();
|
|
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
|
|
|
|
// THEN: No operations should be performed
|
|
$this->sessionRepository
|
|
->expects($this->never())
|
|
->method('save');
|
|
|
|
// WHEN: Handler processes the event
|
|
$this->handler->onAuthenticationSuccess($event);
|
|
|
|
// THEN: No cookies added
|
|
$this->assertEmpty($response->headers->getCookies());
|
|
}
|
|
|
|
#[Test]
|
|
public function itSetsSecureCookieOnlyForHttps(): void
|
|
{
|
|
// GIVEN: An HTTP request (not HTTPS)
|
|
$request = Request::create('http://localhost/login', 'POST', [], [], [], [
|
|
'REMOTE_ADDR' => self::IP_ADDRESS,
|
|
'HTTP_USER_AGENT' => self::USER_AGENT,
|
|
]);
|
|
$this->requestStack->push($request);
|
|
|
|
$securityUser = $this->createSecurityUser();
|
|
$response = new Response();
|
|
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
|
|
|
|
$this->geoLocationService
|
|
->method('locate')
|
|
->willReturn(Location::unknown());
|
|
|
|
// WHEN: Handler processes the event
|
|
$this->handler->onAuthenticationSuccess($event);
|
|
|
|
// THEN: Cookie is NOT marked as secure (HTTP)
|
|
$cookies = $response->headers->getCookies();
|
|
$this->assertCount(1, $cookies);
|
|
$this->assertFalse($cookies[0]->isSecure());
|
|
}
|
|
|
|
private function createRequest(): Request
|
|
{
|
|
return Request::create('/login', 'POST', [], [], [], [
|
|
'REMOTE_ADDR' => self::IP_ADDRESS,
|
|
'HTTP_USER_AGENT' => self::USER_AGENT,
|
|
]);
|
|
}
|
|
|
|
private function createSecurityUser(): SecurityUser
|
|
{
|
|
return new SecurityUser(
|
|
userId: UserId::fromString(self::USER_ID),
|
|
email: self::EMAIL,
|
|
hashedPassword: '$argon2id$hashed',
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
roles: ['ROLE_PROF'],
|
|
);
|
|
}
|
|
}
|