fix(firewall): account for authentication failures exactly once
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -26,6 +26,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
public const EVENT_RULE_REMOVED = 'rule_removed';
|
||||
|
||||
private ?string $id = null;
|
||||
private ?string $eventId = null;
|
||||
private ?string $tenantId = null;
|
||||
private ?string $ipAddress = null;
|
||||
private ?string $deviceFingerprint = null;
|
||||
@@ -55,6 +56,9 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
if (array_key_exists('tenantId', $data)) {
|
||||
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
|
||||
}
|
||||
if (array_key_exists('eventId', $data)) {
|
||||
$this->eventId = $data['eventId'] !== null ? (string)$data['eventId'] : null;
|
||||
}
|
||||
if (array_key_exists('ipAddress', $data)) {
|
||||
$this->ipAddress = $data['ipAddress'] !== null ? (string)$data['ipAddress'] : null;
|
||||
}
|
||||
@@ -101,6 +105,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'eventId' => $this->eventId,
|
||||
'tenantId' => $this->tenantId,
|
||||
'ipAddress' => $this->ipAddress,
|
||||
'deviceFingerprint' => $this->deviceFingerprint,
|
||||
@@ -130,6 +135,17 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEventId(): ?string
|
||||
{
|
||||
return $this->eventId;
|
||||
}
|
||||
|
||||
public function setEventId(?string $eventId): self
|
||||
{
|
||||
$this->eventId = $eventId;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTenantId(): ?string
|
||||
{
|
||||
return $this->tenantId;
|
||||
|
||||
@@ -35,7 +35,6 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
||||
);
|
||||
|
||||
foreach ([
|
||||
SecurityEvent::AUTH_FAILURE,
|
||||
SecurityEvent::AUTH_SUCCESS,
|
||||
SecurityEvent::ACCESS_DENIED,
|
||||
SecurityEvent::BRUTE_FORCE_DETECTED,
|
||||
|
||||
@@ -139,6 +139,12 @@ class FirewallService
|
||||
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,
|
||||
@@ -157,9 +163,6 @@ class FirewallService
|
||||
$windowSeconds
|
||||
);
|
||||
|
||||
// Include current failure in count
|
||||
$failureCount++;
|
||||
|
||||
if ($failureCount >= $maxFailures) {
|
||||
$this->handleBruteForce($tenantId, $ipAddress, $failureCount, $windowSeconds);
|
||||
}
|
||||
@@ -200,15 +203,24 @@ class FirewallService
|
||||
* 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;
|
||||
return null;
|
||||
}
|
||||
|
||||
$log = new FirewallLogObject();
|
||||
$log->setTenantId($tenantId)
|
||||
return $log->setEventId($event->getEventId())
|
||||
->setTenantId($tenantId)
|
||||
->setIpAddress($event->getIpAddress())
|
||||
->setDeviceFingerprint($event->getDeviceFingerprint())
|
||||
->setUserAgent($event->getUserAgent())
|
||||
@@ -221,8 +233,6 @@ class FirewallService
|
||||
->setIdentityId($event->getUserId() ?? $event->getIdentityId())
|
||||
->setTimestamp(new \DateTimeImmutable())
|
||||
->setMetadata($event->getData());
|
||||
|
||||
$this->store->createLog($log);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -260,6 +260,30 @@ class FirewallStore
|
||||
return $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an event-backed log once, using the event ID as MongoDB's unique key.
|
||||
*/
|
||||
public function createLogOnce(FirewallLogObject $log): bool
|
||||
{
|
||||
$eventId = $log->getEventId();
|
||||
if ($eventId === null || $eventId === '') {
|
||||
throw new \InvalidArgumentException('Idempotent firewall logs require an event ID.');
|
||||
}
|
||||
|
||||
$data = $log->jsonSerialize();
|
||||
unset($data['id']);
|
||||
$data['_id'] = $eventId;
|
||||
|
||||
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->updateOne(
|
||||
['_id' => $eventId],
|
||||
['$setOnInsert' => $data],
|
||||
['upsert' => true]
|
||||
);
|
||||
$log->setId($eventId);
|
||||
|
||||
return $result->getUpsertedCount() === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logs for a tenant with optional filters
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user