You've already forked Php-Template
chore: added documentation (#16)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m42s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m38s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m5s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m44s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m28s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m27s
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 1m42s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 1m38s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m5s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m44s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 2m28s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m27s
Reviewed-on: #16 Co-authored-by: Ron Rise <ron@siteworxpro.com> Co-committed-by: Ron Rise <ron@siteworxpro.com>
This commit was merged in pull request #16.
This commit is contained in:
@@ -27,18 +27,44 @@ use Siteworxpro\App\Http\JsonResponseFactory;
|
||||
use Siteworxpro\App\Services\Facades\Config;
|
||||
use Siteworxpro\HttpStatus\CodesEnum;
|
||||
|
||||
/**
|
||||
* JWT authorization middleware.
|
||||
*
|
||||
* Applies JWT validation to controller actions annotated with `Jwt` attribute.
|
||||
* Flow:
|
||||
* - Resolve the targeted controller and method for the current route.
|
||||
* - If the method has `Jwt`, read the `Authorization` header and parse the Bearer token.
|
||||
* - Validate signature, time constraints, issuer\(\) and audience\(\) based on attribute and config.
|
||||
* - On success, attach all token claims to the request as attributes.
|
||||
* - On failure, return a 401 JSON response with validation errors.
|
||||
*
|
||||
* Configuration:
|
||||
* - `jwt.signing_key`: key material or `file://` path to key.
|
||||
* - `jwt.strict_validation`: bool toggling strict vs loose time validation.
|
||||
*/
|
||||
class JwtMiddleware extends Middleware
|
||||
{
|
||||
/**
|
||||
* @throws \JsonException
|
||||
* @throws \Exception
|
||||
* Process the incoming request.
|
||||
*
|
||||
* If the matched controller method is annotated with `Jwt`, validates the token and
|
||||
* augments the request with claims on success. Otherwise, just delegates to the next handler.
|
||||
*
|
||||
* @param ServerRequestInterface $request PSR-7 request instance.
|
||||
* @param RequestHandlerInterface|Dispatcher $handler Next middleware or route dispatcher.
|
||||
*
|
||||
* @return ResponseInterface Response produced by the next handler or a 401 JSON response.
|
||||
*
|
||||
* @throws \JsonException On JSON error response encoding issues.
|
||||
* @throws \Exception On unexpected reflection or JWT parsing issues.
|
||||
*/
|
||||
public function process(
|
||||
ServerRequestInterface $request,
|
||||
RequestHandlerInterface|Dispatcher $handler
|
||||
): ResponseInterface {
|
||||
|
||||
$callable = $this->extractRouteCallable($request, $handler);
|
||||
// Resolve the callable \[Controller, method] for the current route.
|
||||
$callable = $this->extractRouteCallable($handler);
|
||||
if ($callable === null) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
@@ -51,12 +77,15 @@ class JwtMiddleware extends Middleware
|
||||
|
||||
if ($reflectionClass->hasMethod($method)) {
|
||||
$reflectionMethod = $reflectionClass->getMethod($method);
|
||||
// Read `Jwt` attribute on the controller method.
|
||||
$attributes = $reflectionMethod->getAttributes(Jwt::class);
|
||||
|
||||
// If no `Jwt` attribute, do not enforce auth here.
|
||||
if (empty($attributes)) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
// Extract Bearer token from Authorization header.
|
||||
$token = str_replace('Bearer ', '', $request->getHeaderLine('Authorization'));
|
||||
|
||||
if (empty($token)) {
|
||||
@@ -66,6 +95,7 @@ class JwtMiddleware extends Middleware
|
||||
], CodesEnum::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Aggregate required issuers and audience from attributes.
|
||||
$requiredIssuers = [];
|
||||
$requiredAudience = '';
|
||||
|
||||
@@ -81,6 +111,7 @@ class JwtMiddleware extends Middleware
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse and validate the token with signature, time, issuer and audience constraints.
|
||||
$jwt = new JwtFacade()->parse(
|
||||
$token,
|
||||
$this->getSignedWith(),
|
||||
@@ -91,6 +122,7 @@ class JwtMiddleware extends Middleware
|
||||
new PermittedFor($requiredAudience)
|
||||
);
|
||||
} catch (RequiredConstraintsViolated $exception) {
|
||||
// Collect human-readable violations to return to the client.
|
||||
$violations = [];
|
||||
foreach ($exception->violations() as $violation) {
|
||||
$violations[] = $violation->getMessage();
|
||||
@@ -102,12 +134,14 @@ class JwtMiddleware extends Middleware
|
||||
'errors' => $violations
|
||||
], CodesEnum::UNAUTHORIZED);
|
||||
} catch (InvalidTokenStructure) {
|
||||
// Token could not be parsed due to malformed structure.
|
||||
return JsonResponseFactory::createJsonResponse([
|
||||
'status_code' => 401,
|
||||
'message' => 'Unauthorized: Invalid token',
|
||||
], CodesEnum::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Expose all token claims as request attributes for downstream consumers.
|
||||
foreach ($jwt->claims()->all() as $item => $value) {
|
||||
$request = $request->withAttribute($item, $value);
|
||||
}
|
||||
@@ -117,6 +151,17 @@ class JwtMiddleware extends Middleware
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the signature validation constraint from configured key.
|
||||
*
|
||||
* - If the configured key content includes the string `PUBLIC KEY`, use RSA SHA-256.
|
||||
* - Otherwise assume an HMAC SHA-256 shared secret.
|
||||
* - Supports raw key strings or `file://` paths.
|
||||
*
|
||||
* @return SignedWith Signature constraint used during JWT parsing.
|
||||
*
|
||||
* @throws \RuntimeException When no signing key is configured.
|
||||
*/
|
||||
private function getSignedWith(): SignedWith
|
||||
{
|
||||
$key = Config::get('jwt.signing_key');
|
||||
@@ -125,12 +170,14 @@ class JwtMiddleware extends Middleware
|
||||
throw new \RuntimeException('JWT signing key is not configured.');
|
||||
}
|
||||
|
||||
// Load key either from file or raw text.
|
||||
if (str_starts_with($key, 'file://')) {
|
||||
$key = InMemory::file(substr($key, 7));
|
||||
} else {
|
||||
$key = InMemory::plainText($key);
|
||||
}
|
||||
|
||||
// Heuristic: if PEM public key content is detected, use RSA; otherwise use HMAC.
|
||||
if (str_contains($key->contents(), 'PUBLIC KEY')) {
|
||||
return new SignedWith(new Sha256(), $key);
|
||||
}
|
||||
|
||||
@@ -9,30 +9,60 @@ use League\Route\Route;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
/**
|
||||
* Base middleware helper for extracting route callables.
|
||||
*
|
||||
* This abstract middleware provides a utility method to inspect a League\Route
|
||||
* dispatcher and obtain the underlying route callable as a [class, method] tuple.
|
||||
*
|
||||
* @package Siteworxpro\App\Http\Middleware
|
||||
*/
|
||||
abstract class Middleware implements MiddlewareInterface
|
||||
{
|
||||
|
||||
protected function extractRouteCallable($request, RequestHandlerInterface | Dispatcher $handler): array|null
|
||||
{
|
||||
/**
|
||||
* Extract the route callable [class, method] from a League\Route dispatcher.
|
||||
*
|
||||
* When the provided handler is a League\Route\Dispatcher, this inspects its
|
||||
* middleware stack, looks at the last segment (the resolved Route), and
|
||||
* attempts to normalize its callable into a [class, method] pair.
|
||||
*
|
||||
* Supported callable forms:
|
||||
* - array callable: [object|class-string, method-string]
|
||||
* - string callable: "ClassName::methodName"
|
||||
*
|
||||
* Returns null when the handler is not a Dispatcher, the stack is empty,
|
||||
* or the callable cannot be parsed.
|
||||
*
|
||||
* @param RequestHandlerInterface|Dispatcher $handler The downstream handler or dispatcher.
|
||||
*
|
||||
* @return array{0: class-string|object|null, 1: string|null}|null Tuple of [class|object, method] or null.
|
||||
*/
|
||||
protected function extractRouteCallable(
|
||||
RequestHandlerInterface|Dispatcher $handler
|
||||
): array|null {
|
||||
// Only proceed if this is a League\Route dispatcher.
|
||||
if (!$handler instanceof Dispatcher) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var Route | null $lastSegment */
|
||||
// Retrieve the last middleware in the stack, which should be the Route.
|
||||
$lastSegment = array_last($handler->getMiddlewareStack());
|
||||
|
||||
if ($lastSegment === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Obtain the callable associated with the route.
|
||||
$callable = $lastSegment->getCallable();
|
||||
$class = null;
|
||||
$method = null;
|
||||
|
||||
// Handle array callable: [object|class-string, 'method']
|
||||
if (is_array($callable) && count($callable) === 2) {
|
||||
[$class, $method] = $callable;
|
||||
} elseif (is_string($callable)) {
|
||||
// Handle the case where the callable is a string (e.g., 'ClassName::methodName')
|
||||
// Handle string callable: 'ClassName::methodName'
|
||||
[$class, $method] = explode('::', $callable);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,36 +13,65 @@ use Siteworxpro\App\Controllers\Controller;
|
||||
use Siteworxpro\App\Http\JsonResponseFactory;
|
||||
use Siteworxpro\HttpStatus\CodesEnum;
|
||||
|
||||
/**
|
||||
* Middleware that enforces scope-based access control on controller actions.
|
||||
*
|
||||
* It inspects PHP 8 attributes of type \`Scope\` applied to the resolved controller method,
|
||||
* compares the required scopes with the user scopes provided on the request attribute \`scopes\`,
|
||||
* and returns a 403 JSON response when any required scope is missing.
|
||||
*
|
||||
* If the route callable cannot be resolved, or no scope is required, the request is passed through.
|
||||
*
|
||||
* @see Scope
|
||||
*/
|
||||
class ScopeMiddleware extends Middleware
|
||||
{
|
||||
/**
|
||||
* @throws \JsonException
|
||||
* Resolve the route callable, read any \`Scope\` attributes, and enforce required scopes.
|
||||
*
|
||||
* 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 RequestHandlerInterface|Dispatcher $handler Next handler or League\Route dispatcher.
|
||||
*
|
||||
* @return ResponseInterface A 403 JSON response when scopes are insufficient; otherwise the handler response.
|
||||
*
|
||||
* @throws \JsonException If encoding the JSON error response fails.
|
||||
* @throws \ReflectionException If reflection on the controller or method fails.
|
||||
*/
|
||||
public function process(
|
||||
ServerRequestInterface $request,
|
||||
RequestHandlerInterface | Dispatcher $handler
|
||||
): ResponseInterface {
|
||||
$callable = $this->extractRouteCallable($request, $handler);
|
||||
// Attempt to resolve the route's callable [Controller instance, method name].
|
||||
$callable = $this->extractRouteCallable($handler);
|
||||
if ($callable === null) {
|
||||
// If no callable is available, delegate to the next handler.
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
/** @var Controller $class */
|
||||
/** @var Controller $class Controller instance resolved from the route. */
|
||||
[$class, $method] = $callable;
|
||||
|
||||
// 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);
|
||||
|
||||
foreach ($attributes as $attribute) {
|
||||
/** @var Scope $scopeInstance */
|
||||
/** @var Scope $scopeInstance Concrete Scope attribute instance. */
|
||||
$scopeInstance = $attribute->newInstance();
|
||||
$requiredScopes = $scopeInstance->getScopes();
|
||||
|
||||
// Retrieve user scopes from the request (defaults to an empty array).
|
||||
$userScopes = $request->getAttribute('scopes', []);
|
||||
|
||||
// Deny if any required scope is missing from the user's scopes.
|
||||
if (
|
||||
array_any(
|
||||
$requiredScopes,
|
||||
@@ -59,6 +88,7 @@ class ScopeMiddleware extends Middleware
|
||||
}
|
||||
}
|
||||
|
||||
// All checks passed; continue down the middleware pipeline.
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user