fix(security): emit authentication success events

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-08-06 00:08:58 -04:00
parent 5b3e8f6588
commit b14bd302a3
8 changed files with 183 additions and 52 deletions
+8 -1
View File
@@ -52,8 +52,15 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
priority: 100, priority: 100,
); );
foreach ([ $this->events->listen(
'core',
AuthenticationSucceededEvent::class, AuthenticationSucceededEvent::class,
FirewallService::class,
'logAuthenticationSuccess',
DeliveryMode::Deferred,
);
foreach ([
AccessDeniedEvent::class, AccessDeniedEvent::class,
BruteForceDetectedEvent::class, BruteForceDetectedEvent::class,
RateLimitExceededEvent::class, RateLimitExceededEvent::class,
+16 -1
View File
@@ -9,6 +9,7 @@ use KTXC\Resource\ProviderManager;
use KTXC\Security\Authentication\AuthenticationRequest; use KTXC\Security\Authentication\AuthenticationRequest;
use KTXC\Security\Authentication\AuthenticationResponse; use KTXC\Security\Authentication\AuthenticationResponse;
use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Service\TokenService; use KTXC\Service\TokenService;
use KTXC\Service\UserAccountsService; use KTXC\Service\UserAccountsService;
use KTXC\Context\TenantContextInterface; use KTXC\Context\TenantContextInterface;
@@ -616,7 +617,16 @@ class AuthenticationManager
*/ */
private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse
{ {
$userData = $this->userService->fetchByIdentifier($session->userIdentifier); $userId = $session->userIdentifier;
if ($userId === null) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_SESSION,
'Authenticated user is missing',
401,
);
}
$userData = $this->userService->fetchByIdentifier($userId);
if ($userData === null) { if ($userData === null) {
return AuthenticationResponse::failed( return AuthenticationResponse::failed(
@@ -633,6 +643,11 @@ class AuthenticationManager
$this->deleteSession($session->id); $this->deleteSession($session->id);
$this->events->dispatch(new AuthenticationSucceededEvent(
$userId,
$session->tenantIdentifier,
));
return AuthenticationResponse::success( return AuthenticationResponse::success(
$this->buildUserData($user), $this->buildUserData($user),
$tokens $tokens
@@ -6,17 +6,12 @@ namespace KTXC\Security\Event;
use KTXF\Event\Event; use KTXF\Event\Event;
final class AuthenticationSucceededEvent extends Event implements SecurityRequestEventInterface final class AuthenticationSucceededEvent extends Event implements SecurityEventInterface
{ {
public function __construct( public function __construct(
private readonly string $ipAddress,
private readonly string $userId, private readonly string $userId,
?string $tenantId = null, ?string $tenantId = null,
private readonly ?string $deviceFingerprint = null,
) { ) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Successful authentication requires an IP address.');
}
if ($userId === '') { if ($userId === '') {
throw new \InvalidArgumentException('Successful authentication requires a user ID.'); throw new \InvalidArgumentException('Successful authentication requires a user ID.');
} }
@@ -28,31 +23,6 @@ final class AuthenticationSucceededEvent extends Event implements SecurityReques
); );
} }
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 public function getUserId(): string
{ {
return $this->userId; return $this->userId;
+8
View File
@@ -249,6 +249,14 @@ class FirewallService
} }
} }
public function logAuthenticationSuccess(AuthenticationSucceededEvent $event): void
{
$log = $this->securityLog($event, $this->requestContext->current());
if ($log !== null) {
$this->store->createLog($log);
}
}
private function securityLog( private function securityLog(
SecurityEventInterface $event, SecurityEventInterface $event,
?Request $request = null, ?Request $request = null,
@@ -6,6 +6,7 @@ namespace KTXT\Unit\Event;
use KTXC\Security\Event\AuthenticationSucceededEvent; use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\SecurityEventSeverity; use KTXC\Security\Event\SecurityEventSeverity;
use KTXC\Security\Event\SecurityRequestEventInterface;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -17,35 +18,24 @@ final class AuthenticationSucceededEventTest extends TestCase
public function constructsTypedState(): void public function constructsTypedState(): void
{ {
$event = new AuthenticationSucceededEvent( $event = new AuthenticationSucceededEvent(
'203.0.113.10',
'user-a', 'user-a',
'tenant-a', 'tenant-a',
'device-a',
); );
self::assertSame(AuthenticationSucceededEvent::class, $event->getName()); self::assertSame(AuthenticationSucceededEvent::class, $event->getName());
self::assertSame('203.0.113.10', $event->getIpAddress());
self::assertSame('user-a', $event->getUserId()); self::assertSame('user-a', $event->getUserId());
self::assertSame('tenant-a', $event->getTenantId()); self::assertSame('tenant-a', $event->getTenantId());
self::assertSame('device-a', $event->getDeviceFingerprint());
self::assertSame(['userId' => 'user-a'], $event->getData()); self::assertSame(['userId' => 'user-a'], $event->getData());
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity()); self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event);
} }
#[Test] #[Test]
#[TestDox('Successful authentication requires an IP address and user ID')] #[TestDox('Successful authentication requires a user ID')]
public function rejectsIncompleteAuthenticationContext(): void public function rejectsIncompleteAuthenticationContext(): void
{ {
foreach ([ $this->expectException(\InvalidArgumentException::class);
['', 'user-a'],
['203.0.113.10', ''], new AuthenticationSucceededEvent('');
] as $arguments) {
try {
new AuthenticationSucceededEvent(...$arguments);
self::fail('Incomplete successful-authentication context was accepted.');
} catch (\InvalidArgumentException) {
$this->addToAssertionCount(1);
}
}
} }
} }
+7 -1
View File
@@ -52,8 +52,14 @@ final class CoreModuleTest extends TestCase
$registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Immediate)[0]->service, $registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Immediate)[0]->service,
); );
self::assertSame([], $registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Deferred)); self::assertSame([], $registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Deferred));
foreach ([ $successListeners = $registry->listeners(
AuthenticationSucceededEvent::class, AuthenticationSucceededEvent::class,
DeliveryMode::Deferred,
);
self::assertCount(1, $successListeners);
self::assertSame(FirewallService::class, $successListeners[0]->service);
self::assertSame('logAuthenticationSuccess', $successListeners[0]->method);
foreach ([
AccessDeniedEvent::class, AccessDeniedEvent::class,
BruteForceDetectedEvent::class, BruteForceDetectedEvent::class,
RateLimitExceededEvent::class, RateLimitExceededEvent::class,
@@ -10,6 +10,7 @@ use KTXC\Resource\ProviderManager;
use KTXC\Security\Authentication\AuthenticationRequest; use KTXC\Security\Authentication\AuthenticationRequest;
use KTXC\Security\AuthenticationManager; use KTXC\Security\AuthenticationManager;
use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Service\TokenService; use KTXC\Service\TokenService;
use KTXC\Service\UserAccountsService; use KTXC\Service\UserAccountsService;
use KTXF\Cache\CacheScope; use KTXF\Cache\CacheScope;
@@ -24,6 +25,80 @@ use PHPUnit\Framework\TestCase;
final class AuthenticationManagerTest extends TestCase final class AuthenticationManagerTest extends TestCase
{ {
#[Test]
public function completedAuthenticationPublishesAuthenticationSucceededEvent(): void
{
$tenant = $this->createStub(TenantContextInterface::class);
$tenant->method('identifier')->willReturn('tenant-a');
$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);
$cache->expects($this->once())
->method('delete')
->with('auth-session', CacheScope::Tenant, 'auth')
->willReturn(true);
$provider = new SuccessfulAuthenticationProvider();
$providers = $this->createStub(ProviderManager::class);
$providers->method('resolve')->willReturn($provider);
$users = $this->createMock(UserAccountsService::class);
$users->expects($this->once())
->method('fetchByIdentifier')
->with('user-a')
->willReturn([
'uid' => 'user-a',
'identity' => 'person@example.com',
'label' => 'Person',
'permissions' => [],
]);
$tokens = $this->createStub(TokenService::class);
$tokens->method('createToken')->willReturn('token');
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects($this->once())
->method('dispatch')
->with(self::callback(static fn($event): bool =>
$event instanceof AuthenticationSucceededEvent
&& $event->getUserId() === 'user-a'
&& $event->getTenantId() === 'tenant-a'
));
$manager = new AuthenticationManager(
$tenant,
$cache,
$providers,
$tokens,
$users,
$events,
);
$response = $manager->handle(AuthenticationRequest::verify(
'auth-session',
'password',
'correct',
));
self::assertTrue($response->isSuccess());
}
#[Test] #[Test]
public function rejectedCredentialPublishesAuthenticationFailedEvent(): void public function rejectedCredentialPublishesAuthenticationFailedEvent(): void
{ {
@@ -145,3 +220,64 @@ final class AuthenticationManagerTest extends TestCase
self::assertSame(1, $provider->verificationCount); self::assertSame(1, $provider->verificationCount);
} }
} }
final class SuccessfulAuthenticationProvider implements AuthenticationProviderInterface
{
public function type(): string
{
return 'authentication';
}
public function identifier(): string
{
return 'password';
}
public function label(): string
{
return 'Password';
}
public function description(): string
{
return 'Successful test provider';
}
public function method(): string
{
return self::METHOD_CREDENTIAL;
}
public function icon(): string
{
return '';
}
public function verify(ProviderContext $context, string $secret): ProviderResult
{
return ProviderResult::success();
}
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();
}
}
@@ -324,8 +324,7 @@ class FirewallServiceTest extends TestCase
})) }))
->willReturnArgument(0); ->willReturnArgument(0);
$this->service->logSecurityEvent(new AuthenticationSucceededEvent( $this->service->logAuthenticationSuccess(new AuthenticationSucceededEvent(
'203.0.113.10',
'user-a', 'user-a',
'tenant-a', 'tenant-a',
)); ));