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
@@ -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);
}
}