diff --git a/CHANGELOG.md b/CHANGELOG.md index b5054a5..38c298c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0] - 2026-10-10 + +First stable release: the public API (middlewares, configuration objects and exceptions) follows +Semantic Versioning from now on. Adopted by `pivotphp/core` 4.0.0, which removed its own security +middlewares in favour of this package. + +### Added + +- **`Jwt\JwtIssuer`** — issues tokens accepted by `JwtAuthMiddleware` from the same `JwtConfig`: + `issue(array $claims, int $ttl = 3600)` sets `iat`/`nbf`/`exp` and `iss`/`aud`; reserved claims cannot + be overridden; asymmetric algorithms require the private key. + +### Changed + +- Optional adapter libraries are constrained to their tested major versions via `conflict` + (`bepsvpt/secure-headers` 9.x, `firebase/php-jwt` 7.x from 7.1.1, `symfony/rate-limiter` 6.4–8.x, + `yiisoft/csrf` 2.x from 2.2). Required dependencies remain only the PSR-7/15/17 interfaces. +- Development tools raised past published advisories (`phpunit/phpunit` ^10.5.62|^11.5.50, + `squizlabs/php_codesniffer` ^3.13.6). + +### Tests + +- Scenarios ported from `pivotphp-core` (CORS and security headers), with expectations corrected where + the core asserted wrong behaviour (`Access-Control-Allow-Origin: null`, `*` on non-CORS requests, + preflight reaching the handler, `X-XSS-Protection`). + ## [0.1.0] - 2026-10-10 ### Added @@ -26,4 +52,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Exception\InvalidConfigurationException` and `Exception\MissingDependencyException`, both implementing `Exception\SecurityException`. +[1.0.0]: https://github.com/PivotPHP/pivotphp-security/releases/tag/v1.0.0 [0.1.0]: https://github.com/PivotPHP/pivotphp-security/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index e0504e4..d99260f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ mínima em `conflict`. | `Proxy` | própria (só existe acoplado ao HttpFoundation) | | `Headers` | adapter de `bepsvpt/secure-headers` | | `Csrf` | adapter de `yiisoft/csrf` | -| `Jwt` | adapter de `firebase/php-jwt` | +| `Jwt` | adapter de `firebase/php-jwt` (verificação e emissão: `JwtAuthMiddleware`, `JwtIssuer`) | | `RateLimit` | adapter de `symfony/rate-limiter` (`RateLimiterFactory`; a interface só existe a partir da 7.3) | ## Comandos diff --git a/README.md b/README.md index 2fcc058..c479e7f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ demand: | `Proxy\TrustedProxyMiddleware` | — | — | | `Headers\SecurityHeadersMiddleware` | [bepsvpt/secure-headers](https://github.com/bepsvpt/secure-headers) | `composer require bepsvpt/secure-headers` | | `Csrf\CsrfMiddleware` | [yiisoft/csrf](https://github.com/yiisoft/csrf) | `composer require yiisoft/csrf` | -| `Jwt\JwtAuthMiddleware` | [firebase/php-jwt](https://github.com/firebase/php-jwt) | `composer require firebase/php-jwt` | +| `Jwt\JwtAuthMiddleware`, `Jwt\JwtIssuer` | [firebase/php-jwt](https://github.com/firebase/php-jwt) | `composer require firebase/php-jwt` | | `RateLimit\RateLimitMiddleware` | [symfony/rate-limiter](https://symfony.com/doc/current/rate_limiter.html) | `composer require symfony/rate-limiter` | ## Installation @@ -214,6 +214,25 @@ rejected); `iss`/`aud` are verified here. Failures return `401` with `WWW-Authen > algorithms are rejected at configuration. Load keys from the environment or a secret store, never > from source code. +### Issuing tokens + +`JwtIssuer` shares the `JwtConfig` used by the middleware, so issued tokens always match what is +verified (algorithm, `iss`, `aud`): + +```php +use PivotPHP\Security\Jwt\JwtIssuer; + +$issuer = new JwtIssuer($config); // HS*: signs with the configured secret +// $issuer = new JwtIssuer($config, $privateKeyPem); // RS*/PS256/ES*/EdDSA: private key required + +$token = $issuer->issue(['sub' => $userId, 'role' => 'admin'], ttl: 3600); +``` + +`iat`, `nbf` and `exp` come from the clock and `iss`/`aud` from the configuration; passing them in the +claims throws `InvalidConfigurationException`. Generate HMAC secrets with `bin2hex(random_bytes(32))`. +Refresh tokens are an application concern (issue a second token with a longer `ttl` and a `typ` claim, +and check it in your refresh endpoint). + ## Rate limiting ```php diff --git a/composer.json b/composer.json index 8c8e4ad..aa3bea0 100644 --- a/composer.json +++ b/composer.json @@ -37,23 +37,23 @@ "nyholm/psr7": "^1.8", "phpstan/phpstan": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^10.5|^11.0", + "phpunit/phpunit": "^10.5.62|^11.5.50", "roave/security-advisories": "dev-latest", - "squizlabs/php_codesniffer": "^3.7", + "squizlabs/php_codesniffer": "^3.13.6", "symfony/rate-limiter": "^6.4|^7.0|^8.0", "yiisoft/csrf": "^2.2" }, "suggest": { "bepsvpt/secure-headers": "Required by Headers\\SecurityHeadersMiddleware (^9.1)", - "firebase/php-jwt": "Required by Jwt\\JwtAuthMiddleware (^7.1.1)", + "firebase/php-jwt": "Required by Jwt\\JwtAuthMiddleware and Jwt\\JwtIssuer (^7.1.1)", "symfony/rate-limiter": "Required by RateLimit\\RateLimitMiddleware (^6.4|^7.0|^8.0)", "yiisoft/csrf": "Required by Csrf\\CsrfMiddleware (^2.2)" }, "conflict": { - "bepsvpt/secure-headers": "<9.1", - "firebase/php-jwt": "<7.1.1", - "symfony/rate-limiter": "<6.4", - "yiisoft/csrf": "<2.2" + "bepsvpt/secure-headers": "<9.1 || >=10.0", + "firebase/php-jwt": "<7.1.1 || >=8.0", + "symfony/rate-limiter": "<6.4 || >=9.0", + "yiisoft/csrf": "<2.2 || >=3.0" }, "autoload": { "psr-4": { diff --git a/src/Jwt/JwtIssuer.php b/src/Jwt/JwtIssuer.php new file mode 100644 index 0000000..974c87d --- /dev/null +++ b/src/Jwt/JwtIssuer.php @@ -0,0 +1,101 @@ +issue(['sub' => $user->id, 'role' => 'admin'], ttl: 3600); + * ``` + */ +final class JwtIssuer +{ + private const RESERVED = ['iat', 'nbf', 'exp', 'iss', 'aud']; + + /** + * @param string|OpenSSLAsymmetricKey|null $privateKey signing key for RS*, PS256, ES* and EdDSA (ignored for HS*) + */ + public function __construct( + private readonly JwtConfig $config, + #[\SensitiveParameter] + private readonly string|OpenSSLAsymmetricKey|null $privateKey = null, + ) { + if (!class_exists(JWT::class)) { + throw MissingDependencyException::forPackage(self::class, 'firebase/php-jwt'); + } + + if (!$this->isHmac() && ($privateKey === null || $privateKey === '')) { + throw new InvalidConfigurationException(sprintf( + 'JWT: %s tokens need the private key to be issued.', + $config->algorithm + )); + } + } + + /** + * Issues a signed token. + * + * `iat`, `nbf`, `exp`, `iss` and `aud` are set from the clock and the + * configuration; passing them in `$claims` is rejected. + * + * @param array $claims application claims (e.g. `sub`, roles) + * @param int $ttl lifetime in seconds + */ + public function issue(array $claims, int $ttl = 3600, ?int $now = null): string + { + if ($ttl <= 0) { + throw new InvalidConfigurationException('JWT: ttl must be positive.'); + } + + $reserved = array_intersect(array_keys($claims), self::RESERVED); + if ($reserved !== []) { + throw new InvalidConfigurationException(sprintf( + 'JWT: the claims %s are set by the issuer and cannot be passed.', + implode(', ', $reserved) + )); + } + + $now ??= time(); + $payload = $claims + ['iat' => $now, 'nbf' => $now, 'exp' => $now + $ttl]; + + if ($this->config->issuer !== null) { + $payload['iss'] = $this->config->issuer; + } + + if ($this->config->audience !== []) { + $payload['aud'] = count($this->config->audience) === 1 + ? $this->config->audience[0] + : $this->config->audience; + } + + return JWT::encode($payload, $this->signingKey(), $this->config->algorithm); + } + + private function isHmac(): bool + { + return str_starts_with($this->config->algorithm, 'HS'); + } + + private function signingKey(): string|OpenSSLAsymmetricKey|OpenSSLCertificate + { + if ($this->isHmac() || $this->privateKey === null) { + return $this->config->key; + } + + return $this->privateKey; + } +} diff --git a/tests/Cors/CorsCoreScenariosTest.php b/tests/Cors/CorsCoreScenariosTest.php new file mode 100644 index 0000000..20f91c6 --- /dev/null +++ b/tests/Cors/CorsCoreScenariosTest.php @@ -0,0 +1,219 @@ +process($request, new RecordingHandler()); + } + + private static function preflight(string $origin, string $method = 'GET', string $headers = ''): ServerRequest + { + $request = new ServerRequest('OPTIONS', '/api', [ + 'Origin' => $origin, + 'Access-Control-Request-Method' => $method, + ]); + + return $headers === '' ? $request : $request->withHeader('Access-Control-Request-Headers', $headers); + } + + /** + * Core: testCorsHeadersWithDefaultsPsr15 / testDefaultConfiguration. + * The core defaulted to "*"; here the default allows no origin (explicit opt-in). + */ + public function testDefaultConfigurationAllowsNoOrigin(): void + { + $response = $this->process( + new CorsConfig(), + new ServerRequest('GET', '/api', ['Origin' => 'https://example.com']) + ); + + self::assertSame(200, $response->getStatusCode()); + self::assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + } + + /** + * Core: testCorsWithCustomOptionsPsr15 / testCorsHeadersConsistency. + * Allow-Methods/Allow-Headers/Max-Age belong to the preflight only (not to the actual response). + */ + public function testCustomOptionsAreConsistentBetweenPreflightAndActualRequest(): void + { + $config = new CorsConfig( + allowedOrigins: ['https://trusted.com'], + allowedMethods: ['GET', 'POST'], + allowedHeaders: ['Content-Type', 'X-API-Key'], + allowCredentials: true, + maxAge: 7200, + ); + + $preflight = $this->process($config, self::preflight('https://trusted.com', 'POST', 'X-API-Key')); + self::assertSame(204, $preflight->getStatusCode()); + self::assertSame('https://trusted.com', $preflight->getHeaderLine('Access-Control-Allow-Origin')); + self::assertSame('GET, POST', $preflight->getHeaderLine('Access-Control-Allow-Methods')); + self::assertSame('content-type, x-api-key', $preflight->getHeaderLine('Access-Control-Allow-Headers')); + self::assertSame('true', $preflight->getHeaderLine('Access-Control-Allow-Credentials')); + self::assertSame('7200', $preflight->getHeaderLine('Access-Control-Max-Age')); + + $actual = $this->process($config, new ServerRequest('GET', '/api', ['Origin' => 'https://trusted.com'])); + self::assertSame('https://trusted.com', $actual->getHeaderLine('Access-Control-Allow-Origin')); + self::assertSame('true', $actual->getHeaderLine('Access-Control-Allow-Credentials')); + self::assertFalse($actual->hasHeader('Access-Control-Allow-Methods')); + self::assertFalse($actual->hasHeader('Access-Control-Max-Age')); + } + + /** + * Core: testCorsWithArrayOfOrigins / testMultipleCorsRequests. + * The core answered a disallowed origin with "Access-Control-Allow-Origin: null" (SPEC-064). + */ + public function testOriginListIsEvaluatedPerRequestWithoutLeakingState(): void + { + $config = new CorsConfig(allowedOrigins: ['https://app1.com', 'https://app2.com']); + $middleware = new CorsMiddleware(new Psr17Factory(), $config); + $handler = new RecordingHandler(); + + $first = $middleware->process(new ServerRequest('GET', '/', ['Origin' => 'https://app1.com']), $handler); + $second = $middleware->process(new ServerRequest('GET', '/', ['Origin' => 'https://app2.com']), $handler); + $denied = $middleware->process(new ServerRequest('GET', '/', ['Origin' => 'https://malicious.com']), $handler); + + self::assertSame('https://app1.com', $first->getHeaderLine('Access-Control-Allow-Origin')); + self::assertSame('https://app2.com', $second->getHeaderLine('Access-Control-Allow-Origin')); + self::assertFalse($denied->hasHeader('Access-Control-Allow-Origin')); + self::assertSame('Origin', $denied->getHeaderLine('Vary')); + } + + /** + * Core: testCorsWithCredentialsDisabled. + */ + public function testCredentialsDisabledOmitsHeader(): void + { + $config = new CorsConfig(allowedOrigins: ['https://example.com']); + + $actual = $this->process($config, new ServerRequest('GET', '/', ['Origin' => 'https://example.com'])); + $preflight = $this->process($config, self::preflight('https://example.com')); + + self::assertFalse($actual->hasHeader('Access-Control-Allow-Credentials')); + self::assertFalse($preflight->hasHeader('Access-Control-Allow-Credentials')); + } + + /** + * Core: testCorsWithCustomMethods / testCorsWithCustomHeaders (the "...AsString" variants + * do not apply: options are typed lists). + */ + public function testCustomMethodsAndHeadersRestrictThePreflight(): void + { + $config = new CorsConfig( + allowedOrigins: ['https://example.com'], + allowedMethods: ['GET', 'PATCH'], + allowedHeaders: ['X-Custom-Header'], + ); + + $patch = $this->process($config, self::preflight('https://example.com', 'PATCH')); + $delete = $this->process($config, self::preflight('https://example.com', 'DELETE')); + + self::assertSame(204, $patch->getStatusCode()); + self::assertSame(403, $delete->getStatusCode()); + self::assertSame( + 204, + $this->process($config, self::preflight('https://example.com', 'GET', 'x-custom-header'))->getStatusCode() + ); + self::assertSame( + 403, + $this->process($config, self::preflight('https://example.com', 'GET', 'Authorization'))->getStatusCode() + ); + } + + /** + * Core: testCorsWithCustomMaxAge (plus: `null` omits the header). + */ + public function testMaxAge(): void + { + $custom = $this->process( + new CorsConfig(allowedOrigins: ['https://example.com'], maxAge: 3600), + self::preflight('https://example.com') + ); + $omitted = $this->process( + new CorsConfig(allowedOrigins: ['https://example.com'], maxAge: null), + self::preflight('https://example.com') + ); + + self::assertSame('3600', $custom->getHeaderLine('Access-Control-Max-Age')); + self::assertFalse($omitted->hasHeader('Access-Control-Max-Age')); + } + + /** + * Core: testCorsWithExposeHeaders / testCorsWithoutExposeHeaders. + */ + public function testExposeHeaders(): void + { + $request = new ServerRequest('GET', '/', ['Origin' => 'https://example.com']); + + $with = $this->process( + new CorsConfig(allowedOrigins: ['https://example.com'], exposedHeaders: ['X-Total-Count', 'X-Page-Count']), + $request + ); + $without = $this->process(new CorsConfig(allowedOrigins: ['https://example.com']), $request); + + self::assertSame('X-Total-Count, X-Page-Count', $with->getHeaderLine('Access-Control-Expose-Headers')); + self::assertFalse($without->hasHeader('Access-Control-Expose-Headers')); + } + + /** + * Core: testRequestWithoutOriginHeader. + * The core added "Access-Control-Allow-Origin: *" to non-CORS requests; they now pass untouched. + */ + public function testRequestWithoutOriginHasNoCorsHeaders(): void + { + $response = $this->process(new CorsConfig(allowedOrigins: ['*']), new ServerRequest('GET', '/')); + + self::assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + } + + /** + * Core: testOptionsPreflightRequest / testOptionsPreflightRequestPsr15 / testCoreCorsMiddlewareViaUse. + * The core returned 200 for preflight; it is now 204 and never reaches the handler (SPEC-063). + */ + public function testPreflightIsAnsweredByTheMiddleware(): void + { + $handler = new RecordingHandler(); + $response = (new CorsMiddleware(new Psr17Factory(), new CorsConfig(allowedOrigins: ['*']))) + ->process(self::preflight('https://example.com', 'POST', 'Content-Type'), $handler); + + self::assertSame(204, $response->getStatusCode()); + self::assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + self::assertSame(0, $handler->calls); + } + + /** + * Core: testEdgeCases — empty configuration must not break requests. + */ + public function testEmptyConfigurationPassesRequestsThrough(): void + { + $config = new CorsConfig(allowedOrigins: [], allowedMethods: [], allowedHeaders: []); + + $actual = $this->process($config, new ServerRequest('GET', '/', ['Origin' => 'https://example.com'])); + $preflight = $this->process($config, self::preflight('https://example.com')); + + self::assertSame(200, $actual->getStatusCode()); + self::assertSame(403, $preflight->getStatusCode()); + } +} diff --git a/tests/Headers/SecurityHeadersMiddlewareTest.php b/tests/Headers/SecurityHeadersMiddlewareTest.php index 0e88de6..bede431 100644 --- a/tests/Headers/SecurityHeadersMiddlewareTest.php +++ b/tests/Headers/SecurityHeadersMiddlewareTest.php @@ -101,4 +101,33 @@ public function testInvalidReferrerPolicyIsRejected(): void new SecurityHeadersConfig(referrerPolicy: 'never'); } + + /** + * Ported from pivotphp-core tests/Middleware/Security/SecurityHeadersMiddlewareTest.php + * (testSecurityHeadersMiddlewareWithHsts) and tests/Core/SecurityHeadersMiddlewareTest.php. + * The core also asserted the deprecated X-XSS-Protection header (SPEC-075); it is not sent. + */ + public function testHstsCanBeTunedOrDisabled(): void + { + $tuned = (new SecurityHeadersMiddleware(new SecurityHeadersConfig( + hstsMaxAge: 63072000, + hstsPreload: true, + )))->headers(); + $disabled = (new SecurityHeadersMiddleware(new SecurityHeadersConfig(hsts: false)))->headers(); + + self::assertSame('max-age=63072000; includeSubDomains; preload', $tuned['Strict-Transport-Security']); + self::assertArrayNotHasKey('Strict-Transport-Security', $disabled); + self::assertArrayNotHasKey('X-XSS-Protection', $disabled); + } + + /** + * Core: testSecurityHeadersMiddlewareBasicFunctionality — status and body are untouched. + */ + public function testResponseStatusAndBodyAreUntouched(): void + { + $response = (new SecurityHeadersMiddleware())->process(new ServerRequest('GET', '/'), new RecordingHandler()); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('handler', (string) $response->getBody()); + } } diff --git a/tests/Jwt/JwtIssuerTest.php b/tests/Jwt/JwtIssuerTest.php new file mode 100644 index 0000000..87311c6 --- /dev/null +++ b/tests/Jwt/JwtIssuerTest.php @@ -0,0 +1,141 @@ +issue(['sub' => 'u1', 'role' => 'admin'], 60); + + $handler = new RecordingHandler(); + $response = (new JwtAuthMiddleware(new Psr17Factory(), $config)) + ->process(new ServerRequest('GET', '/me', ['Authorization' => 'Bearer ' . $token]), $handler); + + self::assertSame(200, $response->getStatusCode()); + $user = $handler->attribute('user'); + self::assertIsArray($user); + self::assertSame('u1', $user['sub']); + self::assertSame('admin', $user['role']); + self::assertSame('https://auth.example', $user['iss']); + self::assertSame('api', $user['aud']); + } + + public function testTimeClaimsAreSetFromTheClock(): void + { + $token = (new JwtIssuer(new JwtConfig(self::SECRET)))->issue(['sub' => 'u1'], 300, 1_000_000); + + JWT::$timestamp = 1_000_100; + try { + $claims = (array) JWT::decode($token, new Key(self::SECRET, 'HS256')); + } finally { + JWT::$timestamp = null; + } + + self::assertSame(1_000_000, $claims['iat']); + self::assertSame(1_000_000, $claims['nbf']); + self::assertSame(1_000_300, $claims['exp']); + self::assertArrayNotHasKey('iss', $claims); + self::assertArrayNotHasKey('aud', $claims); + } + + public function testExpiredIssuedTokenIsRejected(): void + { + $config = new JwtConfig(self::SECRET); + $token = (new JwtIssuer($config))->issue(['sub' => 'u1'], 60, time() - 120); + + $response = (new JwtAuthMiddleware(new Psr17Factory(), $config)) + ->process(new ServerRequest('GET', '/me', ['Authorization' => 'Bearer ' . $token]), new RecordingHandler()); + + self::assertSame(401, $response->getStatusCode()); + } + + public function testMultipleAudiencesAreIssuedAsList(): void + { + $config = new JwtConfig(self::SECRET, audience: ['web', 'api']); + $claims = (array) JWT::decode((new JwtIssuer($config))->issue(['sub' => 'u1']), new Key(self::SECRET, 'HS256')); + + self::assertSame(['web', 'api'], $claims['aud']); + } + + /** + * @return iterable + */ + public static function reservedClaims(): iterable + { + yield 'exp' => ['exp']; + yield 'iat' => ['iat']; + yield 'nbf' => ['nbf']; + yield 'iss' => ['iss']; + yield 'aud' => ['aud']; + } + + #[DataProvider('reservedClaims')] + public function testReservedClaimsCannotBeOverridden(string $claim): void + { + $this->expectException(InvalidConfigurationException::class); + + (new JwtIssuer(new JwtConfig(self::SECRET)))->issue([$claim => 'x']); + } + + public function testNonPositiveTtlIsRejected(): void + { + $this->expectException(InvalidConfigurationException::class); + + (new JwtIssuer(new JwtConfig(self::SECRET)))->issue(['sub' => 'u1'], 0); + } + + public function testAsymmetricAlgorithmRequiresPrivateKey(): void + { + $details = $this->rsaKeyPair(); + + $this->expectException(InvalidConfigurationException::class); + + new JwtIssuer(new JwtConfig($details['public'], 'RS256')); + } + + public function testRsaTokensRoundTrip(): void + { + $keys = $this->rsaKeyPair(); + $config = new JwtConfig($keys['public'], 'RS256'); + $token = (new JwtIssuer($config, $keys['private']))->issue(['sub' => 'u1']); + + $response = (new JwtAuthMiddleware(new Psr17Factory(), $config)) + ->process(new ServerRequest('GET', '/me', ['Authorization' => 'Bearer ' . $token]), new RecordingHandler()); + + self::assertSame(200, $response->getStatusCode()); + } + + /** + * @return array{public: string, private: string} + */ + private function rsaKeyPair(): array + { + $key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + self::assertNotFalse($key); + openssl_pkey_export($key, $private); + $details = openssl_pkey_get_details($key); + self::assertIsArray($details); + self::assertIsString($private); + self::assertIsString($details['key']); + + return ['public' => $details['key'], 'private' => $private]; + } +}