You've already forked Php-Template
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
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:
44
src/Annotations/Guards/Jwt.php
Normal file
44
src/Annotations/Guards/Jwt.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Siteworxpro\App\Annotations\Guards;
|
||||
|
||||
use Attribute;
|
||||
use Siteworxpro\App\Services\Facades\Config;
|
||||
|
||||
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
|
||||
readonly class Jwt
|
||||
{
|
||||
public function __construct(
|
||||
private string $issuer = '',
|
||||
private string $audience = '',
|
||||
) {
|
||||
}
|
||||
|
||||
public function getRequiredAudience(): string
|
||||
{
|
||||
return Config::get('jwt.audience') ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getAudience(): string
|
||||
{
|
||||
if ($this->audience === '') {
|
||||
return Config::get('jwt.audience') ?? '';
|
||||
}
|
||||
|
||||
return $this->audience;
|
||||
}
|
||||
|
||||
public function getIssuer(): string
|
||||
{
|
||||
if ($this->issuer === '') {
|
||||
return Config::get('jwt.issuer') ?? '';
|
||||
}
|
||||
|
||||
return $this->issuer;
|
||||
}
|
||||
}
|
||||
21
src/Annotations/Guards/Scope.php
Normal file
21
src/Annotations/Guards/Scope.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Siteworxpro\App\Annotations\Guards;
|
||||
|
||||
use Attribute;
|
||||
|
||||
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
|
||||
readonly class Scope
|
||||
{
|
||||
public function __construct(
|
||||
private array $scopes = []
|
||||
) {}
|
||||
|
||||
|
||||
public function getScopes(): array
|
||||
{
|
||||
return $this->scopes;
|
||||
}
|
||||
}
|
||||
46
src/Controllers/HealthcheckController.php
Normal file
46
src/Controllers/HealthcheckController.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Siteworxpro\App\Controllers;
|
||||
|
||||
use Illuminate\Database\PostgresConnection;
|
||||
use Nyholm\Psr7\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Siteworxpro\App\Http\JsonResponseFactory;
|
||||
use Siteworxpro\App\Models\Model;
|
||||
use Siteworxpro\App\Services\Facades\Redis;
|
||||
use Siteworxpro\HttpStatus\CodesEnum;
|
||||
|
||||
class HealthcheckController extends Controller
|
||||
{
|
||||
/**
|
||||
* @throws \JsonException
|
||||
*/
|
||||
public function get(ServerRequest $request): ResponseInterface
|
||||
{
|
||||
try {
|
||||
/** @var PostgresConnection $conn */
|
||||
$conn = Model::getConnectionResolver()->connection();
|
||||
$conn->getPdo()->exec('SELECT 1');
|
||||
|
||||
$response = Redis::ping();
|
||||
if ($response->getPayload() !== 'PONG') {
|
||||
throw new \Exception('Redis ping failed');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return JsonResponseFactory::createJsonResponse(
|
||||
[
|
||||
'status_code' => CodesEnum::SERVICE_UNAVAILABLE->value,
|
||||
'message' => 'Healthcheck Failed',
|
||||
'error' => $e->getMessage(),
|
||||
],
|
||||
CodesEnum::SERVICE_UNAVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
return JsonResponseFactory::createJsonResponse(
|
||||
['status_code' => 200, 'message' => 'Healthcheck OK']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace Siteworxpro\App\Controllers;
|
||||
|
||||
use Nyholm\Psr7\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Siteworxpro\App\Annotations\Guards;
|
||||
use Siteworxpro\App\Http\JsonResponseFactory;
|
||||
|
||||
/**
|
||||
@@ -20,8 +21,20 @@ class IndexController extends Controller
|
||||
*
|
||||
* @throws \JsonException
|
||||
*/
|
||||
#[Guards\Jwt]
|
||||
#[Guards\Scope(['get.index'])]
|
||||
public function get(ServerRequest $request): ResponseInterface
|
||||
{
|
||||
return JsonResponseFactory::createJsonResponse(['status_code' => 200, 'message' => 'Server is running']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \JsonException
|
||||
*/
|
||||
#[Guards\Jwt]
|
||||
#[Guards\Scope(['post.index'])]
|
||||
public function post(ServerRequest $request): ResponseInterface
|
||||
{
|
||||
return JsonResponseFactory::createJsonResponse(['status_code' => 200, 'message' => 'Server is running']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Siteworxpro\App\Http;
|
||||
|
||||
use Nyholm\Psr7\Response;
|
||||
use Siteworxpro\HttpStatus\CodesEnum;
|
||||
|
||||
/**
|
||||
* Class JsonResponseFactory
|
||||
@@ -17,14 +18,14 @@ class JsonResponseFactory
|
||||
* Create a JSON response with the given data and status code.
|
||||
*
|
||||
* @param array $data The data to include in the response.
|
||||
* @param int $statusCode The HTTP status code for the response.
|
||||
* @param CodesEnum $statusCode The HTTP status code for the response.
|
||||
* @return Response The JSON response.
|
||||
* @throws \JsonException
|
||||
*/
|
||||
public static function createJsonResponse(array $data, int $statusCode = 200): Response
|
||||
public static function createJsonResponse(array $data, CodesEnum $statusCode = CodesEnum::OK): Response
|
||||
{
|
||||
return new Response(
|
||||
status: $statusCode,
|
||||
status: $statusCode->value,
|
||||
headers: [
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
|
||||
153
src/Http/Middleware/JwtMiddleware.php
Normal file
153
src/Http/Middleware/JwtMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
71
src/Http/Middleware/ScopeMiddleware.php
Normal file
71
src/Http/Middleware/ScopeMiddleware.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Siteworxpro\App\Http\Middleware;
|
||||
|
||||
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\Scope;
|
||||
use Siteworxpro\App\Http\JsonResponseFactory;
|
||||
use Siteworxpro\HttpStatus\CodesEnum;
|
||||
|
||||
class ScopeMiddleware implements MiddlewareInterface
|
||||
{
|
||||
/**
|
||||
* @throws \JsonException
|
||||
*/
|
||||
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(Scope::class);
|
||||
|
||||
foreach ($attributes as $attribute) {
|
||||
/** @var Scope $scopeInstance */
|
||||
$scopeInstance = $attribute->newInstance();
|
||||
$requiredScopes = $scopeInstance->getScopes();
|
||||
|
||||
$userScopes = $request->getAttribute('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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,18 @@ use League\Route\Http\Exception\NotFoundException;
|
||||
use League\Route\Router;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Siteworx\Config\Config as SWConfig;
|
||||
use Siteworxpro\App\Controllers\HealthcheckController;
|
||||
use Siteworxpro\App\Controllers\IndexController;
|
||||
use Siteworxpro\App\Http\JsonResponseFactory;
|
||||
use Siteworxpro\App\Http\Middleware\CorsMiddleware;
|
||||
use Siteworxpro\App\Http\Middleware\JwtMiddleware;
|
||||
use Siteworxpro\App\Http\Middleware\ScopeMiddleware;
|
||||
use Siteworxpro\App\Services\Facade;
|
||||
use Siteworxpro\App\Services\Facades\Config;
|
||||
use Siteworxpro\App\Services\Facades\Logger;
|
||||
use Siteworxpro\App\Services\ServiceProviders\LoggerServiceProvider;
|
||||
use Siteworxpro\App\Services\ServiceProviders\RedisServiceProvider;
|
||||
use Siteworxpro\HttpStatus\CodesEnum;
|
||||
use Spiral\RoadRunner\Http\PSR7Worker;
|
||||
use Spiral\RoadRunner\Worker;
|
||||
|
||||
@@ -138,7 +142,11 @@ class Server
|
||||
protected function registerRoutes(): void
|
||||
{
|
||||
$this->router->get('/', IndexController::class . '::get');
|
||||
$this->router->get('/healthz', HealthcheckController::class . '::get');
|
||||
|
||||
$this->router->middleware(new CorsMiddleware());
|
||||
$this->router->middleware(new JwtMiddleware());
|
||||
$this->router->middleware(new ScopeMiddleware());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,7 +180,7 @@ class Server
|
||||
$this->worker->respond(
|
||||
JsonResponseFactory::createJsonResponse(
|
||||
['status_code' => 404, 'reason_phrase' => 'Not Found'],
|
||||
404
|
||||
CodesEnum::NOT_FOUND
|
||||
)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -189,7 +197,7 @@ class Server
|
||||
];
|
||||
}
|
||||
|
||||
$this->worker->respond(JsonResponseFactory::createJsonResponse($json, 500));
|
||||
$this->worker->respond(JsonResponseFactory::createJsonResponse($json, CodesEnum::INTERNAL_SERVER_ERROR));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use Siteworxpro\App\Services\Facade;
|
||||
* @method static Status|null set(string $key, $value, $expireResolution = null, $expireTTL = null, $flag = null)
|
||||
* @method static array keys(string $pattern)
|
||||
* @method static int del(string $key)
|
||||
* @method static Status ping()
|
||||
*/
|
||||
class Redis extends Facade
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user