From 2598fb98f2b8363ef4a6aceb85df1b87eed20f5d Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Mon, 1 Jun 2026 18:01:43 +0300 Subject: [PATCH] feat(#117): #[RequiresAuth] checks SecurityContext::isAuthenticated() directly - Method-level #[RequiresAuth] now checks SecurityContext instead of isAuthorized() - Class-level #[RequiresAuth] with no method annotations checks SecurityContext - checkMethodAuthorization() falls back to class-level check before isAuthorized() - Add hasClassLevelRequiresAuth() helper method - Services without auth attributes still use isAuthorized() (unchanged) - 8 new tests covering method-level, class-level, AllowAnonymous override, fallback --- WebFiori/Http/WebService.php | 19 ++- WebFiori/Http/WebServicesManager.php | 5 + .../Http/RequiresAuthSecurityContextTest.php | 161 ++++++++++++++++++ .../TestServices/ClassRequiresAuthService.php | 44 +++++ .../MethodRequiresAuthService.php | 33 ++++ 5 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 tests/WebFiori/Tests/Http/RequiresAuthSecurityContextTest.php create mode 100644 tests/WebFiori/Tests/Http/TestServices/ClassRequiresAuthService.php create mode 100644 tests/WebFiori/Tests/Http/TestServices/MethodRequiresAuthService.php diff --git a/WebFiori/Http/WebService.php b/WebFiori/Http/WebService.php index 06fe03e..29f5f2a 100644 --- a/WebFiori/Http/WebService.php +++ b/WebFiori/Http/WebService.php @@ -328,8 +328,8 @@ public function checkMethodAuthorization(): bool { // Check RequiresAuth if ($hasRequiresAuth) { - // First call isAuthorized() - if (!$this->isAuthorized()) { + // Check SecurityContext directly instead of isAuthorized() + if (!SecurityContext::isAuthenticated()) { return false; } @@ -355,6 +355,11 @@ public function checkMethodAuthorization(): bool { return SecurityContext::evaluateExpression($preAuth->expression); } + // If class has #[RequiresAuth], check SecurityContext + if ($this->hasClassLevelRequiresAuth()) { + return SecurityContext::isAuthenticated(); + } + return $this->isAuthorized(); } @@ -689,6 +694,16 @@ public function isAuthorized() : string|bool { public function isAuthRequired() : bool { return $this->requireAuth; } + /** + * Checks if the class has a #[RequiresAuth] attribute. + * + * @return bool True if the class-level RequiresAuth annotation is present. + */ + public function hasClassLevelRequiresAuth() : bool { + $reflection = new \ReflectionClass($this); + + return !empty($reflection->getAttributes(Annotations\RequiresAuth::class)); + } /** * Validates the name of a web service or request parameter. diff --git a/WebFiori/Http/WebServicesManager.php b/WebFiori/Http/WebServicesManager.php index b87d0b9..a4db812 100644 --- a/WebFiori/Http/WebServicesManager.php +++ b/WebFiori/Http/WebServicesManager.php @@ -1130,6 +1130,11 @@ private function isAuth(WebService $service) { return $service->checkMethodAuthorization(); } + // If class-level #[RequiresAuth] is present, check SecurityContext + if ($service->hasClassLevelRequiresAuth()) { + return SecurityContext::isAuthenticated(); + } + // Fall back to legacy HTTP-method-specific authorization $isAuthCheck = 'isAuthorized'.$this->getRequest()->getMethod(); diff --git a/tests/WebFiori/Tests/Http/RequiresAuthSecurityContextTest.php b/tests/WebFiori/Tests/Http/RequiresAuthSecurityContextTest.php new file mode 100644 index 0000000..a4eb850 --- /dev/null +++ b/tests/WebFiori/Tests/Http/RequiresAuthSecurityContextTest.php @@ -0,0 +1,161 @@ +addService(new MethodRequiresAuthService()); + + $output = $this->getRequest($manager, 'method-requires-auth', [], [], $user); + $response = json_decode($output, true); + + $this->assertIsArray($response); + $this->assertEquals('method-level-protected', $response['secret']); + } + + public function testMethodRequiresAuthWithoutUser() { + SecurityContext::setCurrentUser(null); + + $manager = new WebServicesManager(); + $manager->addService(new MethodRequiresAuthService()); + + $output = $this->getRequest($manager, 'method-requires-auth'); + $response = json_decode($output, true); + + $this->assertIsArray($response); + $this->assertEquals('error', $response['type']); + $this->assertEquals(401, $response['http-code']); + } + + public function testMethodRequiresAuthIgnoresIsAuthorized() { + $user = new TestUser(2, ['ADMIN'], [], true); + + $manager = new WebServicesManager(); + $manager->addService(new MethodRequiresAuthService()); + + $output = $this->getRequest($manager, 'method-requires-auth', [], [], $user); + $response = json_decode($output, true); + + $this->assertIsArray($response); + $this->assertEquals('method-level-protected', $response['secret']); + } + + // ========================================================================= + // Class-level #[RequiresAuth] + // ========================================================================= + + public function testClassRequiresAuthWithUser() { + $user = new TestUser(3, ['USER'], [], true); + + $manager = new WebServicesManager(); + $manager->addService(new ClassRequiresAuthService()); + + $output = $this->getRequest($manager, 'class-requires-auth', [], [], $user); + $response = json_decode($output, true); + + $this->assertIsArray($response); + $this->assertEquals('class-level-protected', $response['secret']); + } + + public function testClassRequiresAuthWithoutUser() { + SecurityContext::setCurrentUser(null); + + $manager = new WebServicesManager(); + $manager->addService(new ClassRequiresAuthService()); + + $output = $this->getRequest($manager, 'class-requires-auth'); + $response = json_decode($output, true); + + $this->assertIsArray($response); + $this->assertEquals('error', $response['type']); + $this->assertEquals(401, $response['http-code']); + } + + public function testClassRequiresAuthMethodAllowAnonymous() { + // Class has #[RequiresAuth] but method has #[AllowAnonymous] + SecurityContext::setCurrentUser(null); + + $manager = new WebServicesManager(); + $manager->addService(new ClassRequiresAuthService()); + + $output = $this->postRequest($manager, 'class-requires-auth'); + $response = json_decode($output, true); + + $this->assertIsArray($response); + $this->assertEquals(true, $response['public']); + } + + // ========================================================================= + // No attributes — traditional fallback + // ========================================================================= + + public function testNoAttributesFallsBackToIsAuthorized() { + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('no-attr-deny'); + $this->addRequestMethod('GET'); + $this->setIsAuthRequired(true); + } + public function isAuthorized(): bool { + return false; + } + public function processRequest() { + $this->sendResponse('should not reach', 200, 'success'); + } + }; + + $manager = new WebServicesManager(); + $manager->addService($service); + + $output = $this->getRequest($manager, 'no-attr-deny'); + $response = json_decode($output, true); + + $this->assertIsArray($response); + $this->assertEquals('error', $response['type']); + $this->assertEquals(401, $response['http-code']); + } + + public function testNoAttributesIsAuthorizedTrue() { + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('no-attr-allow'); + $this->addRequestMethod('GET'); + $this->setIsAuthRequired(true); + } + public function isAuthorized(): bool { + return true; + } + public function processRequest() { + $this->sendResponse('allowed', 200, 'success'); + } + }; + + $manager = new WebServicesManager(); + $manager->addService($service); + + $output = $this->getRequest($manager, 'no-attr-allow'); + $response = json_decode($output, true); + + $this->assertIsArray($response); + $this->assertEquals('success', $response['type']); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ClassRequiresAuthService.php b/tests/WebFiori/Tests/Http/TestServices/ClassRequiresAuthService.php new file mode 100644 index 0000000..53318a5 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ClassRequiresAuthService.php @@ -0,0 +1,44 @@ +add('secret', 'class-level-protected'); + return $json; + } + + #[PostMapping] + #[ResponseBody] + #[AllowAnonymous] + public function publicEndpoint(): Json { + $json = new Json(); + $json->add('public', true); + return $json; + } + + public function isAuthorized(): bool { + return false; // Should NOT matter when #[RequiresAuth] is on class + } + + public function processRequest() { + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/MethodRequiresAuthService.php b/tests/WebFiori/Tests/Http/TestServices/MethodRequiresAuthService.php new file mode 100644 index 0000000..6579dd5 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/MethodRequiresAuthService.php @@ -0,0 +1,33 @@ +add('secret', 'method-level-protected'); + return $json; + } + + public function isAuthorized(): bool { + return false; // Should NOT matter when #[RequiresAuth] is on method + } + + public function processRequest() { + } +}