feat: implement JWT authentication and scope validation middleware (#11)
All checks were successful
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m50s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m41s
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 3m8s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 3m22s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Successful in 3m5s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Successful in 1m41s

Reviewed-on: #11
Co-authored-by: Ron Rise <ron@siteworxpro.com>
Co-committed-by: Ron Rise <ron@siteworxpro.com>
This commit was merged in pull request #11.
This commit is contained in:
2025-11-07 17:14:22 +00:00
committed by Siteworx Pro Gitea
parent 54ea22c49a
commit 13445a0719
15 changed files with 529 additions and 16 deletions

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Http\Middleware;
use Carbon\Carbon;
use Carbon\WrapperClock;
use Lcobucci\JWT\JwtFacade;
use Lcobucci\JWT\Signer\Hmac\Sha256 as Hmac256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\Constraint\IssuedBy;
use Lcobucci\JWT\Validation\Constraint\LooseValidAt;
use Lcobucci\JWT\Validation\Constraint\PermittedFor;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Lcobucci\JWT\Validation\Constraint\StrictValidAt;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use League\Route\Dispatcher;
use League\Route\Route;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Siteworxpro\App\Annotations\Guards\Jwt;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\HttpStatus\CodesEnum;
class JwtMiddleware implements MiddlewareInterface
{
/**
* @throws \JsonException
* @throws \Exception
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface|Dispatcher $handler): ResponseInterface
{
if (!$handler instanceof Dispatcher) {
return $handler->handle($request);
}
/** @var Route | null $lastSegment */
$lastSegment = array_last($handler->getMiddlewareStack());
if ($lastSegment === null) {
return $handler->handle($request);
}
$callable = $lastSegment->getCallable();
$class = null;
$method = null;
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')
[$class, $method] = explode('::', $callable);
}
if (class_exists($class)) {
$reflectionClass = new \ReflectionClass($class);
if ($reflectionClass->hasMethod($method)) {
$reflectionMethod = $reflectionClass->getMethod($method);
$attributes = $reflectionMethod->getAttributes(Jwt::class);
if (empty($attributes)) {
return $handler->handle($request);
}
$token = str_replace('Bearer ', '', $request->getHeaderLine('Authorization'));
if (empty($token)) {
return JsonResponseFactory::createJsonResponse([
'status_code' => 401,
'message' => 'Unauthorized: Missing token',
], CodesEnum::UNAUTHORIZED);
}
$requiredIssuers = [];
$requiredAudience = '';
foreach ($attributes as $attribute) {
/** @var Jwt $jwtInstance */
$jwtInstance = $attribute->newInstance();
if ($jwtInstance->getRequiredAudience() !== '') {
$requiredAudience = $jwtInstance->getAudience();
}
$requiredIssuers[] = $jwtInstance->getIssuer();
}
try {
$jwt = new JwtFacade()->parse(
$token,
$this->getSignedWith(),
Config::get('jwt.strict_validation') ?
new StrictValidAt(new WrapperClock(Carbon::now())) :
new LooseValidAt(new WrapperClock(Carbon::now())),
new IssuedBy(...$requiredIssuers),
new PermittedFor($requiredAudience)
);
} catch (RequiredConstraintsViolated $exception) {
$violations = [];
foreach ($exception->violations() as $violation) {
$violations[] = $violation->getMessage();
}
return JsonResponseFactory::createJsonResponse([
'status_code' => 401,
'message' => 'Unauthorized: Invalid token',
'errors' => $violations
], CodesEnum::UNAUTHORIZED);
} catch (InvalidTokenStructure) {
return JsonResponseFactory::createJsonResponse([
'status_code' => 401,
'message' => 'Unauthorized: Invalid token',
], CodesEnum::UNAUTHORIZED);
}
foreach ($jwt->claims()->all() as $item => $value) {
$request = $request->withAttribute($item, $value);
}
}
}
return $handler->handle($request);
}
private function getSignedWith(): SignedWith
{
$key = Config::get('jwt.signing_key');
if ($key === null) {
throw new \RuntimeException('JWT signing key is not configured.');
}
if (str_starts_with($key, 'file://')) {
$key = InMemory::file(substr($key, 7));
} else {
$key = InMemory::plainText($key);
}
if (str_contains($key->contents(), 'PUBLIC KEY')) {
return new SignedWith(new Sha256(), $key);
}
return new SignedWith(new Hmac256(), $key);
}
}