Files
Classeo/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php
Mathias STRASSER d3c6773be5 feat: Observabilité et monitoring complet
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
2026-02-04 12:59:12 +01:00

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;
}
}
}