52afd35d6f
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
413 lines
13 KiB
PHP
413 lines
13 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 KTXC\Security\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;
|
|
}
|
|
|
|
$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) {
|
|
$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 = SecurityEvent::bruteForceDetected(
|
|
$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(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_EXTENDED => FirewallLogObject::EVENT_RULE_EXTENDED,
|
|
SecurityEvent::FIREWALL_RULE_ENABLED => FirewallLogObject::EVENT_RULE_ENABLED,
|
|
SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED,
|
|
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
|
|
SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::EVENT_SETTINGS_UPDATED,
|
|
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_EXTENDED,
|
|
SecurityEvent::FIREWALL_RULE_ENABLED,
|
|
SecurityEvent::FIREWALL_RULE_DISABLED,
|
|
SecurityEvent::FIREWALL_RULE_REMOVED,
|
|
SecurityEvent::FIREWALL_SETTINGS_UPDATED => 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(),
|
|
$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;
|
|
}
|
|
}
|