From ae4e42f9794752679422e39a598553023fd56018 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Tue, 26 May 2026 18:52:33 +0000 Subject: [PATCH] feat: Two-Factor Audit Service --- app/Services/Auth/ITwoFactorAuditService.php | 35 +++ app/Services/Auth/TwoFactorAuditService.php | 80 ++++++ .../Auth/TwoFactorServiceProvider.php | 2 + phpunit.xml | 1 + tests/Unit/TwoFactorAuditServiceTest.php | 227 ++++++++++++++++++ 5 files changed, 345 insertions(+) create mode 100644 app/Services/Auth/ITwoFactorAuditService.php create mode 100644 app/Services/Auth/TwoFactorAuditService.php create mode 100644 tests/Unit/TwoFactorAuditServiceTest.php diff --git a/app/Services/Auth/ITwoFactorAuditService.php b/app/Services/Auth/ITwoFactorAuditService.php new file mode 100644 index 00000000..e72049d8 --- /dev/null +++ b/app/Services/Auth/ITwoFactorAuditService.php @@ -0,0 +1,35 @@ + $user->getId(), + 'event_type' => $eventType, + 'method' => $method, + 'ip_address' => $ipAddress, + ]); + + $auditLog = new TwoFactorAuditLog(); + $auditLog->setUser($user); + $auditLog->setEventType($eventType); // throws InvalidArgumentException on unknown type + $auditLog->setMethod($method); // throws InvalidArgumentException on unknown method + $auditLog->setIpAddress($ipAddress); + // user_agent is captured from the current HTTP request context; falls back to empty + // string in CLI / queue contexts. A future signature change may accept $userAgent + // explicitly if project conventions require it (see ticket CU-86ba2z5gz). + $auditLog->setUserAgent(request()?->userAgent() ?? ''); + $auditLog->setMetadata($metadata); + + $this->repository->add($auditLog, true); + + if (config('opentelemetry.enabled', false)) { + EmitAuditLogJob::dispatch('two_factor.audit', [ + 'two_factor.event_type' => $eventType, + 'two_factor.method' => $method, + 'two_factor.user_id' => $user->getId(), + 'two_factor.ip_address' => $ipAddress, + 'two_factor.success' => $this->resolveSuccess($eventType), + 'two_factor.device_trusted' => $eventType === TwoFactorAuditLog::EventDeviceTrusted, + 'elasticsearch.index' => config('opentelemetry.logs.elasticsearch_index', 'logs-audit'), + ]); + } + } + + /** + * Derive whether the 2FA event represents a successful outcome. + * Only challenge_failed is treated as a failure; all other event types + * represent informational or successful operations. + */ + private function resolveSuccess(string $eventType): bool + { + return $eventType !== TwoFactorAuditLog::EventChallengeFailed; + } +} diff --git a/app/Services/Auth/TwoFactorServiceProvider.php b/app/Services/Auth/TwoFactorServiceProvider.php index f2f2568b..705eda7a 100644 --- a/app/Services/Auth/TwoFactorServiceProvider.php +++ b/app/Services/Auth/TwoFactorServiceProvider.php @@ -30,12 +30,14 @@ public function boot(): void public function register(): void { $this->app->singleton(IDeviceTrustService::class, DeviceTrustService::class); + $this->app->singleton(ITwoFactorAuditService::class, TwoFactorAuditService::class); } public function provides(): array { return [ IDeviceTrustService::class, + ITwoFactorAuditService::class, ]; } } diff --git a/phpunit.xml b/phpunit.xml index a653e0ce..005f6b46 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -28,6 +28,7 @@ ./tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php ./tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php ./tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php + ./tests/Unit/TwoFactorAuditServiceTest.php diff --git a/tests/Unit/TwoFactorAuditServiceTest.php b/tests/Unit/TwoFactorAuditServiceTest.php new file mode 100644 index 00000000..e16c5679 --- /dev/null +++ b/tests/Unit/TwoFactorAuditServiceTest.php @@ -0,0 +1,227 @@ +repository = Mockery::mock(ITwoFactorAuditLogRepository::class); + $this->service = new TwoFactorAuditService($this->repository); + + $this->user = Mockery::mock(User::class); + $this->user->shouldReceive('getId')->andReturn(42); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // log() persists TwoFactorAuditLog with correct fields + // ------------------------------------------------------------------------- + + public function testLogPersistsTwoFactorAuditLogWithCorrectFields(): void + { + /** @var TwoFactorAuditLog|null $persisted */ + $persisted = null; + + $this->repository + ->shouldReceive('add') + ->once() + ->withArgs(function (TwoFactorAuditLog $log, bool $sync) use (&$persisted) { + $persisted = $log; + return $sync === true; + }); + + $this->service->log( + $this->user, + TwoFactorAuditLog::EventChallengeSucceeded, + TwoFactorAuditLog::MethodEmailOtp, + '127.0.0.1', + ['attempt' => 1] + ); + + $this->assertNotNull($persisted); + $this->assertSame($this->user, $persisted->getUser()); + $this->assertSame(TwoFactorAuditLog::EventChallengeSucceeded, $persisted->getEventType()); + $this->assertSame(TwoFactorAuditLog::MethodEmailOtp, $persisted->getMethod()); + $this->assertSame('127.0.0.1', $persisted->getIpAddress()); + $this->assertSame(['attempt' => 1], $persisted->getMetadata()); + } + + // ------------------------------------------------------------------------- + // log() emits OTLP attributes + // ------------------------------------------------------------------------- + + public function testLogEmitsOtlpAttributes(): void + { + Config::set('opentelemetry.enabled', true); + + $this->repository->shouldReceive('add')->once(); + + $this->service->log( + $this->user, + TwoFactorAuditLog::EventChallengeSucceeded, + TwoFactorAuditLog::MethodEmailOtp, + '10.0.0.1' + ); + + Queue::assertPushed(EmitAuditLogJob::class, function (EmitAuditLogJob $job) { + return $job->logMessage === 'two_factor.audit' + && $job->auditData['two_factor.event_type'] === TwoFactorAuditLog::EventChallengeSucceeded + && $job->auditData['two_factor.method'] === TwoFactorAuditLog::MethodEmailOtp + && $job->auditData['two_factor.user_id'] === 42 + && $job->auditData['two_factor.ip_address'] === '10.0.0.1' + && $job->auditData['two_factor.success'] === true + && $job->auditData['two_factor.device_trusted'] === false; + }); + } + + // ------------------------------------------------------------------------- + // log() emits two_factor.success = false for challenge_failed + // ------------------------------------------------------------------------- + + public function testLogEmitsSuccessFalseForChallengeFailed(): void + { + Config::set('opentelemetry.enabled', true); + + $this->repository->shouldReceive('add')->once(); + + $this->service->log( + $this->user, + TwoFactorAuditLog::EventChallengeFailed, + TwoFactorAuditLog::MethodEmailOtp, + '10.0.0.1' + ); + + Queue::assertPushed(EmitAuditLogJob::class, function (EmitAuditLogJob $job) { + return $job->auditData['two_factor.event_type'] === TwoFactorAuditLog::EventChallengeFailed + && $job->auditData['two_factor.success'] === false; + }); + } + + // ------------------------------------------------------------------------- + // log() does NOT dispatch job when OTLP is disabled (default) + // ------------------------------------------------------------------------- + + public function testLogDoesNotDispatchJobWhenOtlpDisabled(): void + { + Config::set('opentelemetry.enabled', false); + + $this->repository->shouldReceive('add')->once(); + + $this->service->log( + $this->user, + TwoFactorAuditLog::EventChallengeSucceeded, + TwoFactorAuditLog::MethodEmailOtp, + '127.0.0.1' + ); + + Queue::assertNotPushed(EmitAuditLogJob::class); + } + + // ------------------------------------------------------------------------- + // log() accepts null metadata + // ------------------------------------------------------------------------- + + public function testLogAcceptsNullMetadata(): void + { + /** @var TwoFactorAuditLog|null $persisted */ + $persisted = null; + + $this->repository + ->shouldReceive('add') + ->once() + ->withArgs(function (TwoFactorAuditLog $log) use (&$persisted) { + $persisted = $log; + return true; + }); + + $this->service->log( + $this->user, + TwoFactorAuditLog::EventChallengeIssued, + TwoFactorAuditLog::MethodTotp, + '192.168.1.1', + null + ); + + $this->assertNotNull($persisted); + $this->assertNull($persisted->getMetadata()); + } + + // ------------------------------------------------------------------------- + // invalid event type throws InvalidArgumentException + // ------------------------------------------------------------------------- + + public function testInvalidEventTypeThrowsInvalidArgumentException(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->repository->shouldNotReceive('add'); + + $this->service->log( + $this->user, + 'not_a_valid_event', + TwoFactorAuditLog::MethodEmailOtp, + '127.0.0.1' + ); + } + + // ------------------------------------------------------------------------- + // invalid method throws InvalidArgumentException + // ------------------------------------------------------------------------- + + public function testInvalidMethodThrowsInvalidArgumentException(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->repository->shouldNotReceive('add'); + + $this->service->log( + $this->user, + TwoFactorAuditLog::EventChallengeIssued, + 'not_a_valid_method', + '127.0.0.1' + ); + } +}