getClientIp() ?? '0.0.0.0'; $deviceFingerprint = $request->headers->get('X-Device-Fingerprint'); $result = $this->analyze($ipAddress, $deviceFingerprint); if ($result->isBlocked()) { return false; } return true; } /** * Check if a request is allowed based on IP and device fingerprint */ public function analyze( string $ipAddress, ?string $deviceFingerprint = null ): FirewallAnalyzeResult { $tenantId = $this->tenantContext->identifier(); $ruleGroups = [ [$this->ruleCache->system(), FirewallRuleObject::ACTION_BLOCK], ]; if ($tenantId !== null && $this->isEnabled()) { $tenantRules = $this->ruleCache->tenant($tenantId); $ruleGroups[] = [$tenantRules, FirewallRuleObject::ACTION_ALLOW]; $ruleGroups[] = [$tenantRules, FirewallRuleObject::ACTION_BLOCK]; } $ruleGroups[] = [$this->ruleCache->system(), FirewallRuleObject::ACTION_ALLOW]; foreach ($ruleGroups as [$rules, $action]) { foreach ($rules as $rule) { if ($rule->getAction() !== $action) { continue; } if (!$this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) { continue; } if ($action === FirewallRuleObject::ACTION_ALLOW) { return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed'); } $this->publishAccessDenied($ipAddress, $deviceFingerprint, $rule); return new FirewallAnalyzeResult(false, $rule->getId(), $rule->getReason()); } } return new FirewallAnalyzeResult(true); } /** * Check if a rule matches the request */ private function ruleMatchesRequest( FirewallRuleObject $rule, string $ipAddress, ?string $deviceFingerprint ): bool { $type = $rule->getType(); $value = $rule->getValue(); return match ($type) { FirewallRuleObject::TYPE_IP => $ipAddress === $value, FirewallRuleObject::TYPE_IP_RANGE => IpUtils::checkIp($ipAddress, $value), FirewallRuleObject::TYPE_DEVICE => $deviceFingerprint !== null && $deviceFingerprint === $value, default => false, }; } /** * Handle authentication failure event */ public function handleAuthFailure(AuthenticationFailedEvent $event): void { $request = $this->requestContext->current(); $ipAddress = $request?->getClientIp(); $tenantId = $event->getTenantId() ?? $this->tenantContext->identifier(); if (!$ipAddress || !$tenantId) { return; } $log = $this->securityLog($event, $request); if ($log === null || !$this->store->createLogOnce($log)) { return; } // Check for brute force $windowSeconds = $this->getBoundedIntegerConfig( self::CONFIG_FAILURE_WINDOW, self::DEFAULT_AUTH_FAILURE_WINDOW, self::MAX_AUTH_FAILURE_WINDOW ); $maxFailures = $this->getBoundedIntegerConfig( self::CONFIG_MAX_FAILURES, self::DEFAULT_MAX_AUTH_FAILURES, self::MAX_AUTH_FAILURES ); $failureCount = $this->store->countRecentFailures( $tenantId, $ipAddress, $windowSeconds ); if ($failureCount >= $maxFailures) { $blockDuration = $this->getBoundedIntegerConfig( self::CONFIG_AUTO_BLOCK_DURATION, self::DEFAULT_AUTO_BLOCK_DURATION, self::MAX_AUTO_BLOCK_DURATION ); $responseCooldown = min($windowSeconds, max(1, intdiv($blockDuration, 2))); if (!$this->store->claimBruteForce($tenantId, $ipAddress, $responseCooldown)) { return; } $this->handleBruteForce( $tenantId, $ipAddress, $failureCount, $windowSeconds, $blockDuration ); } } /** * Handle detected brute force attack */ private function handleBruteForce( string $tenantId, string $ipAddress, int $failureCount, int $windowSeconds, int $blockDuration ): void { // Publish brute force event $event = new BruteForceDetectedEvent( $ipAddress, $failureCount, $windowSeconds, $tenantId, ); $this->events->dispatch($event); $this->rules->blockIp( FirewallRuleScope::tenant($tenantId), $ipAddress, sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds), null, // System-created $blockDuration, FirewallRuleManager::ORIGIN_AUTOMATIC, [ 'failureThreshold' => $this->getBoundedIntegerConfig( self::CONFIG_MAX_FAILURES, self::DEFAULT_MAX_AUTH_FAILURES, self::MAX_AUTH_FAILURES ), 'failureWindowSeconds' => $windowSeconds, 'lastFailureCount' => $failureCount, 'blockDurationSeconds' => $blockDuration, ] ); } /** * Log security event to firewall logs */ public function logSecurityEvent(SecurityEventInterface $event): void { $log = $this->securityLog($event); if ($log !== null) { $this->store->createLog($log); } } private function securityLog( SecurityEventInterface $event, ?Request $request = null, ): ?FirewallLogObject { $tenantId = $event->getTenantId() ?? $this->tenantContext->identifier(); $ruleScope = $event->get('ruleScope'); if (!$tenantId && $ruleScope !== FirewallRuleObject::SCOPE_SYSTEM) { return null; } $requestEvent = $event instanceof SecurityRequestEventInterface ? $event : null; $log = new FirewallLogObject(); return $log->setEventId($event->getEventId()) ->setTenantId($tenantId) ->setIpAddress($request?->getClientIp() ?? $requestEvent?->getIpAddress()) ->setDeviceFingerprint( $request?->headers->get('X-Device-Fingerprint') ?? $requestEvent?->getDeviceFingerprint() ) ->setUserAgent($request?->headers->get('User-Agent') ?? $requestEvent?->getUserAgent()) ->setRequestPath($request?->getPathInfo() ?? $requestEvent?->getRequestPath()) ->setRequestMethod($request?->getMethod() ?? $requestEvent?->getRequestMethod()) ->setEventType($this->mapEventToLogType($event->getName())) ->setResult($this->mapEventToResult($event)) ->setRuleId($event->get('ruleId')) ->setRuleScope($ruleScope) ->setIdentityId($event->getUserId() ?? $event->getIdentityId()) ->setTimestamp(new \DateTimeImmutable()) ->setMetadata($event->getData()); } /** * Map security event name to log event type */ private function mapEventToLogType(string $eventName): string { return match ($eventName) { AuthenticationFailedEvent::class => FirewallLogObject::EVENT_AUTH_FAILURE, AuthenticationSucceededEvent::class => FirewallLogObject::EVENT_ACCESS_CHECK, BruteForceDetectedEvent::class => FirewallLogObject::EVENT_BRUTE_FORCE, RateLimitExceededEvent::class => FirewallLogObject::EVENT_RATE_LIMIT, AccessDeniedEvent::class => FirewallLogObject::EVENT_RULE_MATCH, SuspiciousActivityEvent::class => FirewallLogObject::EVENT_SUSPICIOUS, FirewallRuleCreatedEvent::class => FirewallLogObject::EVENT_RULE_CREATED, FirewallRuleExtendedEvent::class => FirewallLogObject::EVENT_RULE_EXTENDED, FirewallRuleEnabledEvent::class => FirewallLogObject::EVENT_RULE_ENABLED, FirewallRuleDisabledEvent::class => FirewallLogObject::EVENT_RULE_DISABLED, FirewallRuleRemovedEvent::class => FirewallLogObject::EVENT_RULE_REMOVED, FirewallSettingsUpdatedEvent::class => FirewallLogObject::EVENT_SETTINGS_UPDATED, default => FirewallLogObject::EVENT_ACCESS_CHECK, }; } /** * Map security event to result */ private function mapEventToResult(SecurityEventInterface $event): string { return match ($event->getName()) { AuthenticationSucceededEvent::class => FirewallLogObject::RESULT_ALLOWED, FirewallRuleCreatedEvent::class, FirewallRuleExtendedEvent::class, FirewallRuleEnabledEvent::class, FirewallRuleDisabledEvent::class, FirewallRuleRemovedEvent::class, FirewallSettingsUpdatedEvent::class => FirewallLogObject::RESULT_RECORDED, default => FirewallLogObject::RESULT_BLOCKED, }; } /** * Publish access denied event */ private function publishAccessDenied( string $ipAddress, ?string $deviceFingerprint, FirewallRuleObject $rule ): void { $event = new AccessDeniedEvent( ipAddress: $ipAddress, ruleId: $rule->getId(), ruleScope: $rule->getScope(), deviceFingerprint: $deviceFingerprint, reason: $rule->getReason(), tenantId: $this->tenantContext->identifier(), ); $this->events->dispatch($event); } /** * Check if firewall is enabled for current tenant */ private function isEnabled(): bool { return (bool) $this->getConfig(self::CONFIG_ENABLED, true); } /** * Get configuration value */ private function getConfig(string $key, mixed $default = null): mixed { $config = $this->tenantContext->configuration(); if ($config instanceof \JsonSerializable) { $config = $config->jsonSerialize(); } $parts = explode('.', $key); foreach ($parts as $part) { if (!is_array($config) || !array_key_exists($part, $config)) { return $default; } $config = $config[$part]; } return $config; } private function getBoundedIntegerConfig(string $key, int $default, int $maximum): int { $value = $this->getConfig($key, $default); if (!is_int($value) || $value < 1 || $value > $maximum) { return $default; } return $value; } /** * Cleanup maintenance tasks */ public function cleanup(): array { $startedAt = new \DateTimeImmutable(); try { $result = [ 'expiredRules' => $this->store->cleanupExpiredRules(), 'oldLogs' => $this->store->cleanupOldLogs(30), 'expiredBruteForceClaims' => $this->store->cleanupExpiredBruteForceClaims(), ]; $this->store->recordMaintenanceStatus($startedAt, new \DateTimeImmutable(), 'success', $result); return $result; } catch (\Throwable $error) { try { $this->store->recordMaintenanceStatus( $startedAt, new \DateTimeImmutable(), 'failed', [], $error->getMessage() ); } catch (\Throwable) { // Preserve the cleanup failure when the status store is also unavailable. } throw $error; } } } /** * Result of a firewall check */ class FirewallAnalyzeResult { public function __construct( public readonly bool $allowed, public readonly ?string $ruleId = null, public readonly ?string $reason = null ) {} public function isAllowed(): bool { return $this->allowed; } public function isBlocked(): bool { return !$this->allowed; } }