diff --git a/.rr.yaml b/.rr.yaml index 7336bc9..186b19e 100644 --- a/.rr.yaml +++ b/.rr.yaml @@ -21,4 +21,4 @@ http: logs: encoding: json level: ${LOG_LEVEL:-info} - mode: production \ No newline at end of file + mode: ${LOG_MODE:-production} \ No newline at end of file diff --git a/composer.json b/composer.json index 0502be3..cce99a9 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 0d44157..3f85ab1 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config.php b/config.php index 89e4c00..5717aea 100644 --- a/config.php +++ b/config.php @@ -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'), ] ]; diff --git a/docker-compose.yml b/docker-compose.yml index 21607d2..aaa5cfd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 \ No newline at end of file + - pgdata:/var/lib/postgresql \ No newline at end of file diff --git a/src/Annotations/Guards/Jwt.php b/src/Annotations/Guards/Jwt.php new file mode 100644 index 0000000..779963e --- /dev/null +++ b/src/Annotations/Guards/Jwt.php @@ -0,0 +1,44 @@ +audience === '') { + return Config::get('jwt.audience') ?? ''; + } + + return $this->audience; + } + + public function getIssuer(): string + { + if ($this->issuer === '') { + return Config::get('jwt.issuer') ?? ''; + } + + return $this->issuer; + } +} \ No newline at end of file diff --git a/src/Annotations/Guards/Scope.php b/src/Annotations/Guards/Scope.php new file mode 100644 index 0000000..8e29943 --- /dev/null +++ b/src/Annotations/Guards/Scope.php @@ -0,0 +1,21 @@ +scopes; + } +} diff --git a/src/Controllers/HealthcheckController.php b/src/Controllers/HealthcheckController.php new file mode 100644 index 0000000..f1e10a5 --- /dev/null +++ b/src/Controllers/HealthcheckController.php @@ -0,0 +1,46 @@ +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'] + ); + } +} diff --git a/src/Controllers/IndexController.php b/src/Controllers/IndexController.php index 37d0812..40a5f25 100644 --- a/src/Controllers/IndexController.php +++ b/src/Controllers/IndexController.php @@ -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']); + } } diff --git a/src/Http/JsonResponseFactory.php b/src/Http/JsonResponseFactory.php index c4f1914..005412f 100644 --- a/src/Http/JsonResponseFactory.php +++ b/src/Http/JsonResponseFactory.php @@ -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', ], diff --git a/src/Http/Middleware/JwtMiddleware.php b/src/Http/Middleware/JwtMiddleware.php new file mode 100644 index 0000000..110377f --- /dev/null +++ b/src/Http/Middleware/JwtMiddleware.php @@ -0,0 +1,153 @@ +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); + } +} \ No newline at end of file diff --git a/src/Http/Middleware/ScopeMiddleware.php b/src/Http/Middleware/ScopeMiddleware.php new file mode 100644 index 0000000..0b30133 --- /dev/null +++ b/src/Http/Middleware/ScopeMiddleware.php @@ -0,0 +1,71 @@ +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); + } +} \ No newline at end of file diff --git a/src/Server.php b/src/Server.php index 22a39d3..faa1a22 100644 --- a/src/Server.php +++ b/src/Server.php @@ -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)); } } } diff --git a/src/Services/Facades/Redis.php b/src/Services/Facades/Redis.php index 9ff4c5b..5667109 100644 --- a/src/Services/Facades/Redis.php +++ b/src/Services/Facades/Redis.php @@ -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 { diff --git a/tests/Http/JsonResponseFactoryTest.php b/tests/Http/JsonResponseFactoryTest.php index c203350..db41b2d 100644 --- a/tests/Http/JsonResponseFactoryTest.php +++ b/tests/Http/JsonResponseFactoryTest.php @@ -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()); }