Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions WebFiori/Http/WebService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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();
}

Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions WebFiori/Http/WebServicesManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -1123,13 +1123,18 @@

return $retVal;
}
private function isAuth(WebService $service) {

Check warning on line 1126 in WebFiori/Http/WebServicesManager.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This method has 6 returns, which is more than the 5 allowed.

See more on https://sonarcloud.io/project/issues?id=WebFiori_http2&issues=AZ6DtV6wGAWD_diY0Jtt&open=AZ6DtV6wGAWD_diY0Jtt&pullRequest=133
if ($service->isAuthRequired()) {
// Check if method has authorization annotations
if ($service->hasMethodAuthorizationAnnotations()) {
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();

Expand Down
161 changes: 161 additions & 0 deletions tests/WebFiori/Tests/Http/RequiresAuthSecurityContextTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php
namespace WebFiori\Tests\Http;

use WebFiori\Http\APITestCase;
use WebFiori\Http\SecurityContext;
use WebFiori\Tests\Http\TestUser;
use WebFiori\Http\WebServicesManager;
use WebFiori\Tests\Http\TestServices\ClassRequiresAuthService;
use WebFiori\Tests\Http\TestServices\MethodRequiresAuthService;

/**
* Tests for #[RequiresAuth] checking SecurityContext::isAuthenticated() directly.
*
* @see https://github.com/WebFiori/http/issues/117
*/
class RequiresAuthSecurityContextTest extends APITestCase {

// =========================================================================
// Method-level #[RequiresAuth]
// =========================================================================

public function testMethodRequiresAuthWithUser() {
$user = new TestUser(1, ['USER'], [], 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']);
}

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']);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php
namespace WebFiori\Tests\Http\TestServices;

use WebFiori\Http\Annotations\AllowAnonymous;
use WebFiori\Http\Annotations\GetMapping;
use WebFiori\Http\Annotations\PostMapping;
use WebFiori\Http\Annotations\RequiresAuth;
use WebFiori\Http\Annotations\ResponseBody;
use WebFiori\Http\Annotations\RestController;
use WebFiori\Http\WebService;
use WebFiori\Json\Json;

/**
* Service with class-level #[RequiresAuth].
* isAuthorized() intentionally returns false to prove SecurityContext is used instead.
*/
#[RestController('class-requires-auth')]
#[RequiresAuth]
class ClassRequiresAuthService extends WebService {

#[GetMapping]
#[ResponseBody]
public function getProtectedData(): Json {
$json = new Json();
$json->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() {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php
namespace WebFiori\Tests\Http\TestServices;

use WebFiori\Http\Annotations\GetMapping;
use WebFiori\Http\Annotations\RequiresAuth;
use WebFiori\Http\Annotations\ResponseBody;
use WebFiori\Http\Annotations\RestController;
use WebFiori\Http\WebService;
use WebFiori\Json\Json;

/**
* Service with method-level #[RequiresAuth] only.
* isAuthorized() returns false to prove it's not called.
*/
#[RestController('method-requires-auth')]
class MethodRequiresAuthService extends WebService {

#[GetMapping]
#[ResponseBody]
#[RequiresAuth]
public function getSecretData(): Json {
$json = new Json();
$json->add('secret', 'method-level-protected');
return $json;
}

public function isAuthorized(): bool {
return false; // Should NOT matter when #[RequiresAuth] is on method
}

public function processRequest() {
}
}
Loading