diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index d4d5f26..6234c08 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -13,6 +13,7 @@ use KTXC\Service\TenantFirewallRuleService; use KTXC\Service\TenantFirewallStatusService; use KTXC\Security\Event\AccessDeniedEvent; use KTXC\Security\Event\AuthenticationFailedEvent; +use KTXC\Security\Event\AuthenticationSucceededEvent; use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Security\Event\SuspiciousActivityEvent; @@ -47,7 +48,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M ); foreach ([ - SecurityEvent::AUTH_SUCCESS, + AuthenticationSucceededEvent::class, AccessDeniedEvent::class, BruteForceDetectedEvent::class, RateLimitExceededEvent::class, diff --git a/core/lib/Security/Event/AuthenticationSucceededEvent.php b/core/lib/Security/Event/AuthenticationSucceededEvent.php new file mode 100644 index 0000000..f191d44 --- /dev/null +++ b/core/lib/Security/Event/AuthenticationSucceededEvent.php @@ -0,0 +1,70 @@ + $userId], + $tenantId, + ); + } + + public function getIpAddress(): string + { + return $this->ipAddress; + } + + public function getDeviceFingerprint(): ?string + { + return $this->deviceFingerprint; + } + + public function getUserAgent(): ?string + { + return null; + } + + public function getRequestPath(): ?string + { + return null; + } + + public function getRequestMethod(): ?string + { + return null; + } + + public function getUserId(): string + { + return $this->userId; + } + + public function getReason(): ?string + { + return null; + } + + public function getSeverity(): int + { + return SecurityEvent::SEVERITY_INFO; + } +} diff --git a/core/lib/Security/Event/SecurityEvent.php b/core/lib/Security/Event/SecurityEvent.php index 5bf751f..cf8442f 100644 --- a/core/lib/Security/Event/SecurityEvent.php +++ b/core/lib/Security/Event/SecurityEvent.php @@ -12,7 +12,6 @@ use KTXF\Event\Event; final class SecurityEvent extends Event implements SecurityRequestEventInterface { // Event names - public const AUTH_SUCCESS = 'security.auth.success'; public const AUTH_LOGOUT = 'security.auth.logout'; public const TOKEN_REFRESH = 'security.token.refresh'; public const TOKEN_REVOKED = 'security.token.revoked'; @@ -90,32 +89,12 @@ final class SecurityEvent extends Event implements SecurityRequestEventInterface ); } - /** - * Create an authentication success event - */ - public static function authSuccess( - string $ipAddress, - ?string $deviceFingerprint = null, - ?string $userId = null, - ?string $tenantId = null, - ): self { - return self::create( - self::AUTH_SUCCESS, - $ipAddress, - $deviceFingerprint, - ['userId' => $userId], - tenantId: $tenantId, - userId: $userId, - ); - } - /** * Get default severity for event types */ private static function getSeverityForEvent(string $eventName): int { return match ($eventName) { - self::AUTH_SUCCESS, self::ACCESS_GRANTED, self::TOKEN_REFRESH => self::SEVERITY_INFO, diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php index 88d8b47..24c2ffa 100644 --- a/core/lib/Service/FirewallService.php +++ b/core/lib/Service/FirewallService.php @@ -12,6 +12,7 @@ use KTXC\Stores\FirewallStore; use KTXC\Context\TenantContextInterface; use KTXC\Security\Event\AccessDeniedEvent; use KTXC\Security\Event\AuthenticationFailedEvent; +use KTXC\Security\Event\AuthenticationSucceededEvent; use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Security\Event\SuspiciousActivityEvent; @@ -283,7 +284,7 @@ class FirewallService { return match ($eventName) { AuthenticationFailedEvent::class => FirewallLogObject::EVENT_AUTH_FAILURE, - SecurityEvent::AUTH_SUCCESS => FirewallLogObject::EVENT_ACCESS_CHECK, + AuthenticationSucceededEvent::class => FirewallLogObject::EVENT_ACCESS_CHECK, BruteForceDetectedEvent::class => FirewallLogObject::EVENT_BRUTE_FORCE, RateLimitExceededEvent::class => FirewallLogObject::EVENT_RATE_LIMIT, AccessDeniedEvent::class => FirewallLogObject::EVENT_RULE_MATCH, @@ -304,7 +305,7 @@ class FirewallService private function mapEventToResult(SecurityEventInterface $event): string { return match ($event->getName()) { - SecurityEvent::AUTH_SUCCESS, + AuthenticationSucceededEvent::class, SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED, SecurityEvent::FIREWALL_RULE_CREATED, SecurityEvent::FIREWALL_RULE_EXTENDED, diff --git a/tests/php/Unit/Event/AuthenticationSucceededEventTest.php b/tests/php/Unit/Event/AuthenticationSucceededEventTest.php new file mode 100644 index 0000000..0f57f93 --- /dev/null +++ b/tests/php/Unit/Event/AuthenticationSucceededEventTest.php @@ -0,0 +1,51 @@ +getName()); + self::assertSame('203.0.113.10', $event->getIpAddress()); + self::assertSame('user-a', $event->getUserId()); + self::assertSame('tenant-a', $event->getTenantId()); + self::assertSame('device-a', $event->getDeviceFingerprint()); + self::assertSame(['userId' => 'user-a'], $event->getData()); + self::assertSame(SecurityEvent::SEVERITY_INFO, $event->getSeverity()); + } + + #[Test] + #[TestDox('Successful authentication requires an IP address and user ID')] + public function rejectsIncompleteAuthenticationContext(): void + { + foreach ([ + ['', 'user-a'], + ['203.0.113.10', ''], + ] as $arguments) { + try { + new AuthenticationSucceededEvent(...$arguments); + self::fail('Incomplete successful-authentication context was accepted.'); + } catch (\InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } + } +} diff --git a/tests/php/Unit/Module/CoreModuleTest.php b/tests/php/Unit/Module/CoreModuleTest.php index e8fc88e..57c442c 100644 --- a/tests/php/Unit/Module/CoreModuleTest.php +++ b/tests/php/Unit/Module/CoreModuleTest.php @@ -18,6 +18,7 @@ use KTXC\Service\TenantFirewallRuleService; use KTXC\Event\EventListenerRegistry; use KTXC\Security\Event\AccessDeniedEvent; use KTXC\Security\Event\AuthenticationFailedEvent; +use KTXC\Security\Event\AuthenticationSucceededEvent; use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Security\Event\SuspiciousActivityEvent; @@ -47,6 +48,7 @@ final class CoreModuleTest extends TestCase ); self::assertSame([], $registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Deferred)); foreach ([ + AuthenticationSucceededEvent::class, AccessDeniedEvent::class, BruteForceDetectedEvent::class, RateLimitExceededEvent::class, diff --git a/tests/php/Unit/Service/FirewallServiceTest.php b/tests/php/Unit/Service/FirewallServiceTest.php index 8e2872b..19877bb 100644 --- a/tests/php/Unit/Service/FirewallServiceTest.php +++ b/tests/php/Unit/Service/FirewallServiceTest.php @@ -12,6 +12,7 @@ use KTXC\Models\Firewall\FirewallLogObject; use KTXC\Models\Tenant\TenantConfiguration; use KTXC\Security\Event\AccessDeniedEvent; use KTXC\Security\Event\AuthenticationFailedEvent; +use KTXC\Security\Event\AuthenticationSucceededEvent; use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Security\Event\SuspiciousActivityEvent; @@ -307,6 +308,26 @@ class FirewallServiceTest extends TestCase $this->service->logSecurityEvent($event); } + #[TestDox('Successful authentication maps to an allowed access log')] + public function testAuthenticationSuccessAudit(): void + { + $this->store->expects($this->once()) + ->method('createLog') + ->with(self::callback(static function (FirewallLogObject $log): bool { + return $log->getEventType() === FirewallLogObject::EVENT_ACCESS_CHECK + && $log->getResult() === FirewallLogObject::RESULT_ALLOWED + && $log->getIpAddress() === '203.0.113.10' + && $log->getIdentityId() === 'user-a'; + })) + ->willReturnArgument(0); + + $this->service->logSecurityEvent(new AuthenticationSucceededEvent( + '203.0.113.10', + 'user-a', + 'tenant-a', + )); + } + #[TestDox('Suspicious-activity events retain request and detection metadata')] public function testSuspiciousActivityAudit(): void {