Skip to content

Commit ae4e42f

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

5 files changed

Lines changed: 345 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: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
// user_agent is captured from the current HTTP request context; falls back to empty
51+
// string in CLI / queue contexts. A future signature change may accept $userAgent
52+
// explicitly if project conventions require it (see ticket CU-86ba2z5gz).
53+
$auditLog->setUserAgent(request()?->userAgent() ?? '');
54+
$auditLog->setMetadata($metadata);
55+
56+
$this->repository->add($auditLog, true);
57+
58+
if (config('opentelemetry.enabled', false)) {
59+
EmitAuditLogJob::dispatch('two_factor.audit', [
60+
'two_factor.event_type' => $eventType,
61+
'two_factor.method' => $method,
62+
'two_factor.user_id' => $user->getId(),
63+
'two_factor.ip_address' => $ipAddress,
64+
'two_factor.success' => $this->resolveSuccess($eventType),
65+
'two_factor.device_trusted' => $eventType === TwoFactorAuditLog::EventDeviceTrusted,
66+
'elasticsearch.index' => config('opentelemetry.logs.elasticsearch_index', 'logs-audit'),
67+
]);
68+
}
69+
}
70+
71+
/**
72+
* Derive whether the 2FA event represents a successful outcome.
73+
* Only challenge_failed is treated as a failure; all other event types
74+
* represent informational or successful operations.
75+
*/
76+
private function resolveSuccess(string $eventType): bool
77+
{
78+
return $eventType !== TwoFactorAuditLog::EventChallengeFailed;
79+
}
80+
}

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: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
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() does NOT dispatch job when OTLP is disabled (default)
145+
// -------------------------------------------------------------------------
146+
147+
public function testLogDoesNotDispatchJobWhenOtlpDisabled(): void
148+
{
149+
Config::set('opentelemetry.enabled', false);
150+
151+
$this->repository->shouldReceive('add')->once();
152+
153+
$this->service->log(
154+
$this->user,
155+
TwoFactorAuditLog::EventChallengeSucceeded,
156+
TwoFactorAuditLog::MethodEmailOtp,
157+
'127.0.0.1'
158+
);
159+
160+
Queue::assertNotPushed(EmitAuditLogJob::class);
161+
}
162+
163+
// -------------------------------------------------------------------------
164+
// log() accepts null metadata
165+
// -------------------------------------------------------------------------
166+
167+
public function testLogAcceptsNullMetadata(): void
168+
{
169+
/** @var TwoFactorAuditLog|null $persisted */
170+
$persisted = null;
171+
172+
$this->repository
173+
->shouldReceive('add')
174+
->once()
175+
->withArgs(function (TwoFactorAuditLog $log) use (&$persisted) {
176+
$persisted = $log;
177+
return true;
178+
});
179+
180+
$this->service->log(
181+
$this->user,
182+
TwoFactorAuditLog::EventChallengeIssued,
183+
TwoFactorAuditLog::MethodTotp,
184+
'192.168.1.1',
185+
null
186+
);
187+
188+
$this->assertNotNull($persisted);
189+
$this->assertNull($persisted->getMetadata());
190+
}
191+
192+
// -------------------------------------------------------------------------
193+
// invalid event type throws InvalidArgumentException
194+
// -------------------------------------------------------------------------
195+
196+
public function testInvalidEventTypeThrowsInvalidArgumentException(): void
197+
{
198+
$this->expectException(\InvalidArgumentException::class);
199+
200+
$this->repository->shouldNotReceive('add');
201+
202+
$this->service->log(
203+
$this->user,
204+
'not_a_valid_event',
205+
TwoFactorAuditLog::MethodEmailOtp,
206+
'127.0.0.1'
207+
);
208+
}
209+
210+
// -------------------------------------------------------------------------
211+
// invalid method throws InvalidArgumentException
212+
// -------------------------------------------------------------------------
213+
214+
public function testInvalidMethodThrowsInvalidArgumentException(): void
215+
{
216+
$this->expectException(\InvalidArgumentException::class);
217+
218+
$this->repository->shouldNotReceive('add');
219+
220+
$this->service->log(
221+
$this->user,
222+
TwoFactorAuditLog::EventChallengeIssued,
223+
'not_a_valid_method',
224+
'127.0.0.1'
225+
);
226+
}
227+
}

0 commit comments

Comments
 (0)