631 lines
18 KiB
PHP
631 lines
18 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\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;
|
|
}
|
|
}
|