diff --git a/app/Services/Auth/ITwoFactorGateService.php b/app/Services/Auth/ITwoFactorGateService.php
new file mode 100644
index 00000000..ad229f63
--- /dev/null
+++ b/app/Services/Auth/ITwoFactorGateService.php
@@ -0,0 +1,33 @@
+shouldRequire2FA()) {
+ return false;
+ }
+ return !$this->deviceTrustService->isDeviceTrusted($user, $cookieToken);
+ }
+}
diff --git a/app/Services/Auth/TwoFactorServiceProvider.php b/app/Services/Auth/TwoFactorServiceProvider.php
index 705eda7a..081eed76 100644
--- a/app/Services/Auth/TwoFactorServiceProvider.php
+++ b/app/Services/Auth/TwoFactorServiceProvider.php
@@ -1,5 +1,7 @@
app->singleton(IDeviceTrustService::class, DeviceTrustService::class);
$this->app->singleton(ITwoFactorAuditService::class, TwoFactorAuditService::class);
+ $this->app->singleton(ITwoFactorGateService::class, MFAGateService::class);
}
public function provides(): array
@@ -38,6 +41,7 @@ public function provides(): array
return [
IDeviceTrustService::class,
ITwoFactorAuditService::class,
+ ITwoFactorGateService::class,
];
}
}
diff --git a/phpunit.xml b/phpunit.xml
index 005f6b46..4c4f8d12 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -29,6 +29,7 @@
./tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php
./tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php
./tests/Unit/TwoFactorAuditServiceTest.php
+ ./tests/Unit/MFAGateServiceTest.php
diff --git a/tests/Unit/MFAGateServiceTest.php b/tests/Unit/MFAGateServiceTest.php
new file mode 100644
index 00000000..49c66822
--- /dev/null
+++ b/tests/Unit/MFAGateServiceTest.php
@@ -0,0 +1,113 @@
+deviceTrustService = Mockery::mock(IDeviceTrustService::class);
+ $this->service = new MFAGateService($this->deviceTrustService);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ // -------------------------------------------------------------------------
+ // Non-admin/non-enforced user with 2FA disabled returns false
+ // -------------------------------------------------------------------------
+
+ public function testRequiresChallengeReturnsFalseWhenUserDoesNotRequire2FA(): void
+ {
+ $user = Mockery::mock(User::class);
+ $user->shouldReceive('shouldRequire2FA')->once()->andReturn(false);
+ $this->deviceTrustService->shouldNotReceive('isDeviceTrusted');
+
+ $this->assertFalse($this->service->requiresChallenge($user, null));
+ }
+
+ // -------------------------------------------------------------------------
+ // Admin/enforced user with no cookie returns true
+ // -------------------------------------------------------------------------
+
+ public function testRequiresChallengeReturnsTrueWhenEnforcedAndNoCookie(): void
+ {
+ $user = Mockery::mock(User::class);
+ $user->shouldReceive('shouldRequire2FA')->once()->andReturn(true);
+ $this->deviceTrustService->shouldReceive('isDeviceTrusted')->once()->with($user, null)->andReturn(false);
+
+ $this->assertTrue($this->service->requiresChallenge($user, null));
+ }
+
+ // -------------------------------------------------------------------------
+ // Admin/enforced user with trusted device returns false
+ // -------------------------------------------------------------------------
+
+ public function testRequiresChallengeReturnsFalseWhenEnforcedAndDeviceTrusted(): void
+ {
+ $user = Mockery::mock(User::class);
+ $user->shouldReceive('shouldRequire2FA')->once()->andReturn(true);
+ $this->deviceTrustService->shouldReceive('isDeviceTrusted')->once()->with($user, 'valid-token')->andReturn(true);
+
+ $this->assertFalse($this->service->requiresChallenge($user, 'valid-token'));
+ }
+
+ // -------------------------------------------------------------------------
+ // Admin/enforced user with expired/revoked/wrong device returns true
+ // -------------------------------------------------------------------------
+
+ public function testRequiresChallengeReturnsTrueWhenEnforcedAndDeviceNotTrusted(): void
+ {
+ $user = Mockery::mock(User::class);
+ $user->shouldReceive('shouldRequire2FA')->once()->andReturn(true);
+ $this->deviceTrustService->shouldReceive('isDeviceTrusted')->once()->with($user, 'expired-token')->andReturn(false);
+
+ $this->assertTrue($this->service->requiresChallenge($user, 'expired-token'));
+ }
+
+ // -------------------------------------------------------------------------
+ // Empty-string cookie is forwarded as-is (not coerced to null) and treated
+ // as untrusted — documents the ?string contract between gate and trust layers
+ // -------------------------------------------------------------------------
+
+ public function testRequiresChallengePassesThroughEmptyStringCookieToDeviceTrustService(): void
+ {
+ $user = Mockery::mock(User::class);
+ $user->shouldReceive('shouldRequire2FA')->once()->andReturn(true);
+ $this->deviceTrustService->shouldReceive('isDeviceTrusted')->once()->with($user, '')->andReturn(false);
+
+ $this->assertTrue($this->service->requiresChallenge($user, ''));
+ }
+}