Skip to content

Commit 3ebff26

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

5 files changed

Lines changed: 167 additions & 0 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: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
namespace App\Services\Auth;
3+
/**
4+
* Copyright 2025 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 Auth\User;
17+
18+
/**
19+
* Class MFAGateService
20+
* @package App\Services\Auth
21+
*/
22+
final class MFAGateService implements ITwoFactorGateService
23+
{
24+
public function __construct(
25+
private readonly IDeviceTrustService $deviceTrustService
26+
) {
27+
}
28+
29+
/**
30+
* @inheritDoc
31+
*/
32+
public function requiresChallenge(User $user, ?string $cookieToken): bool
33+
{
34+
if (!$user->shouldRequire2FA()) {
35+
return false;
36+
}
37+
return !$this->deviceTrustService->isDeviceTrusted($user, $cookieToken);
38+
}
39+
}

app/Services/Auth/TwoFactorServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@ public function register(): void
3131
{
3232
$this->app->singleton(IDeviceTrustService::class, DeviceTrustService::class);
3333
$this->app->singleton(ITwoFactorAuditService::class, TwoFactorAuditService::class);
34+
$this->app->singleton(ITwoFactorGateService::class, MFAGateService::class);
3435
}
3536

3637
public function provides(): array
3738
{
3839
return [
3940
IDeviceTrustService::class,
4041
ITwoFactorAuditService::class,
42+
ITwoFactorGateService::class,
4143
];
4244
}
4345
}

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

0 commit comments

Comments
 (0)