Skip to content

Commit 5225c98

Browse files
feat: MFAGateService (Two-Factor Gate Decision Service)
1 parent ea04053 commit 5225c98

5 files changed

Lines changed: 189 additions & 0 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace App\Services\Auth;
4+
5+
/**
6+
* Copyright 2025 OpenStack Foundation
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
**/
17+
18+
use Auth\User;
19+
20+
/**
21+
* Interface ITwoFactorGateService
22+
* @package App\Services\Auth
23+
*/
24+
interface ITwoFactorGateService
25+
{
26+
/**
27+
* Determines whether the given user must complete a 2FA challenge for the current login attempt.
28+
* Pure decision — no session writes, no OTP generation, no persistence side effects.
29+
*
30+
* Infrastructure exceptions from the underlying device-trust check are not caught and propagate to the caller.
31+
*/
32+
public function requiresChallenge(User $user, ?string $cookieToken): bool;
33+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace App\Services\Auth;
4+
5+
/**
6+
* Copyright 2025 OpenStack Foundation
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
**/
17+
18+
use Auth\User;
19+
20+
/**
21+
* Class MFAGateService
22+
* @package App\Services\Auth
23+
*/
24+
final class MFAGateService implements ITwoFactorGateService
25+
{
26+
public function __construct(
27+
private readonly IDeviceTrustService $deviceTrustService
28+
) {
29+
}
30+
31+
public function requiresChallenge(User $user, ?string $cookieToken): bool
32+
{
33+
if (!$user->shouldRequire2FA()) {
34+
return false;
35+
}
36+
return !$this->deviceTrustService->isDeviceTrusted($user, $cookieToken);
37+
}
38+
}

app/Services/Auth/TwoFactorServiceProvider.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<?php
2+
23
namespace App\Services\Auth;
4+
35
/**
46
* Copyright 2025 OpenStack Foundation
57
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -31,13 +33,15 @@ public function register(): void
3133
{
3234
$this->app->singleton(IDeviceTrustService::class, DeviceTrustService::class);
3335
$this->app->singleton(ITwoFactorAuditService::class, TwoFactorAuditService::class);
36+
$this->app->singleton(ITwoFactorGateService::class, MFAGateService::class);
3437
}
3538

3639
public function provides(): array
3740
{
3841
return [
3942
IDeviceTrustService::class,
4043
ITwoFactorAuditService::class,
44+
ITwoFactorGateService::class,
4145
];
4246
}
4347
}

phpunit.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
<file>./tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php</file>
3030
<file>./tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php</file>
3131
<file>./tests/Unit/TwoFactorAuditServiceTest.php</file>
32+
<file>./tests/Unit/MFAGateServiceTest.php</file>
3233
</testsuite>
3334
</testsuites>
3435
<php>

tests/Unit/MFAGateServiceTest.php

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
namespace Tests\Unit;
4+
5+
/**
6+
* Copyright 2025 OpenStack Foundation
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
**/
17+
18+
use App\Services\Auth\IDeviceTrustService;
19+
use App\Services\Auth\MFAGateService;
20+
use Auth\User;
21+
use Mockery;
22+
use Tests\TestCase;
23+
use Mockery\MockInterface;
24+
25+
/**
26+
* Class MFAGateServiceTest
27+
* @package Tests\Unit
28+
*/
29+
final class MFAGateServiceTest extends TestCase
30+
{
31+
private MFAGateService $service;
32+
33+
private MockInterface&IDeviceTrustService $deviceTrustService;
34+
35+
protected function setUp(): void
36+
{
37+
parent::setUp();
38+
$this->deviceTrustService = Mockery::mock(IDeviceTrustService::class);
39+
$this->service = new MFAGateService($this->deviceTrustService);
40+
}
41+
42+
protected function tearDown(): void
43+
{
44+
Mockery::close();
45+
parent::tearDown();
46+
}
47+
48+
// -------------------------------------------------------------------------
49+
// Non-admin/non-enforced user with 2FA disabled returns false
50+
// -------------------------------------------------------------------------
51+
52+
public function testRequiresChallengeReturnsFalseWhenUserDoesNotRequire2FA(): void
53+
{
54+
$user = Mockery::mock(User::class);
55+
$user->shouldReceive('shouldRequire2FA')->once()->andReturn(false);
56+
$this->deviceTrustService->shouldNotReceive('isDeviceTrusted');
57+
58+
$this->assertFalse($this->service->requiresChallenge($user, null));
59+
}
60+
61+
// -------------------------------------------------------------------------
62+
// Admin/enforced user with no cookie returns true
63+
// -------------------------------------------------------------------------
64+
65+
public function testRequiresChallengeReturnsTrueWhenEnforcedAndNoCookie(): void
66+
{
67+
$user = Mockery::mock(User::class);
68+
$user->shouldReceive('shouldRequire2FA')->once()->andReturn(true);
69+
$this->deviceTrustService->shouldReceive('isDeviceTrusted')->once()->with($user, null)->andReturn(false);
70+
71+
$this->assertTrue($this->service->requiresChallenge($user, null));
72+
}
73+
74+
// -------------------------------------------------------------------------
75+
// Admin/enforced user with trusted device returns false
76+
// -------------------------------------------------------------------------
77+
78+
public function testRequiresChallengeReturnsFalseWhenEnforcedAndDeviceTrusted(): void
79+
{
80+
$user = Mockery::mock(User::class);
81+
$user->shouldReceive('shouldRequire2FA')->once()->andReturn(true);
82+
$this->deviceTrustService->shouldReceive('isDeviceTrusted')->once()->with($user, 'valid-token')->andReturn(true);
83+
84+
$this->assertFalse($this->service->requiresChallenge($user, 'valid-token'));
85+
}
86+
87+
// -------------------------------------------------------------------------
88+
// Admin/enforced user with expired/revoked/wrong device returns true
89+
// -------------------------------------------------------------------------
90+
91+
public function testRequiresChallengeReturnsTrueWhenEnforcedAndDeviceNotTrusted(): void
92+
{
93+
$user = Mockery::mock(User::class);
94+
$user->shouldReceive('shouldRequire2FA')->once()->andReturn(true);
95+
$this->deviceTrustService->shouldReceive('isDeviceTrusted')->once()->with($user, 'expired-token')->andReturn(false);
96+
97+
$this->assertTrue($this->service->requiresChallenge($user, 'expired-token'));
98+
}
99+
100+
// -------------------------------------------------------------------------
101+
// Empty-string cookie is forwarded as-is (not coerced to null) and treated
102+
// as untrusted — documents the ?string contract between gate and trust layers
103+
// -------------------------------------------------------------------------
104+
105+
public function testRequiresChallengePassesThroughEmptyStringCookieToDeviceTrustService(): void
106+
{
107+
$user = Mockery::mock(User::class);
108+
$user->shouldReceive('shouldRequire2FA')->once()->andReturn(true);
109+
$this->deviceTrustService->shouldReceive('isDeviceTrusted')->once()->with($user, '')->andReturn(false);
110+
111+
$this->assertTrue($this->service->requiresChallenge($user, ''));
112+
}
113+
}

0 commit comments

Comments
 (0)