Implémentation complète de la stack d'observabilité pour le monitoring de la plateforme multi-tenant Classeo. ## Error Tracking (GlitchTip) - Intégration Sentry SDK avec GlitchTip auto-hébergé - Scrubber PII avant envoi (RGPD: emails, tokens JWT, NIR français) - Contexte enrichi: tenant_id, user_id, correlation_id - Configuration backend (sentry.yaml) et frontend (sentry.ts) ## Metrics (Prometheus) - Endpoint /metrics avec restriction IP en production - Métriques HTTP: requests_total, request_duration_seconds (histogramme) - Métriques sécurité: login_failures_total par tenant - Métriques santé: health_check_status (postgres, redis, rabbitmq) - Storage Redis pour persistance entre requêtes ## Logs (Loki) - Processors Monolog: CorrelationIdLogProcessor, PiiScrubberLogProcessor - Détection PII: emails, téléphones FR, tokens JWT, NIR français - Labels structurés: tenant_id, correlation_id, level ## Dashboards (Grafana) - Dashboard principal: latence P50/P95/P99, error rate, RPS - Dashboard par tenant: métriques isolées par sous-domaine - Dashboard infrastructure: santé postgres/redis/rabbitmq - Datasources avec UIDs fixes pour portabilité ## Alertes (Alertmanager) - HighApiLatencyP95/P99: SLA monitoring (200ms/500ms) - HighErrorRate: error rate > 1% pendant 2 min - ExcessiveLoginFailures: détection brute force - ApplicationUnhealthy: health check failures ## Infrastructure - InfrastructureHealthChecker: service partagé (DRY) - HealthCheckController: endpoint /health pour load balancers - Pre-push hook: make ci && make e2e avant push
167 lines
5.9 KiB
PHP
167 lines
5.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Administration\Infrastructure\Security;
|
|
|
|
use App\Administration\Domain\Event\CompteBloqueTemporairement;
|
|
use App\Administration\Domain\Event\ConnexionEchouee;
|
|
use App\Shared\Domain\Clock;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use App\Shared\Infrastructure\Monitoring\MetricsCollector;
|
|
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
|
|
use App\Shared\Infrastructure\RateLimit\LoginRateLimitResult;
|
|
use App\Shared\Infrastructure\Tenant\TenantResolver;
|
|
|
|
use function is_array;
|
|
use function is_string;
|
|
use function sprintf;
|
|
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\Messenger\MessageBusInterface;
|
|
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
|
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Handles login failures: Fibonacci rate limiting, audit, user-friendly messages.
|
|
*
|
|
* Important: Never reveal whether the email exists or not (AC2).
|
|
*
|
|
* Note: /api/login is excluded from TenantMiddleware, so we resolve tenant
|
|
* directly from host header using TenantResolver.
|
|
*
|
|
* @see Story 1.4 - T5: Backend Login Endpoint
|
|
*/
|
|
final readonly class LoginFailureHandler implements AuthenticationFailureHandlerInterface
|
|
{
|
|
public function __construct(
|
|
private LoginRateLimiterInterface $rateLimiter,
|
|
private MessageBusInterface $eventBus,
|
|
private Clock $clock,
|
|
private TenantResolver $tenantResolver,
|
|
private MetricsCollector $metricsCollector,
|
|
) {
|
|
}
|
|
|
|
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
|
{
|
|
$content = json_decode($request->getContent(), true);
|
|
$email = is_array($content) && isset($content['email']) && is_string($content['email'])
|
|
? $content['email']
|
|
: 'unknown';
|
|
$ipAddress = $request->getClientIp() ?? 'unknown';
|
|
$userAgent = $request->headers->get('User-Agent', 'unknown');
|
|
|
|
// Record the failure and get the new state
|
|
$result = $this->rateLimiter->recordFailure($request, $email);
|
|
|
|
// Resolve tenant from host header (TenantMiddleware skips /api/login)
|
|
$tenantId = $this->resolveTenantFromHost($request);
|
|
|
|
$this->eventBus->dispatch(new ConnexionEchouee(
|
|
email: $email,
|
|
tenantId: $tenantId,
|
|
ipAddress: $ipAddress,
|
|
userAgent: $userAgent,
|
|
reason: 'invalid_credentials',
|
|
occurredOn: $this->clock->now(),
|
|
));
|
|
|
|
// Record metric for Prometheus alerting
|
|
$this->metricsCollector->recordLoginFailure('invalid_credentials');
|
|
|
|
// If the IP was just blocked
|
|
if ($result->ipBlocked) {
|
|
$this->eventBus->dispatch(new CompteBloqueTemporairement(
|
|
email: $email,
|
|
tenantId: $tenantId,
|
|
ipAddress: $ipAddress,
|
|
userAgent: $userAgent,
|
|
blockedForSeconds: $result->retryAfter ?? LoginRateLimiterInterface::IP_BLOCK_DURATION,
|
|
failedAttempts: $result->attempts,
|
|
occurredOn: $this->clock->now(),
|
|
));
|
|
|
|
return $this->createBlockedResponse($result);
|
|
}
|
|
|
|
// Standard failure response with delay and CAPTCHA info
|
|
return $this->createFailureResponse($result);
|
|
}
|
|
|
|
private function createBlockedResponse(LoginRateLimitResult $result): JsonResponse
|
|
{
|
|
$response = new JsonResponse([
|
|
'type' => '/errors/ip-blocked',
|
|
'title' => 'Accès temporairement bloqué',
|
|
'status' => Response::HTTP_TOO_MANY_REQUESTS,
|
|
'detail' => sprintf(
|
|
'Trop de tentatives de connexion. Réessayez dans %s.',
|
|
$result->getFormattedDelay(),
|
|
),
|
|
'retryAfter' => $result->retryAfter,
|
|
], Response::HTTP_TOO_MANY_REQUESTS);
|
|
|
|
foreach ($result->toHeaders() as $name => $value) {
|
|
$response->headers->set($name, $value);
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
private function createFailureResponse(LoginRateLimitResult $result): JsonResponse
|
|
{
|
|
$data = [
|
|
'type' => '/errors/authentication-failed',
|
|
'title' => 'Identifiants incorrects',
|
|
'status' => Response::HTTP_UNAUTHORIZED,
|
|
'detail' => 'L\'adresse email ou le mot de passe est incorrect.',
|
|
'attempts' => $result->attempts,
|
|
];
|
|
|
|
// Add delay if applicable
|
|
if ($result->delaySeconds > 0) {
|
|
$data['delay'] = $result->delaySeconds;
|
|
$data['delayFormatted'] = $result->getFormattedDelay();
|
|
}
|
|
|
|
// Indicate if CAPTCHA is required for the next attempt
|
|
if ($result->requiresCaptcha) {
|
|
$data['captchaRequired'] = true;
|
|
}
|
|
|
|
$response = new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
|
|
|
|
foreach ($result->toHeaders() as $name => $value) {
|
|
$response->headers->set($name, $value);
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Resolve tenant from request host header.
|
|
*
|
|
* Since /api/login is excluded from TenantMiddleware, we must resolve
|
|
* the tenant ourselves to properly scope audit events.
|
|
*
|
|
* Returns null if tenant cannot be resolved (unknown domain, database issues, etc.)
|
|
* to ensure login failure handling never breaks due to tenant resolution.
|
|
*/
|
|
private function resolveTenantFromHost(Request $request): ?TenantId
|
|
{
|
|
try {
|
|
$config = $this->tenantResolver->resolve($request->getHost());
|
|
|
|
return $config->tenantId;
|
|
} catch (Throwable) {
|
|
// Login attempt on unknown domain or tenant resolution failed
|
|
// Don't let tenant resolution break the login failure handling
|
|
return null;
|
|
}
|
|
}
|
|
}
|