diff --git a/core/lib/Http/Request/RequestContext.php b/core/lib/Http/Request/RequestContext.php new file mode 100644 index 0000000..c8af511 --- /dev/null +++ b/core/lib/Http/Request/RequestContext.php @@ -0,0 +1,32 @@ +request !== null) { + throw new \LogicException('The request context has already been initialized.'); + } + + $this->request = $request; + } + + public function current(): ?Request + { + return $this->request; + } + + public function clear(): void + { + $this->request = null; + } +} diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index f15986f..19c8dd5 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -38,6 +38,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M AuthenticationFailedEvent::class, FirewallService::class, 'handleAuthFailure', + DeliveryMode::Immediate, priority: 100, ); diff --git a/core/lib/Runtime/Http/HttpRuntime.php b/core/lib/Runtime/Http/HttpRuntime.php index dba75a0..ea1e8ad 100644 --- a/core/lib/Runtime/Http/HttpRuntime.php +++ b/core/lib/Runtime/Http/HttpRuntime.php @@ -11,6 +11,7 @@ use KTXC\Http\Middleware\MiddlewarePipeline; use KTXC\Http\Middleware\RouterMiddleware; use KTXC\Http\Middleware\TenantMiddleware; use KTXC\Http\Request\Request; +use KTXC\Http\Request\RequestContext; use KTXC\Http\Response\Response; use KTXC\KernelInterface; @@ -25,26 +26,34 @@ final class HttpRuntime public function run(?Request $request = null, bool $send = true): Response { $request ??= Request::createFromGlobals(); + $requestContext = null; - return $this->kernel->executionRunner()->execute( - ExecutionDescriptor::http(), - function () use ($request, $send): Response { - $response = $this->pipeline()->handle($request); - if ($send) { - $response->send(); - } + try { + return $this->kernel->executionRunner()->execute( + ExecutionDescriptor::http(), + function () use ($request, $send, &$requestContext): Response { + $requestContext = $this->kernel->container()->get(RequestContext::class); + $requestContext->initialize($request); - return $response; - }, - function (\Throwable $error) use ($send): Response { - $response = $this->errorResponse($error); - if ($send) { - $response->send(); - } + $response = $this->pipeline()->handle($request); + if ($send) { + $response->send(); + } - return $response; - }, - ); + return $response; + }, + function (\Throwable $error) use ($send): Response { + $response = $this->errorResponse($error); + if ($send) { + $response->send(); + } + + return $response; + }, + ); + } finally { + $requestContext?->clear(); + } } private function pipeline(): MiddlewarePipeline diff --git a/core/lib/Security/AuthenticationManager.php b/core/lib/Security/AuthenticationManager.php index 11641e1..b7ec9b4 100644 --- a/core/lib/Security/AuthenticationManager.php +++ b/core/lib/Security/AuthenticationManager.php @@ -8,11 +8,13 @@ use KTXC\Models\Identity\User; use KTXC\Resource\ProviderManager; use KTXC\Security\Authentication\AuthenticationRequest; use KTXC\Security\Authentication\AuthenticationResponse; +use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Service\TokenService; use KTXC\Service\UserAccountsService; use KTXC\Context\TenantContextInterface; use KTXF\Cache\CacheScope; use KTXF\Cache\EphemeralCacheInterface; +use KTXF\Event\EventDispatcherInterface; use KTXF\Security\Authentication\AuthenticationProviderInterface; use KTXF\Security\Authentication\AuthenticationSession; use KTXF\Security\Authentication\ProviderContext; @@ -31,6 +33,7 @@ class AuthenticationManager private readonly ProviderManager $providerManager, private readonly TokenService $tokenService, private readonly UserAccountsService $userService, + private readonly EventDispatcherInterface $events, ) { $this->securityCode = $this->tenantContext->configuration()->security()->code(); } @@ -187,6 +190,10 @@ class AuthenticationManager if (!$result->isSuccess()) { $this->saveSession($session); + $this->publishAuthenticationFailure( + $session, + $result->errorCode ?? AuthenticationResponse::ERROR_INVALID_CREDENTIALS, + ); return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_CREDENTIALS, 'Authentication failed. If you haven\'t set up this method, try another option.', @@ -389,6 +396,10 @@ class AuthenticationManager $result = $provider->completeRedirect($context, $request->params); if ($result->isFailed()) { + $this->publishAuthenticationFailure( + $session, + $result->errorCode ?? AuthenticationResponse::ERROR_INVALID_CREDENTIALS, + ); $this->deleteSession($session->id); return AuthenticationResponse::failed( AuthenticationResponse::ERROR_INVALID_CREDENTIALS, @@ -566,6 +577,17 @@ class AuthenticationManager // Helper Methods // ========================================================================= + private function publishAuthenticationFailure( + AuthenticationSession $session, + string $reason, + ): void { + $this->events->dispatch(new AuthenticationFailedEvent( + userId: $session->userIdentifier, + reason: $reason, + tenantId: $session->tenantIdentifier, + )); + } + /** * Build provider context from session */ diff --git a/core/lib/Security/Event/AuthenticationFailedEvent.php b/core/lib/Security/Event/AuthenticationFailedEvent.php index b4fcdd6..a177f70 100644 --- a/core/lib/Security/Event/AuthenticationFailedEvent.php +++ b/core/lib/Security/Event/AuthenticationFailedEvent.php @@ -9,15 +9,10 @@ use KTXF\Event\Event; final class AuthenticationFailedEvent extends Event implements SecurityEventInterface { public function __construct( - private readonly string $ipAddress, - private readonly ?string $deviceFingerprint = null, private readonly ?string $userId = null, private readonly ?string $reason = null, ?string $tenantId = null, ?string $identityId = null, - private readonly ?string $userAgent = null, - private readonly ?string $requestPath = null, - private readonly ?string $requestMethod = null, ) { parent::__construct( self::class, @@ -27,31 +22,6 @@ final class AuthenticationFailedEvent extends Event implements SecurityEventInte ); } - public function getIpAddress(): string - { - return $this->ipAddress; - } - - public function getDeviceFingerprint(): ?string - { - return $this->deviceFingerprint; - } - - public function getUserAgent(): ?string - { - return $this->userAgent; - } - - public function getRequestPath(): ?string - { - return $this->requestPath; - } - - public function getRequestMethod(): ?string - { - return $this->requestMethod; - } - public function getUserId(): ?string { return $this->userId; diff --git a/core/lib/Security/Event/SecurityEvent.php b/core/lib/Security/Event/SecurityEvent.php index 94a8fb0..83da6b5 100644 --- a/core/lib/Security/Event/SecurityEvent.php +++ b/core/lib/Security/Event/SecurityEvent.php @@ -9,7 +9,7 @@ use KTXF\Event\Event; /** * Security-specific event for authentication and access control events */ -final class SecurityEvent extends Event implements SecurityEventInterface +final class SecurityEvent extends Event implements SecurityRequestEventInterface { // Event names public const AUTH_SUCCESS = 'security.auth.success'; diff --git a/core/lib/Security/Event/SecurityEventInterface.php b/core/lib/Security/Event/SecurityEventInterface.php index 958852e..62fbde7 100644 --- a/core/lib/Security/Event/SecurityEventInterface.php +++ b/core/lib/Security/Event/SecurityEventInterface.php @@ -18,16 +18,6 @@ interface SecurityEventInterface public function getIdentityId(): ?string; - public function getIpAddress(): ?string; - - public function getDeviceFingerprint(): ?string; - - public function getUserAgent(): ?string; - - public function getRequestPath(): ?string; - - public function getRequestMethod(): ?string; - public function getUserId(): ?string; public function getReason(): ?string; diff --git a/core/lib/Security/Event/SecurityRequestEventInterface.php b/core/lib/Security/Event/SecurityRequestEventInterface.php new file mode 100644 index 0000000..93f163d --- /dev/null +++ b/core/lib/Security/Event/SecurityRequestEventInterface.php @@ -0,0 +1,18 @@ +getIpAddress(); + $request = $this->requestContext->current(); + $ipAddress = $request?->getClientIp(); $tenantId = $event->getTenantId() ?? $this->tenantContext->identifier(); if (!$ipAddress || !$tenantId) { return; } - $log = $this->securityLog($event); + $log = $this->securityLog($event, $request); if ($log === null || !$this->store->createLogOnce($log)) { return; } @@ -235,7 +239,10 @@ class FirewallService } } - private function securityLog(SecurityEventInterface $event): ?FirewallLogObject + private function securityLog( + SecurityEventInterface $event, + ?Request $request = null, + ): ?FirewallLogObject { $tenantId = $event->getTenantId() ?? $this->tenantContext->identifier(); $ruleScope = $event->get('ruleScope'); @@ -243,14 +250,19 @@ class FirewallService return null; } + $requestEvent = $event instanceof SecurityRequestEventInterface ? $event : 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()) + ->setIpAddress($request?->getClientIp() ?? $requestEvent?->getIpAddress()) + ->setDeviceFingerprint( + $request?->headers->get('X-Device-Fingerprint') + ?? $requestEvent?->getDeviceFingerprint() + ) + ->setUserAgent($request?->headers->get('User-Agent') ?? $requestEvent?->getUserAgent()) + ->setRequestPath($request?->getPathInfo() ?? $requestEvent?->getRequestPath()) + ->setRequestMethod($request?->getMethod() ?? $requestEvent?->getRequestMethod()) ->setEventType($this->mapEventToLogType($event->getName())) ->setResult($this->mapEventToResult($event)) ->setRuleId($event->get('ruleId')) diff --git a/tests/php/Unit/Event/AuthenticationFailedEventTest.php b/tests/php/Unit/Event/AuthenticationFailedEventTest.php index 8b698ef..8ed687f 100644 --- a/tests/php/Unit/Event/AuthenticationFailedEventTest.php +++ b/tests/php/Unit/Event/AuthenticationFailedEventTest.php @@ -6,6 +6,7 @@ namespace KTXT\Unit\Event; use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\SecurityEvent; +use KTXC\Security\Event\SecurityRequestEventInterface; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -17,27 +18,18 @@ final class AuthenticationFailedEventTest extends TestCase public function constructsTypedState(): void { $event = new AuthenticationFailedEvent( - '203.0.113.10', - 'device-a', 'user-a', 'Invalid credentials', 'tenant-a', 'identity-a', - 'Test Agent', - '/login', - 'POST', ); self::assertSame(AuthenticationFailedEvent::class, $event->getName()); - self::assertSame('203.0.113.10', $event->getIpAddress()); - self::assertSame('device-a', $event->getDeviceFingerprint()); self::assertSame('user-a', $event->getUserId()); self::assertSame('Invalid credentials', $event->getReason()); self::assertSame('tenant-a', $event->getTenantId()); self::assertSame('identity-a', $event->getIdentityId()); - self::assertSame('Test Agent', $event->getUserAgent()); - self::assertSame('/login', $event->getRequestPath()); - self::assertSame('POST', $event->getRequestMethod()); self::assertSame(SecurityEvent::SEVERITY_WARNING, $event->getSeverity()); + self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event); } } diff --git a/tests/php/Unit/Runtime/HttpRuntimeTest.php b/tests/php/Unit/Runtime/HttpRuntimeTest.php index 5cf4639..d0a281d 100644 --- a/tests/php/Unit/Runtime/HttpRuntimeTest.php +++ b/tests/php/Unit/Runtime/HttpRuntimeTest.php @@ -20,6 +20,7 @@ use KTXC\Http\Middleware\RequestHandlerInterface; use KTXC\Http\Middleware\RouterMiddleware; use KTXC\Http\Middleware\TenantMiddleware; use KTXC\Http\Request\Request; +use KTXC\Http\Request\RequestContext; use KTXC\Http\Response\Response; use KTXC\Http\Response\StreamedResponse; use KTXC\KernelInterface; @@ -78,6 +79,31 @@ final class HttpRuntimeTest extends TestCase self::assertTrue($kernel->outcome?->successful); } + #[Test] + #[TestDox('HTTP requests are available only for the active runtime execution')] + public function scopesRequestContext(): void + { + $requestContext = new RequestContext(); + $observer = new RequestContextMiddleware($requestContext); + $kernel = new RuntimeKernel( + new RuntimeContainer([ + RequestContext::class => $requestContext, + TenantMiddleware::class => new PassMiddleware(), + FirewallMiddleware::class => new PassMiddleware(), + AuthenticationMiddleware::class => new PassMiddleware(), + RouterMiddleware::class => $observer, + ]), + new TenantContext($this->createStub(TenantService::class)), + new IdentityContext(), + ); + $request = Request::create('/auth/verify', 'POST'); + + (new HttpRuntime($kernel, false))->run($request, send: false); + + self::assertSame($request, $observer->observed); + self::assertNull($requestContext->current()); + } + #[Test] #[TestDox('HTTP exceptions render a response and still terminate')] public function fails(): void @@ -198,9 +224,12 @@ final class RuntimeKernel implements KernelInterface final class RuntimeContainer implements ContainerInterface { + private readonly array $services; + public function __construct( - private readonly array $services, + array $services, ) { + $this->services = [RequestContext::class => new RequestContext(), ...$services]; } public function get(string $id): mixed @@ -250,6 +279,21 @@ final class ResponseMiddleware extends PassMiddleware } } +final class RequestContextMiddleware extends PassMiddleware +{ + public ?Request $observed = null; + + public function __construct( + private readonly RequestContext $requestContext, + ) {} + + public function process(Request $request, RequestHandlerInterface $handler): Response + { + $this->observed = $this->requestContext->current(); + return new Response('ok'); + } +} + final class ThrowingMiddleware extends PassMiddleware { public function process(Request $request, RequestHandlerInterface $handler): Response diff --git a/tests/php/Unit/Security/AuthenticationManagerTest.php b/tests/php/Unit/Security/AuthenticationManagerTest.php new file mode 100644 index 0000000..105acf6 --- /dev/null +++ b/tests/php/Unit/Security/AuthenticationManagerTest.php @@ -0,0 +1,147 @@ +createStub(TenantContextInterface::class); + $tenant->method('configuration')->willReturn(new TenantConfiguration()); + + $session = new AuthenticationSession( + id: 'auth-session', + state: AuthenticationSession::STATE_IDENTIFIED, + tenantIdentifier: 'tenant-a', + userIdentifier: 'user-a', + userIdentity: 'person@example.com', + methodsAvailable: ['password'], + createdAt: time(), + expiresAt: time() + 300, + ); + + $cache = $this->createMock(EphemeralCacheInterface::class); + $cache->expects($this->once()) + ->method('get') + ->with('auth-session', CacheScope::Tenant, 'auth') + ->willReturn($session); + $cache->expects($this->once()) + ->method('set') + ->willReturn(true); + + $provider = new class implements AuthenticationProviderInterface { + public int $verificationCount = 0; + + public function type(): string + { + return 'authentication'; + } + + public function identifier(): string + { + return 'password'; + } + + public function label(): string + { + return 'Password'; + } + + public function description(): string + { + return 'Test provider'; + } + + public function method(): string + { + return self::METHOD_CREDENTIAL; + } + + public function icon(): string + { + return ''; + } + + public function verify(ProviderContext $context, string $secret): ProviderResult + { + $this->verificationCount++; + return ProviderResult::failed(ProviderResult::ERROR_INVALID_FACTOR); + } + + public function beginChallenge(ProviderContext $context): ProviderResult + { + return ProviderResult::failed(); + } + + public function verifyChallenge(ProviderContext $context, string $code): ProviderResult + { + return ProviderResult::failed(); + } + + public function beginRedirect( + ProviderContext $context, + string $callbackUrl, + ?string $returnUrl = null, + ): ProviderResult { + return ProviderResult::failed(); + } + + public function completeRedirect(ProviderContext $context, array $params): ProviderResult + { + return ProviderResult::failed(); + } + }; + + $providers = $this->createMock(ProviderManager::class); + $providers->method('resolve')->with('authentication', 'password')->willReturn($provider); + + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects($this->once()) + ->method('dispatch') + ->with(self::callback(static function ($event): bool { + return $event instanceof AuthenticationFailedEvent + && $event->getUserId() === 'user-a' + && $event->getReason() === ProviderResult::ERROR_INVALID_FACTOR + && $event->getTenantId() === 'tenant-a'; + })); + + $manager = new AuthenticationManager( + $tenant, + $cache, + $providers, + $this->createStub(TokenService::class), + $this->createStub(UserAccountsService::class), + $events, + ); + + $response = $manager->handle(AuthenticationRequest::verify( + 'auth-session', + 'password', + 'incorrect', + )); + + self::assertTrue($response->isFailed()); + self::assertSame(1, $provider->verificationCount); + } +} diff --git a/tests/php/Unit/Service/FirewallServiceTest.php b/tests/php/Unit/Service/FirewallServiceTest.php index 48032ac..f876ad2 100644 --- a/tests/php/Unit/Service/FirewallServiceTest.php +++ b/tests/php/Unit/Service/FirewallServiceTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace KTXT\Unit\Service; use KTXC\Context\TenantContextInterface; +use KTXC\Http\Request\Request; +use KTXC\Http\Request\RequestContext; use KTXC\Models\Firewall\FirewallRuleObject; use KTXC\Models\Firewall\FirewallLogObject; use KTXC\Models\Tenant\TenantConfiguration; @@ -25,6 +27,7 @@ class FirewallServiceTest extends TestCase private FirewallStore&MockObject $store; private TenantContextInterface&MockObject $tenantContext; private EventDispatcherInterface&MockObject $events; + private RequestContext $requestContext; private FirewallService $service; private ?string $currentTenant; private ?TenantConfiguration $currentConfiguration; @@ -34,6 +37,16 @@ class FirewallServiceTest extends TestCase $this->store = $this->createMock(FirewallStore::class); $this->tenantContext = $this->createMock(TenantContextInterface::class); $this->events = $this->createMock(EventDispatcherInterface::class); + $this->requestContext = new RequestContext(); + $this->requestContext->initialize(Request::create( + '/login', + 'POST', + server: [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_USER_AGENT' => 'Test Agent', + 'HTTP_X_DEVICE_FINGERPRINT' => 'device-a', + ], + )); $this->currentTenant = 'tenant-a'; $this->currentConfiguration = null; $this->tenantContext->method('identifier')->willReturnCallback( @@ -49,7 +62,8 @@ class FirewallServiceTest extends TestCase $this->tenantContext, $this->events, $manager, - $cache + $cache, + $this->requestContext, ); } @@ -253,7 +267,7 @@ class FirewallServiceTest extends TestCase $this->store->expects($this->never())->method('createLog'); $this->service->logSecurityEvent( - new AuthenticationFailedEvent('203.0.113.10') + new AuthenticationFailedEvent() ); } @@ -378,16 +392,53 @@ class FirewallServiceTest extends TestCase ->willReturn(4); $this->events->expects($this->never())->method('dispatch'); - $event = new AuthenticationFailedEvent( - '203.0.113.10', - tenantId: 'tenant-a', - ); + $event = new AuthenticationFailedEvent(tenantId: 'tenant-a'); $this->service->handleAuthFailure($event); self::assertSame(8, $this->currentConfiguration->firewall()->maxAuthFailures()); self::assertSame(7200, $this->currentConfiguration->firewall()->autoBlockDuration()); } + #[TestDox('Authentication failures combine event facts with the current request')] + public function testAuthenticationFailureRequestContext(): void + { + $this->store->expects($this->once()) + ->method('createLogOnce') + ->with(self::callback(static function (FirewallLogObject $log): bool { + return $log->getIpAddress() === '203.0.113.10' + && $log->getDeviceFingerprint() === 'device-a' + && $log->getUserAgent() === 'Test Agent' + && $log->getRequestPath() === '/login' + && $log->getRequestMethod() === 'POST' + && $log->getIdentityId() === 'user-a' + && $log->getMetadata()['reason'] === 'invalid_credentials'; + })) + ->willReturn(true); + $this->store->expects($this->once()) + ->method('countRecentFailures') + ->with('tenant-a', '203.0.113.10', 300) + ->willReturn(0); + + $this->service->handleAuthFailure(new AuthenticationFailedEvent( + userId: 'user-a', + reason: 'invalid_credentials', + tenantId: 'tenant-a', + )); + } + + #[TestDox('Authentication failures outside HTTP request context are ignored')] + public function testAuthenticationFailureWithoutRequestContext(): void + { + $this->requestContext->clear(); + $this->store->expects($this->never())->method('createLogOnce'); + $this->store->expects($this->never())->method('countRecentFailures'); + + $this->service->handleAuthFailure(new AuthenticationFailedEvent( + reason: 'invalid_credentials', + tenantId: 'tenant-a', + )); + } + #[TestDox('Unsafe numeric firewall settings fall back to safe defaults')] public function testConfigurationBounds(): void { @@ -404,10 +455,7 @@ class FirewallServiceTest extends TestCase ->with('tenant-a', '203.0.113.10', 300) ->willReturn(0); - $event = new AuthenticationFailedEvent( - '203.0.113.10', - tenantId: 'tenant-a', - ); + $event = new AuthenticationFailedEvent(tenantId: 'tenant-a'); $this->service->handleAuthFailure($event); } @@ -460,10 +508,7 @@ class FirewallServiceTest extends TestCase } }); - $event = new AuthenticationFailedEvent( - '203.0.113.10', - tenantId: 'tenant-event', - ); + $event = new AuthenticationFailedEvent(tenantId: 'tenant-event'); $this->service->handleAuthFailure($event); self::assertSame(['tenant-event', 'tenant-event', 'tenant-event'], $publishedTenants); @@ -486,7 +531,7 @@ class FirewallServiceTest extends TestCase $this->events->expects($this->never())->method('dispatch'); $this->service->handleAuthFailure( - new AuthenticationFailedEvent('203.0.113.10') + new AuthenticationFailedEvent() ); } @@ -500,7 +545,7 @@ class FirewallServiceTest extends TestCase ->willReturn(0); $this->service->handleAuthFailure( - new AuthenticationFailedEvent('203.0.113.10') + new AuthenticationFailedEvent() ); } @@ -512,7 +557,7 @@ class FirewallServiceTest extends TestCase $this->store->expects($this->never())->method('depositRule'); $this->service->handleAuthFailure( - new AuthenticationFailedEvent('203.0.113.10') + new AuthenticationFailedEvent() ); } @@ -526,7 +571,7 @@ class FirewallServiceTest extends TestCase ->method('countRecentFailures') ->with('tenant-a', '203.0.113.10', 300) ->willReturn(1); - $event = new AuthenticationFailedEvent('203.0.113.10'); + $event = new AuthenticationFailedEvent(); $eventId = $event->getEventId(); $this->service->handleAuthFailure($event);