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

@@ -21,4 +21,4 @@ http:
logs:
encoding: json
level: ${LOG_LEVEL:-info}
mode: production
mode: ${LOG_MODE:-production}

View File

@@ -16,7 +16,9 @@
"illuminate/support": "^v12.10.2",
"roadrunner-php/app-logger": "^1.2.0",
"siteworxpro/config": "^1.1.1",
"predis/predis": "^v3.2.0"
"predis/predis": "^v3.2.0",
"siteworxpro/http-status": "0.0.2",
"lcobucci/jwt": "^5.6"
},
"require-dev": {
"phpunit/phpunit": "^12.4",

102
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "df98926488dc1be80080ae38a55b6f97",
"content-hash": "d9509da999bae9517bf79ee251ccdd32",
"packages": [
{
"name": "brick/math",
@@ -740,6 +740,79 @@
},
"time": "2025-10-09T13:42:30+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.6.0",
"source": {
"type": "git",
"url": "https://github.com/lcobucci/jwt.git",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-sodium": "*",
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/clock": "^1.0"
},
"require-dev": {
"infection/infection": "^0.29",
"lcobucci/clock": "^3.2",
"lcobucci/coding-standard": "^11.0",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.2",
"phpstan/phpstan": "^1.10.7",
"phpstan/phpstan-deprecation-rules": "^1.1.3",
"phpstan/phpstan-phpunit": "^1.3.10",
"phpstan/phpstan-strict-rules": "^1.5.0",
"phpunit/phpunit": "^11.1"
},
"suggest": {
"lcobucci/clock": ">= 3.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Lcobucci\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Luís Cobucci",
"email": "lcobucci@gmail.com",
"role": "Developer"
}
],
"description": "A simple library to work with JSON Web Token and JSON Web Signature",
"keywords": [
"JWS",
"jwt"
],
"support": {
"issues": "https://github.com/lcobucci/jwt/issues",
"source": "https://github.com/lcobucci/jwt/tree/5.6.0"
},
"funding": [
{
"url": "https://github.com/lcobucci",
"type": "github"
},
{
"url": "https://www.patreon.com/lcobucci",
"type": "patreon"
}
],
"time": "2025-10-17T11:30:53+00:00"
},
{
"name": "league/route",
"version": "6.2.0",
@@ -1739,6 +1812,33 @@
],
"time": "2025-08-15T19:08:49+00:00"
},
{
"name": "siteworxpro/http-status",
"version": "0.0.2",
"source": {
"type": "git",
"url": "https://gitea.siteworxpro.com/php-packages/http-status",
"reference": "0.0.2"
},
"dist": {
"type": "zip",
"url": "https://gitea.siteworxpro.com/api/packages/php-packages/composer/files/siteworxpro%2Fhttp-status/0.0.2/siteworxpro-http-status.0.0.2.zip",
"shasum": "2eee4cd2605aa4b64ce18d18eb651764e9e88dbf"
},
"require": {
"php": ">=8.4"
},
"type": "library",
"autoload": {
"psr-4": {
"Siteworxpro\\HttpStatus\\": "src/"
}
},
"license": [
"MIT"
],
"time": "2025-06-20T12:46:36+00:00"
},
{
"name": "spiral/goridge",
"version": "4.2.1",

View File

@@ -41,5 +41,12 @@ return [
'port' => Env::get('REDIS_PORT', 6379, 'int'),
'database' => Env::get('REDIS_DATABASE', 0, 'int'),
'password' => Env::get('REDIS_PASSWORD'),
],
'jwt' => [
'signing_key' => Env::get('JWT_SIGNING_KEY', 'a_super_secret_key'),
'audience' => Env::get('JWT_AUDIENCE', 'my_audience'),
'issuer' => Env::get('JWT_ISSUER', 'my_issuer'),
'strict_validation' => Env::get('JWT_STRICT_VALIDATION', true, 'bool'),
]
];

View File

@@ -4,6 +4,31 @@ volumes:
services:
traefik:
image: traefik:latest
container_name: traefik
healthcheck:
test: ["CMD", "traefik", "healthcheck", "--ping"]
interval: 10s
timeout: 5s
retries: 5
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
restart: always
command:
- "--providers.docker=true"
- "--ping"
- "--providers.docker.exposedByDefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.web-secure.address=:443"
- "--accesslog=true"
- "--entrypoints.web.http.redirections.entryPoint.to=web-secure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- "--entrypoints.web.http.redirections.entrypoint.permanent=true"
composer-runtime:
volumes:
- .:/app
@@ -31,19 +56,33 @@ services:
DB_PORT: ${DB_PORT-5432}
dev-runtime:
ports:
- "9501:9501"
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.entrypoints=web-secure"
- "traefik.http.routers.api.rule=Host(`localhost`) || Host(`127.0.0.1`)"
- "traefik.http.routers.api.tls=true"
- "traefik.http.routers.api.service=api"
- "traefik.http.services.api.loadbalancer.healthcheck.path=/healthz"
- "traefik.http.services.api.loadbalancer.healthcheck.interval=5s"
- "traefik.http.services.api.loadbalancer.healthcheck.timeout=60s"
volumes:
- .:/app
build:
context: .
dockerfile: Dockerfile
entrypoint: "/bin/sh -c 'while true; do sleep 30; done;'"
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
environment:
PHP_IDE_CONFIG: serverName=localhost
WORKERS: 1
DEBUG: 1
REDIS_HOST: redis
DB_HOST: postgres
JWT_SIGNING_KEY: a-string-secret-at-least-256-bits-long
redis:
image: redis:latest
@@ -58,7 +97,7 @@ services:
- redisdata:/data
postgres:
image: postgres:latest
image: postgres:18
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-siteworxpro}"]
interval: 10s
@@ -71,4 +110,4 @@ services:
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- pgdata:/var/lib/postgresql

View 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;
}
}

View 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;
}
}

View 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']
);
}
}

View File

@@ -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']);
}
}

View File

@@ -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',
],

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);
}
}

View 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);
}
}

View File

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

View File

@@ -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
{

View File

@@ -6,29 +6,36 @@ namespace Siteworxpro\Tests\Http;
use PHPUnit\Framework\TestCase;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\HttpStatus\CodesEnum;
class JsonResponseFactoryTest extends TestCase
{
/**
* @throws \JsonException
*/
public function testCreateJsonResponseReturnsValidResponse(): void
{
$data = ['key' => 'value'];
$statusCode = 200;
$statusCode = CodesEnum::OK;
$response = JsonResponseFactory::createJsonResponse($data, $statusCode);
$this->assertSame($statusCode, $response->getStatusCode());
$this->assertSame($statusCode->value, $response->getStatusCode());
$this->assertSame('application/json', $response->getHeaderLine('Content-Type'));
$this->assertSame(json_encode($data), (string) $response->getBody());
}
/**
* @throws \JsonException
*/
public function testCreateJsonResponseHandlesEmptyData(): void
{
$data = [];
$statusCode = 204;
$statusCode = CodesEnum::NO_CONTENT;
$response = JsonResponseFactory::createJsonResponse($data, $statusCode);
$this->assertSame($statusCode, $response->getStatusCode());
$this->assertSame($statusCode->value, $response->getStatusCode());
$this->assertSame('application/json', $response->getHeaderLine('Content-Type'));
$this->assertSame(json_encode($data), (string) $response->getBody());
}