Files
server/core/lib/Service/FirewallService.php
T
2026-07-30 23:01:38 -04:00

366 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Http\Request\Request;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Stores\FirewallStore;
use KTXC\Context\TenantContextInterface;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent;
use KTXF\IpUtils;
/**
* Firewall service for IP/device-based access control
*
* Features:
* - IP allow/block lists per tenant
* - CIDR range support
* - Device fingerprint blocking
* - Automatic blocking on brute force detection
* - Event-driven integration
*/
class FirewallService
{
// Default thresholds for auto-blocking
private const DEFAULT_MAX_AUTH_FAILURES = 5;
private const DEFAULT_AUTH_FAILURE_WINDOW = 300; // 5 minutes
private const DEFAULT_AUTO_BLOCK_DURATION = 3600; // 1 hour
private const MAX_AUTH_FAILURES = 1000;
private const MAX_AUTH_FAILURE_WINDOW = 86400; // 1 day
private const MAX_AUTO_BLOCK_DURATION = 31536000; // 1 year
// Configuration keys
private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures';
private const CONFIG_FAILURE_WINDOW = 'firewall.authFailureWindow';
private const CONFIG_AUTO_BLOCK_DURATION = 'firewall.autoBlockDuration';
private const CONFIG_ENABLED = 'firewall.enabled';
public function __construct(
private readonly FirewallStore $store,
private readonly TenantContextInterface $tenantContext,
private readonly EventDispatcherInterface $events,
private readonly FirewallRuleManager $rules,
private readonly FirewallRuleCache $ruleCache,
) {
}
/**
* Check firewall rules for a request
* Returns a Response if blocked, null if allowed
*/
public function authorized(Request $request): bool
{
$ipAddress = $request->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(SecurityEvent $event): void
{
$ipAddress = $event->getIpAddress();
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
if (!$ipAddress || !$tenantId) {
return;
}
$event->setTenantId($tenantId);
$log = $this->securityLog($event);
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) {
$this->handleBruteForce($tenantId, $ipAddress, $failureCount, $windowSeconds);
}
}
/**
* Handle detected brute force attack
*/
private function handleBruteForce(
string $tenantId,
string $ipAddress,
int $failureCount,
int $windowSeconds
): void {
// Publish brute force event
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
$event->setTenantId($tenantId);
$this->events->dispatch($event);
// Auto-block the IP
$blockDuration = $this->getBoundedIntegerConfig(
self::CONFIG_AUTO_BLOCK_DURATION,
self::DEFAULT_AUTO_BLOCK_DURATION,
self::MAX_AUTO_BLOCK_DURATION
);
$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
);
}
/**
* Log security event to firewall logs
*/
public function logSecurityEvent(SecurityEvent $event): void
{
$log = $this->securityLog($event);
if ($log !== null) {
$this->store->createLog($log);
}
}
private function securityLog(SecurityEvent $event): ?FirewallLogObject
{
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
$ruleScope = $event->get('ruleScope');
if (!$tenantId && $ruleScope !== FirewallRuleObject::SCOPE_SYSTEM) {
return null;
}
$log = new FirewallLogObject();
return $log->setEventId($event->getEventId())
->setTenantId($tenantId)
->setIpAddress($event->getIpAddress())
->setDeviceFingerprint($event->getDeviceFingerprint())
->setUserAgent($event->getUserAgent())
->setRequestPath($event->getRequestPath())
->setRequestMethod($event->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) {
SecurityEvent::AUTH_FAILURE => FirewallLogObject::EVENT_AUTH_FAILURE,
SecurityEvent::AUTH_SUCCESS => FirewallLogObject::EVENT_ACCESS_CHECK,
SecurityEvent::BRUTE_FORCE_DETECTED => FirewallLogObject::EVENT_BRUTE_FORCE,
SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT,
SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
default => FirewallLogObject::EVENT_ACCESS_CHECK,
};
}
/**
* Map security event to result
*/
private function mapEventToResult(SecurityEvent $event): string
{
return match ($event->getName()) {
SecurityEvent::AUTH_SUCCESS,
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::RESULT_RECORDED,
default => FirewallLogObject::RESULT_BLOCKED,
};
}
/**
* Publish access denied event
*/
private function publishAccessDenied(
string $ipAddress,
?string $deviceFingerprint,
FirewallRuleObject $rule
): void {
$event = SecurityEvent::accessDenied(
$ipAddress,
$deviceFingerprint,
$rule->getId(),
$rule->getScope(),
$rule->getReason()
);
$event->setTenantId($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
{
$expiredRules = $this->store->cleanupExpiredRules();
$oldLogs = $this->store->cleanupOldLogs(30);
return [
'expiredRules' => $expiredRules,
'oldLogs' => $oldLogs,
];
}
}
/**
* 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;
}
}