feat(security): emit authentication failures for firewall handling

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-08-05 23:26:01 -04:00
parent a363a1a4bc
commit a8e29d0305
13 changed files with 377 additions and 95 deletions
@@ -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);
}
}
+45 -1
View File
@@ -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
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Security;
use KTXC\Context\TenantContextInterface;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Resource\ProviderManager;
use KTXC\Security\Authentication\AuthenticationRequest;
use KTXC\Security\AuthenticationManager;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Service\TokenService;
use KTXC\Service\UserAccountsService;
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;
use KTXF\Security\Authentication\ProviderResult;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class AuthenticationManagerTest extends TestCase
{
#[Test]
public function rejectedCredentialPublishesAuthenticationFailedEvent(): void
{
$tenant = $this->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);
}
}
+63 -18
View File
@@ -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);