From 7d9eb96bea1aa4573963bc3582699a676f0efb50 Mon Sep 17 00:00:00 2001 From: Ron Rise Date: Wed, 19 Nov 2025 19:32:52 +0000 Subject: [PATCH] fix: make Scope attribute repeatable and improve scope handling in middleware (#21) Reviewed-on: https://gitea.siteworxpro.com/siteworxpro/Php-Template/pulls/21 Co-authored-by: Ron Rise Co-committed-by: Ron Rise --- src/Attributes/Guards/RequireAllScopes.php | 12 +++++ src/Attributes/Guards/Scope.php | 9 +++- src/Controllers/IndexController.php | 3 +- src/Http/Middleware/ScopeMiddleware.php | 58 ++++++++++++++-------- 4 files changed, 59 insertions(+), 23 deletions(-) create mode 100644 src/Attributes/Guards/RequireAllScopes.php diff --git a/src/Attributes/Guards/RequireAllScopes.php b/src/Attributes/Guards/RequireAllScopes.php new file mode 100644 index 0000000..95bd0c8 --- /dev/null +++ b/src/Attributes/Guards/RequireAllScopes.php @@ -0,0 +1,12 @@ + $scopes the required scopes + * @param string $claim the claim to check for scopes + * @param string $separator the separator used to split scopes in the claim + */ public function __construct( private array $scopes = [], private string $claim = 'scope', - private string $separator = ' ', + private string $separator = ' ' ) { } diff --git a/src/Controllers/IndexController.php b/src/Controllers/IndexController.php index c0a8e57..1547df5 100644 --- a/src/Controllers/IndexController.php +++ b/src/Controllers/IndexController.php @@ -22,7 +22,8 @@ class IndexController extends Controller * @throws \JsonException */ #[Guards\Jwt] - #[Guards\Scope(['get.index'])] + #[Guards\Scope(['get.index', 'status.check'])] + #[Guards\RequireAllScopes] public function get(ServerRequest $request): ResponseInterface { return JsonResponseFactory::createJsonResponse(['status_code' => 200, 'message' => 'Server is running']); diff --git a/src/Http/Middleware/ScopeMiddleware.php b/src/Http/Middleware/ScopeMiddleware.php index 19a144e..334afde 100644 --- a/src/Http/Middleware/ScopeMiddleware.php +++ b/src/Http/Middleware/ScopeMiddleware.php @@ -8,6 +8,7 @@ use League\Route\Dispatcher; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use Siteworxpro\App\Attributes\Guards\RequireAllScopes; use Siteworxpro\App\Attributes\Guards\Scope; use Siteworxpro\App\Controllers\Controller; use Siteworxpro\App\Http\JsonResponseFactory; @@ -32,7 +33,7 @@ class ScopeMiddleware extends Middleware * Expected user scopes are provided on the request under the attribute name \`scopes\` * as an array of strings. * - * @param ServerRequestInterface $request Incoming PSR-7 request (expects \`scopes\` attribute). + * @param ServerRequestInterface $request Incoming PSR-7 request (expects \`scopes\` attribute). * @param RequestHandlerInterface|Dispatcher $handler Next handler or League\Route dispatcher. * * @return ResponseInterface A 403 JSON response when scopes are insufficient; otherwise the handler response. @@ -42,7 +43,7 @@ class ScopeMiddleware extends Middleware */ public function process( ServerRequestInterface $request, - RequestHandlerInterface | Dispatcher $handler + RequestHandlerInterface|Dispatcher $handler ): ResponseInterface { // Attempt to resolve the route's callable [Controller instance, method name]. $callable = $this->extractRouteCallable($handler); @@ -57,38 +58,55 @@ class ScopeMiddleware extends Middleware // Ensure the controller exists and the method is defined before reflecting. if (class_exists($class::class)) { $reflectionClass = new \ReflectionClass($class); + if ($reflectionClass->hasMethod($method)) { $reflectionMethod = $reflectionClass->getMethod($method); // Fetch all Scope attributes declared on the method. $attributes = $reflectionMethod->getAttributes(Scope::class); + $requireAllAttributes = $reflectionMethod->getAttributes(RequireAllScopes::class); + + if (empty($attributes)) { + // No scope attributes; delegate to the next handler. + return $handler->handle($request); + } + + $requiredScopes = []; + $userScopes = []; + $requireAll = false; foreach ($attributes as $attribute) { /** @var Scope $scopeInstance Concrete Scope attribute instance. */ $scopeInstance = $attribute->newInstance(); - $requiredScopes = $scopeInstance->getScopes(); + $requiredScopes = array_merge($requiredScopes, $scopeInstance->getScopes()); - // Retrieve user scopes from the request (defaults to an empty array). - $userScopes = $request->getAttribute($scopeInstance->getClaim(), []); + // If any attribute requires all scopes, set the flag. + $requireAll = $requireAll || !empty($requireAllAttributes); - if (!is_array($userScopes)) { + $scopes = $request->getAttribute($scopeInstance->getClaim()); + if (!is_array($scopes)) { // If user scopes are not an array, treat as no scopes provided. - $userScopes = explode($scopeInstance->getSeparator(), (string) $userScopes); + $scopes = explode($scopeInstance->getSeparator(), (string) $scopes); } - // Deny if any required scope is missing from the user's scopes. - if ( - array_any( - $requiredScopes, - fn($requiredScope) => !in_array($requiredScope, $userScopes, true) - ) - ) { - return JsonResponseFactory::createJsonResponse([ - 'error' => 'insufficient_scope', - 'error_description' => - 'The request requires higher privileges than provided by the access token.' - ], CodesEnum::FORBIDDEN); - } + $userScopes = array_merge( + $userScopes, + $scopes + ); + } + + $userScopes = array_unique($userScopes); + + // Deny if any required scope is missing from the user's scopes. + if ( + (!$requireAll && array_intersect($userScopes, $requiredScopes) === []) || + ($requireAll && array_diff($requiredScopes, $userScopes) !== []) + ) { + return JsonResponseFactory::createJsonResponse([ + 'error' => 'insufficient_scope', + 'error_description' => + 'The request requires higher privileges than provided by the access token.' + ], CodesEnum::FORBIDDEN); } } }