Initial Version
This commit is contained in:
630
core/lib/Service/FirewallService.php
Normal file
630
core/lib/Service/FirewallService.php
Normal file
@@ -0,0 +1,630 @@
|
||||
<?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\SessionTenant;
|
||||
use KTXF\Event\EventBus;
|
||||
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
|
||||
|
||||
// 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';
|
||||
|
||||
/** @var FirewallRuleObject[]|null */
|
||||
private ?array $rulesCache = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallStore $store,
|
||||
private readonly SessionTenant $tenant,
|
||||
private readonly EventBus $eventBus
|
||||
) {
|
||||
// Listen for auth failures to detect brute force
|
||||
$this->eventBus->subscribe(
|
||||
SecurityEvent::AUTH_FAILURE,
|
||||
[$this, 'handleAuthFailure'],
|
||||
100 // High priority
|
||||
);
|
||||
|
||||
// Log all security events asynchronously
|
||||
$this->eventBus->subscribeAsync(
|
||||
SecurityEvent::AUTH_FAILURE,
|
||||
[$this, 'logSecurityEvent']
|
||||
);
|
||||
$this->eventBus->subscribeAsync(
|
||||
SecurityEvent::AUTH_SUCCESS,
|
||||
[$this, 'logSecurityEvent']
|
||||
);
|
||||
$this->eventBus->subscribeAsync(
|
||||
SecurityEvent::ACCESS_DENIED,
|
||||
[$this, 'logSecurityEvent']
|
||||
);
|
||||
$this->eventBus->subscribeAsync(
|
||||
SecurityEvent::BRUTE_FORCE_DETECTED,
|
||||
[$this, 'logSecurityEvent']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// Check if firewall is enabled for this tenant
|
||||
if (!$this->isEnabled()) {
|
||||
return new FirewallAnalyzeResult(true);
|
||||
}
|
||||
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return new FirewallAnalyzeResult(true);
|
||||
}
|
||||
|
||||
$rules = $this->getActiveRules();
|
||||
|
||||
// First check for explicit allow rules (whitelist takes precedence)
|
||||
foreach ($rules as $rule) {
|
||||
if ($rule->getAction() !== FirewallRuleObject::ACTION_ALLOW) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
|
||||
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
|
||||
}
|
||||
}
|
||||
|
||||
// Then check for block rules
|
||||
foreach ($rules as $rule) {
|
||||
if ($rule->getAction() !== FirewallRuleObject::ACTION_BLOCK) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
|
||||
$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->tenant->identifier();
|
||||
|
||||
if (!$ipAddress || !$tenantId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for brute force
|
||||
$windowSeconds = $this->getConfig(
|
||||
self::CONFIG_FAILURE_WINDOW,
|
||||
self::DEFAULT_AUTH_FAILURE_WINDOW
|
||||
);
|
||||
$maxFailures = $this->getConfig(
|
||||
self::CONFIG_MAX_FAILURES,
|
||||
self::DEFAULT_MAX_AUTH_FAILURES
|
||||
);
|
||||
|
||||
$failureCount = $this->store->countRecentFailures(
|
||||
$tenantId,
|
||||
$ipAddress,
|
||||
$windowSeconds
|
||||
);
|
||||
|
||||
// Include current failure in count
|
||||
$failureCount++;
|
||||
|
||||
if ($failureCount >= $maxFailures) {
|
||||
$this->handleBruteForce($ipAddress, $failureCount, $windowSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle detected brute force attack
|
||||
*/
|
||||
private function handleBruteForce(
|
||||
string $ipAddress,
|
||||
int $failureCount,
|
||||
int $windowSeconds
|
||||
): void {
|
||||
// Publish brute force event
|
||||
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
|
||||
$event->setTenantId($this->tenant->identifier());
|
||||
$this->eventBus->publish($event);
|
||||
|
||||
// Auto-block the IP
|
||||
$blockDuration = $this->getConfig(
|
||||
self::CONFIG_AUTO_BLOCK_DURATION,
|
||||
self::DEFAULT_AUTO_BLOCK_DURATION
|
||||
);
|
||||
|
||||
$this->blockIp(
|
||||
$ipAddress,
|
||||
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
|
||||
null, // System-created
|
||||
$blockDuration
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security event to firewall logs
|
||||
*/
|
||||
public function logSecurityEvent(SecurityEvent $event): void
|
||||
{
|
||||
$tenantId = $event->getTenantId() ?? $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$log = new FirewallLogObject();
|
||||
$log->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->getName()))
|
||||
->setIdentityId($event->getUserId())
|
||||
->setTimestamp(new \DateTimeImmutable())
|
||||
->setMetadata($event->getData());
|
||||
|
||||
$this->store->createLog($log);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
default => FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map security event to result
|
||||
*/
|
||||
private function mapEventToResult(string $eventName): string
|
||||
{
|
||||
return match ($eventName) {
|
||||
SecurityEvent::AUTH_SUCCESS,
|
||||
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
|
||||
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->getReason()
|
||||
);
|
||||
$event->setTenantId($this->tenant->identifier());
|
||||
$this->eventBus->publish($event);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Rule Management
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Block an IP address
|
||||
*/
|
||||
public function blockIp(
|
||||
string $ipAddress,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null,
|
||||
?int $durationSeconds = null
|
||||
): FirewallRuleObject {
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
// Check if already blocked
|
||||
$existing = $this->store->findExactIpRule(
|
||||
$tenantId,
|
||||
$ipAddress,
|
||||
FirewallRuleObject::ACTION_BLOCK
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_IP)
|
||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||
->setValue($ipAddress)
|
||||
->setReason($reason ?? 'Blocked by administrator')
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
if ($durationSeconds !== null) {
|
||||
$rule->setExpiresAt(
|
||||
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
|
||||
);
|
||||
}
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
// Publish event
|
||||
$event = new SecurityEvent(SecurityEvent::IP_BLOCKED, ['ip' => $ipAddress, 'reason' => $reason]);
|
||||
$event->setIpAddress($ipAddress)
|
||||
->setReason($reason)
|
||||
->setTenantId($tenantId);
|
||||
$this->eventBus->publish($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow an IP address (whitelist)
|
||||
*/
|
||||
public function allowIp(
|
||||
string $ipAddress,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null
|
||||
): FirewallRuleObject {
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_IP)
|
||||
->setAction(FirewallRuleObject::ACTION_ALLOW)
|
||||
->setValue($ipAddress)
|
||||
->setReason($reason ?? 'Allowed by administrator')
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
// Publish event
|
||||
$event = new SecurityEvent(SecurityEvent::IP_ALLOWED, ['ip' => $ipAddress, 'reason' => $reason]);
|
||||
$event->setIpAddress($ipAddress)
|
||||
->setReason($reason)
|
||||
->setTenantId($tenantId);
|
||||
$this->eventBus->publish($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block an IP range (CIDR notation)
|
||||
*/
|
||||
public function blockIpRange(
|
||||
string $cidr,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null
|
||||
): FirewallRuleObject {
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_IP_RANGE)
|
||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||
->setValue($cidr)
|
||||
->setReason($reason ?? 'Range blocked by administrator')
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a device fingerprint
|
||||
*/
|
||||
public function blockDevice(
|
||||
string $fingerprint,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null,
|
||||
?int $durationSeconds = null
|
||||
): FirewallRuleObject {
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_DEVICE)
|
||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||
->setValue($fingerprint)
|
||||
->setReason($reason ?? 'Device blocked by administrator')
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
if ($durationSeconds !== null) {
|
||||
$rule->setExpiresAt(
|
||||
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
|
||||
);
|
||||
}
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
// Publish event
|
||||
$event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]);
|
||||
$event->setDeviceFingerprint($fingerprint)
|
||||
->setReason($reason)
|
||||
->setTenantId($tenantId);
|
||||
$this->eventBus->publish($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a rule by ID
|
||||
*/
|
||||
public function removeRule(string $ruleId): bool
|
||||
{
|
||||
$rule = $this->store->fetchRule($ruleId);
|
||||
if (!$rule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify tenant ownership
|
||||
if ($rule->getTenantId() !== $this->tenant->identifier()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->store->destroyRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a rule (soft delete)
|
||||
*/
|
||||
public function disableRule(string $ruleId): bool
|
||||
{
|
||||
$rule = $this->store->fetchRule($ruleId);
|
||||
if (!$rule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify tenant ownership
|
||||
if ($rule->getTenantId() !== $this->tenant->identifier()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rule->setEnabled(false);
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all rules for current tenant
|
||||
*/
|
||||
public function listRules(bool $activeOnly = true): array
|
||||
{
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->store->listRules($tenantId, $activeOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get firewall logs for current tenant
|
||||
*/
|
||||
public function getLogs(
|
||||
?string $ipAddress = null,
|
||||
?string $eventType = null,
|
||||
?string $result = null,
|
||||
int $limit = 100
|
||||
): array {
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->store->listLogs($tenantId, $ipAddress, $eventType, $result, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blocked requests count
|
||||
*/
|
||||
public function getBlockedCount(?\DateTimeImmutable $since = null): int
|
||||
{
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->store->countBlockedRequests($tenantId, $since);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helpers
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 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->tenant->configuration();
|
||||
$parts = explode('.', $key);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (!is_array($config) || !array_key_exists($part, $config)) {
|
||||
return $default;
|
||||
}
|
||||
$config = $config[$part];
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active rules (cached)
|
||||
* @return FirewallRuleObject[]
|
||||
*/
|
||||
private function getActiveRules(): array
|
||||
{
|
||||
if ($this->rulesCache === null) {
|
||||
$tenantId = $this->tenant->identifier();
|
||||
$this->rulesCache = $tenantId
|
||||
? $this->store->listRules($tenantId, true)
|
||||
: [];
|
||||
}
|
||||
return $this->rulesCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear rules cache
|
||||
*/
|
||||
private function clearRulesCache(): void
|
||||
{
|
||||
$this->rulesCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user