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