Skip to content

Commit fad9611

Browse files
feat: Two-Factor Audit Service
1 parent 4d99419 commit fad9611

5 files changed

Lines changed: 322 additions & 0 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php namespace App\Services\Auth;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use Auth\User;
16+
17+
/**
18+
* Interface ITwoFactorAuditService
19+
* @package App\Services\Auth
20+
*/
21+
interface ITwoFactorAuditService
22+
{
23+
/**
24+
* Persist a TwoFactorAuditLog record and emit corresponding OTLP attributes.
25+
*
26+
* @param User $user The user the event relates to.
27+
* @param string $eventType One of the TwoFactorAuditLog::Event* constants.
28+
* @param string $method One of the TwoFactorAuditLog::Method* constants.
29+
* @param string $ipAddress Client IP address (IPv4 or IPv6).
30+
* @param array|null $metadata Optional structured context; stored as JSON.
31+
*
32+
* @throws \InvalidArgumentException if $eventType or $method is not in the allowed set.
33+
*/
34+
public function log(User $user, string $eventType, string $method, string $ipAddress, ?array $metadata = null): void;
35+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
namespace App\Services\Auth;
3+
/**
4+
* Copyright 2026 OpenStack Foundation
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
**/
15+
16+
use App\Jobs\EmitAuditLogJob;
17+
use App\libs\Auth\Models\TwoFactorAuditLog;
18+
use Auth\Repositories\ITwoFactorAuditLogRepository;
19+
use Auth\User;
20+
use Illuminate\Support\Facades\Log;
21+
22+
/**
23+
* Class TwoFactorAuditService
24+
* @package App\Services\Auth
25+
*/
26+
final class TwoFactorAuditService implements ITwoFactorAuditService
27+
{
28+
public function __construct(
29+
private readonly ITwoFactorAuditLogRepository $repository
30+
) {
31+
}
32+
33+
/**
34+
* @inheritDoc
35+
*/
36+
public function log(User $user, string $eventType, string $method, string $ipAddress, ?array $metadata = null): void
37+
{
38+
Log::debug('TwoFactorAuditService::log', [
39+
'user_id' => $user->getId(),
40+
'event_type' => $eventType,
41+
'method' => $method,
42+
'ip_address' => $ipAddress,
43+
]);
44+
45+
$auditLog = new TwoFactorAuditLog();
46+
$auditLog->setUser($user);
47+
$auditLog->setEventType($eventType); // throws InvalidArgumentException on unknown type
48+
$auditLog->setMethod($method); // throws InvalidArgumentException on unknown method
49+
$auditLog->setIpAddress($ipAddress);
50+
$auditLog->setUserAgent(request()?->userAgent() ?? '');
51+
$auditLog->setMetadata($metadata);
52+
53+
$this->repository->add($auditLog, true);
54+
55+
if (config('opentelemetry.enabled', false)) {
56+
EmitAuditLogJob::dispatch('two_factor.audit', [
57+
'two_factor.event_type' => $eventType,
58+
'two_factor.method' => $method,
59+
'two_factor.user_id' => $user->getId(),
60+
'two_factor.ip_address' => $ipAddress,
61+
'two_factor.success' => $this->resolveSuccess($eventType),
62+
'two_factor.device_trusted' => $eventType === TwoFactorAuditLog::EventDeviceTrusted,
63+
'elasticsearch.index' => config('opentelemetry.logs.elasticsearch_index', 'logs-audit'),
64+
]);
65+
}
66+
}
67+
68+
/**
69+
* Derive whether the 2FA event represents a successful outcome.
70+
* Only challenge_failed is treated as a failure; all other event types
71+
* represent informational or successful operations.
72+
*/
73+
private function resolveSuccess(string $eventType): bool
74+
{
75+
return $eventType !== TwoFactorAuditLog::EventChallengeFailed;
76+
}
77+
}

app/Services/Auth/TwoFactorServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,14 @@ public function boot(): void
3030
public function register(): void
3131
{
3232
$this->app->singleton(IDeviceTrustService::class, DeviceTrustService::class);
33+
$this->app->singleton(ITwoFactorAuditService::class, TwoFactorAuditService::class);
3334
}
3435

3536
public function provides(): array
3637
{
3738
return [
3839
IDeviceTrustService::class,
40+
ITwoFactorAuditService::class,
3941
];
4042
}
4143
}

phpunit.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
<file>./tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php</file>
2929
<file>./tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php</file>
3030
<file>./tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php</file>
31+
<file>./tests/Unit/TwoFactorAuditServiceTest.php</file>
3132
</testsuite>
3233
</testsuites>
3334
<php>
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
<?php
2+
namespace Tests\Unit;
3+
/**
4+
* Copyright 2026 OpenStack Foundation
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
**/
15+
16+
use App\Jobs\EmitAuditLogJob;
17+
use App\libs\Auth\Models\TwoFactorAuditLog;
18+
use App\Services\Auth\TwoFactorAuditService;
19+
use Auth\Repositories\ITwoFactorAuditLogRepository;
20+
use Auth\User;
21+
use Illuminate\Support\Facades\Config;
22+
use Illuminate\Support\Facades\Queue;
23+
use Mockery;
24+
use Tests\TestCase;
25+
26+
/**
27+
* Class TwoFactorAuditServiceTest
28+
* @package Tests\Unit
29+
*/
30+
final class TwoFactorAuditServiceTest extends TestCase
31+
{
32+
/** @var TwoFactorAuditService */
33+
private TwoFactorAuditService $service;
34+
35+
/** @var \Mockery\MockInterface&ITwoFactorAuditLogRepository */
36+
private $repository;
37+
38+
/** @var \Mockery\MockInterface&User */
39+
private $user;
40+
41+
protected function setUp(): void
42+
{
43+
parent::setUp();
44+
Queue::fake();
45+
46+
$this->repository = Mockery::mock(ITwoFactorAuditLogRepository::class);
47+
$this->service = new TwoFactorAuditService($this->repository);
48+
49+
$this->user = Mockery::mock(User::class);
50+
$this->user->shouldReceive('getId')->andReturn(42);
51+
}
52+
53+
protected function tearDown(): void
54+
{
55+
Mockery::close();
56+
parent::tearDown();
57+
}
58+
59+
// -------------------------------------------------------------------------
60+
// log() persists TwoFactorAuditLog with correct fields
61+
// -------------------------------------------------------------------------
62+
63+
public function testLogPersistsTwoFactorAuditLogWithCorrectFields(): void
64+
{
65+
/** @var TwoFactorAuditLog|null $persisted */
66+
$persisted = null;
67+
68+
$this->repository
69+
->shouldReceive('add')
70+
->once()
71+
->withArgs(function (TwoFactorAuditLog $log, bool $sync) use (&$persisted) {
72+
$persisted = $log;
73+
return $sync === true;
74+
});
75+
76+
$this->service->log(
77+
$this->user,
78+
TwoFactorAuditLog::EventChallengeSucceeded,
79+
TwoFactorAuditLog::MethodEmailOtp,
80+
'127.0.0.1',
81+
['attempt' => 1]
82+
);
83+
84+
$this->assertNotNull($persisted);
85+
$this->assertSame($this->user, $persisted->getUser());
86+
$this->assertSame(TwoFactorAuditLog::EventChallengeSucceeded, $persisted->getEventType());
87+
$this->assertSame(TwoFactorAuditLog::MethodEmailOtp, $persisted->getMethod());
88+
$this->assertSame('127.0.0.1', $persisted->getIpAddress());
89+
$this->assertSame(['attempt' => 1], $persisted->getMetadata());
90+
}
91+
92+
// -------------------------------------------------------------------------
93+
// log() emits OTLP attributes
94+
// -------------------------------------------------------------------------
95+
96+
public function testLogEmitsOtlpAttributes(): void
97+
{
98+
Config::set('opentelemetry.enabled', true);
99+
100+
$this->repository->shouldReceive('add')->once();
101+
102+
$this->service->log(
103+
$this->user,
104+
TwoFactorAuditLog::EventChallengeSucceeded,
105+
TwoFactorAuditLog::MethodEmailOtp,
106+
'10.0.0.1'
107+
);
108+
109+
Queue::assertPushed(EmitAuditLogJob::class, function (EmitAuditLogJob $job) {
110+
return $job->logMessage === 'two_factor.audit'
111+
&& $job->auditData['two_factor.event_type'] === TwoFactorAuditLog::EventChallengeSucceeded
112+
&& $job->auditData['two_factor.method'] === TwoFactorAuditLog::MethodEmailOtp
113+
&& $job->auditData['two_factor.user_id'] === 42
114+
&& $job->auditData['two_factor.ip_address'] === '10.0.0.1'
115+
&& $job->auditData['two_factor.success'] === true
116+
&& $job->auditData['two_factor.device_trusted'] === false;
117+
});
118+
}
119+
120+
// -------------------------------------------------------------------------
121+
// log() emits two_factor.success = false for challenge_failed
122+
// -------------------------------------------------------------------------
123+
124+
public function testLogEmitsSuccessFalseForChallengeFailed(): void
125+
{
126+
Config::set('opentelemetry.enabled', true);
127+
128+
$this->repository->shouldReceive('add')->once();
129+
130+
$this->service->log(
131+
$this->user,
132+
TwoFactorAuditLog::EventChallengeFailed,
133+
TwoFactorAuditLog::MethodEmailOtp,
134+
'10.0.0.1'
135+
);
136+
137+
Queue::assertPushed(EmitAuditLogJob::class, function (EmitAuditLogJob $job) {
138+
return $job->auditData['two_factor.event_type'] === TwoFactorAuditLog::EventChallengeFailed
139+
&& $job->auditData['two_factor.success'] === false;
140+
});
141+
}
142+
143+
// -------------------------------------------------------------------------
144+
// log() accepts null metadata
145+
// -------------------------------------------------------------------------
146+
147+
public function testLogAcceptsNullMetadata(): void
148+
{
149+
/** @var TwoFactorAuditLog|null $persisted */
150+
$persisted = null;
151+
152+
$this->repository
153+
->shouldReceive('add')
154+
->once()
155+
->withArgs(function (TwoFactorAuditLog $log) use (&$persisted) {
156+
$persisted = $log;
157+
return true;
158+
});
159+
160+
$this->service->log(
161+
$this->user,
162+
TwoFactorAuditLog::EventChallengeIssued,
163+
TwoFactorAuditLog::MethodTotp,
164+
'192.168.1.1',
165+
null
166+
);
167+
168+
$this->assertNotNull($persisted);
169+
$this->assertNull($persisted->getMetadata());
170+
}
171+
172+
// -------------------------------------------------------------------------
173+
// invalid event type throws InvalidArgumentException
174+
// -------------------------------------------------------------------------
175+
176+
public function testInvalidEventTypeThrowsInvalidArgumentException(): void
177+
{
178+
$this->expectException(\InvalidArgumentException::class);
179+
180+
$this->repository->shouldNotReceive('add');
181+
182+
$this->service->log(
183+
$this->user,
184+
'not_a_valid_event',
185+
TwoFactorAuditLog::MethodEmailOtp,
186+
'127.0.0.1'
187+
);
188+
}
189+
190+
// -------------------------------------------------------------------------
191+
// invalid method throws InvalidArgumentException
192+
// -------------------------------------------------------------------------
193+
194+
public function testInvalidMethodThrowsInvalidArgumentException(): void
195+
{
196+
$this->expectException(\InvalidArgumentException::class);
197+
198+
$this->repository->shouldNotReceive('add');
199+
200+
$this->service->log(
201+
$this->user,
202+
TwoFactorAuditLog::EventChallengeIssued,
203+
'not_a_valid_method',
204+
'127.0.0.1'
205+
);
206+
}
207+
}

0 commit comments

Comments
 (0)