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'
+ );
+ }
+}