From 25fc89fd30df3bb81975dbe46332c8905f405cc9 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Wed, 27 May 2026 21:50:43 +0000 Subject: [PATCH] feat: MFAGateService (Two-Factor Gate Decision Service) --- app/Services/Auth/ITwoFactorGateService.php | 33 +++++ app/Services/Auth/MFAGateService.php | 38 ++++++ .../Auth/TwoFactorServiceProvider.php | 4 + phpunit.xml | 1 + tests/Unit/MFAGateServiceTest.php | 113 ++++++++++++++++++ 5 files changed, 189 insertions(+) create mode 100644 app/Services/Auth/ITwoFactorGateService.php create mode 100644 app/Services/Auth/MFAGateService.php create mode 100644 tests/Unit/MFAGateServiceTest.php 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, '')); + } +}