Skip to content

Commit e65b45f

Browse files
feat: MFAGateService (Two-Factor Gate Decision Service)
1 parent ae4e42f commit e65b45f

5 files changed

Lines changed: 177 additions & 2 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php namespace App\Services\Auth;
2+
/**
3+
* Copyright 2025 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 ITwoFactorGateService
19+
* @package App\Services\Auth
20+
*/
21+
interface ITwoFactorGateService
22+
{
23+
/**
24+
* Determines whether the given user must complete a 2FA challenge for the current login attempt.
25+
* Pure decision — no session writes, no OTP generation, no persistence side effects.
26+
*/
27+
public function requiresChallenge(User $user, ?string $cookieToken): bool;
28+
}
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 2025 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+
* Class MFAGateService
19+
* @package App\Services\Auth
20+
*/
21+
final class MFAGateService implements ITwoFactorGateService
22+
{
23+
public function __construct(
24+
private readonly IDeviceTrustService $deviceTrustService
25+
) {
26+
}
27+
28+
public function requiresChallenge(User $user, ?string $cookieToken): bool
29+
{
30+
if (!$user->shouldRequire2FA()) {
31+
return false;
32+
}
33+
return !$this->deviceTrustService->isDeviceTrusted($user, $cookieToken);
34+
}
35+
}

app/Services/Auth/TwoFactorServiceProvider.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
<?php
2-
namespace App\Services\Auth;
1+
<?php namespace App\Services\Auth;
32
/**
43
* Copyright 2025 OpenStack Foundation
54
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -31,13 +30,15 @@ public function register(): void
3130
{
3231
$this->app->singleton(IDeviceTrustService::class, DeviceTrustService::class);
3332
$this->app->singleton(ITwoFactorAuditService::class, TwoFactorAuditService::class);
33+
$this->app->singleton(ITwoFactorGateService::class, MFAGateService::class);
3434
}
3535

3636
public function provides(): array
3737
{
3838
return [
3939
IDeviceTrustService::class,
4040
ITwoFactorAuditService::class,
41+
ITwoFactorGateService::class,
4142
];
4243
}
4344
}

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: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php namespace Tests\Unit;
2+
/**
3+
* Copyright 2025 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 App\Services\Auth\IDeviceTrustService;
16+
use App\Services\Auth\MFAGateService;
17+
use Auth\User;
18+
use Mockery;
19+
use Tests\TestCase;
20+
use Mockery\MockInterface;
21+
22+
/**
23+
* Class MFAGateServiceTest
24+
* @package Tests\Unit
25+
*/
26+
final class MFAGateServiceTest extends TestCase
27+
{
28+
private MFAGateService $service;
29+
30+
private MockInterface&IDeviceTrustService $deviceTrustService;
31+
32+
protected function setUp(): void
33+
{
34+
parent::setUp();
35+
$this->deviceTrustService = Mockery::mock(IDeviceTrustService::class);
36+
$this->service = new MFAGateService($this->deviceTrustService);
37+
}
38+
39+
protected function tearDown(): void
40+
{
41+
Mockery::close();
42+
parent::tearDown();
43+
}
44+
45+
// -------------------------------------------------------------------------
46+
// Non-admin/non-enforced user with 2FA disabled returns false
47+
// -------------------------------------------------------------------------
48+
49+
public function testRequiresChallengeReturnsFalseWhenUserDoesNotRequire2FA(): void
50+
{
51+
$user = Mockery::mock(User::class);
52+
$user->shouldReceive('shouldRequire2FA')->once()->andReturn(false);
53+
$this->deviceTrustService->shouldNotReceive('isDeviceTrusted');
54+
55+
$this->assertFalse($this->service->requiresChallenge($user, null));
56+
}
57+
58+
// -------------------------------------------------------------------------
59+
// Admin/enforced user with no cookie returns true
60+
// -------------------------------------------------------------------------
61+
62+
public function testRequiresChallengeReturnsTrueWhenEnforcedAndNoCookie(): void
63+
{
64+
$user = Mockery::mock(User::class);
65+
$user->shouldReceive('shouldRequire2FA')->once()->andReturn(true);
66+
$this->deviceTrustService->shouldReceive('isDeviceTrusted')->once()->with($user, null)->andReturn(false);
67+
68+
$this->assertTrue($this->service->requiresChallenge($user, null));
69+
}
70+
71+
// -------------------------------------------------------------------------
72+
// Admin/enforced user with trusted device returns false
73+
// -------------------------------------------------------------------------
74+
75+
public function testRequiresChallengeReturnsFalseWhenEnforcedAndDeviceTrusted(): void
76+
{
77+
$user = Mockery::mock(User::class);
78+
$user->shouldReceive('shouldRequire2FA')->once()->andReturn(true);
79+
$this->deviceTrustService->shouldReceive('isDeviceTrusted')->once()->with($user, 'valid-token')->andReturn(true);
80+
81+
$this->assertFalse($this->service->requiresChallenge($user, 'valid-token'));
82+
}
83+
84+
// -------------------------------------------------------------------------
85+
// Admin/enforced user with expired/revoked/wrong device returns true
86+
// -------------------------------------------------------------------------
87+
88+
public function testRequiresChallengeReturnsTrueWhenEnforcedAndDeviceNotTrusted(): void
89+
{
90+
$user = Mockery::mock(User::class);
91+
$user->shouldReceive('shouldRequire2FA')->once()->andReturn(true);
92+
$this->deviceTrustService->shouldReceive('isDeviceTrusted')->once()->with($user, 'expired-token')->andReturn(false);
93+
94+
$this->assertTrue($this->service->requiresChallenge($user, 'expired-token'));
95+
}
96+
97+
// -------------------------------------------------------------------------
98+
// Empty-string cookie is forwarded as-is (not coerced to null) and treated
99+
// as untrusted — documents the ?string contract between gate and trust layers
100+
// -------------------------------------------------------------------------
101+
102+
public function testRequiresChallengePassesThroughEmptyStringCookieToDeviceTrustService(): void
103+
{
104+
$user = Mockery::mock(User::class);
105+
$user->shouldReceive('shouldRequire2FA')->once()->andReturn(true);
106+
$this->deviceTrustService->shouldReceive('isDeviceTrusted')->once()->with($user, '')->andReturn(false);
107+
108+
$this->assertTrue($this->service->requiresChallenge($user, ''));
109+
}
110+
}

0 commit comments

Comments
 (0)