-
-
Notifications
You must be signed in to change notification settings - Fork 162
feat(router): add signature validation middleware and route attribute #1946
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
450b2e7
7afcdb3
16655b4
852f619
97bc7df
1c8d0ed
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Tempest\Router; | ||
|
|
||
| use Attribute; | ||
|
|
||
| /** | ||
| * Validates that the request has a valid signature. | ||
| * | ||
| * This attribute should be used on routes that require a signed URL. | ||
| * If the signature is invalid or missing, a 403 Forbidden response is returned. | ||
| * If the signature has expired (for temporary signed URLs), a 403 Forbidden response is also returned. | ||
| * | ||
| * Usage: | ||
| * ```php | ||
| * #[Get('/verify-email')] | ||
| * #[ValidSignature] | ||
| * public function verifyEmail(string $token): Response | ||
| * { | ||
| * // This code only executes if the signature is valid | ||
| * } | ||
| * ``` | ||
| * | ||
| * @see UriGenerator::createSignedUri() | ||
| * @see UriGenerator::createTemporarySignedUri() | ||
| */ | ||
| #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] | ||
| final class ValidSignature implements RouteDecorator | ||
| { | ||
| public function decorate(Route $route): Route | ||
| { | ||
| // ValidSignatureMiddleware intentionally doesn't implement HttpMiddleware to prevent | ||
| // auto-discovery as a global middleware. It follows the same callable signature | ||
| // and is invoked via HandleRouteSpecificMiddleware. | ||
| $route->middleware = [ | ||
| ...$route->middleware, | ||
| ValidSignatureMiddleware::class, | ||
| ]; | ||
|
|
||
| return $route; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Tempest\Router; | ||
|
|
||
| use Tempest\Container\Container; | ||
| use Tempest\Http\Request; | ||
| use Tempest\Http\Response; | ||
| use Tempest\Http\Responses\Forbidden; | ||
|
|
||
| /** | ||
| * Middleware that validates the signature of a signed URL. | ||
| * | ||
| * Returns 403 Forbidden if: | ||
| * - The signature is missing | ||
| * - The signature is invalid (tampered URL) | ||
| * - The signature has expired (for temporary signed URLs) | ||
| * | ||
| * Note: This class intentionally does NOT implement HttpMiddleware to prevent | ||
| * it from being auto-discovered as a global middleware. It should only run | ||
| * on routes that have the #[ValidSignature] attribute, via HandleRouteSpecificMiddleware. | ||
| */ | ||
| final readonly class ValidSignatureMiddleware | ||
| { | ||
| public function __construct( | ||
| private Container $container, | ||
|
Tresor-Kasenda marked this conversation as resolved.
Outdated
|
||
| ) {} | ||
|
|
||
| public function __invoke(Request $request, HttpMiddlewareCallable $next): Response | ||
| { | ||
| $uriGenerator = $this->container->get(UriGenerator::class); | ||
|
|
||
| if (! $uriGenerator->hasValidSignature($request)) { | ||
| return new Forbidden(); | ||
| } | ||
|
|
||
| return $next($request); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Tests\Tempest\Integration\Route\Fixtures; | ||
|
|
||
| use Tempest\Http\Response; | ||
| use Tempest\Http\Responses\Ok; | ||
| use Tempest\Router\Get; | ||
| use Tempest\Router\ValidSignature; | ||
|
|
||
| final readonly class SignedUrlController | ||
| { | ||
| #[Get('/signed-action/{token}')] | ||
| #[ValidSignature] | ||
| public function signedAction(string $token): Response | ||
| { | ||
| return new Ok(['token' => $token, 'message' => 'Signature valid']); | ||
| } | ||
|
|
||
| #[Get('/unsigned-action/{token}')] | ||
| public function unsignedAction(string $token): Response | ||
| { | ||
| return new Ok(['token' => $token]); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Tests\Tempest\Integration\Route; | ||
|
|
||
| use PHPUnit\Framework\Attributes\Test; | ||
| use Tempest\DateTime\Duration; | ||
| use Tempest\Http\Status; | ||
| use Tempest\Router\UriGenerator; | ||
| use Tests\Tempest\Integration\FrameworkIntegrationTestCase; | ||
| use Tests\Tempest\Integration\Route\Fixtures\SignedUrlController; | ||
|
|
||
| final class ValidSignatureMiddlewareTest extends FrameworkIntegrationTestCase | ||
| { | ||
| private UriGenerator $generator { | ||
| get => $this->container->get(UriGenerator::class); | ||
| } | ||
|
|
||
| protected function setUp(): void | ||
| { | ||
| parent::setUp(); | ||
|
|
||
| $this->registerRoute([SignedUrlController::class, 'signedAction']); | ||
| $this->registerRoute([SignedUrlController::class, 'unsignedAction']); | ||
| } | ||
|
Comment on lines
+20
to
+26
|
||
|
|
||
| #[Test] | ||
| public function valid_signature_allows_request(): void | ||
| { | ||
| $uri = $this->generator->createSignedUri( | ||
| action: [SignedUrlController::class, 'signedAction'], | ||
| token: 'abc123', | ||
| ); | ||
|
|
||
| $response = $this->http->get($uri); | ||
|
|
||
| $this->assertSame(Status::OK, $response->status); | ||
| $body = is_array($response->body) ? $response->body : json_decode($response->body, true); | ||
| $this->assertSame('abc123', $body['token']); | ||
| $this->assertSame('Signature valid', $body['message']); | ||
| } | ||
|
|
||
| #[Test] | ||
| public function missing_signature_returns_forbidden(): void | ||
| { | ||
| $response = $this->http->get('/signed-action/abc123'); | ||
|
|
||
| $this->assertSame(Status::FORBIDDEN, $response->status); | ||
| } | ||
|
|
||
| #[Test] | ||
| public function invalid_signature_returns_forbidden(): void | ||
| { | ||
| $uri = $this->generator->createSignedUri( | ||
| action: [SignedUrlController::class, 'signedAction'], | ||
| token: 'abc123', | ||
| ); | ||
|
|
||
| // Tamper with the signature | ||
| $tamperedUri = str_replace('signature=', 'signature=tampered', $uri); | ||
|
|
||
| $response = $this->http->get($tamperedUri); | ||
|
|
||
| $this->assertSame(Status::FORBIDDEN, $response->status); | ||
| } | ||
|
|
||
| #[Test] | ||
| public function tampered_parameter_returns_forbidden(): void | ||
| { | ||
| $uri = $this->generator->createSignedUri( | ||
| action: [SignedUrlController::class, 'signedAction'], | ||
| token: 'abc123', | ||
| ); | ||
|
|
||
| // Tamper with the token parameter | ||
| $tamperedUri = str_replace('abc123', 'tampered', $uri); | ||
|
|
||
| $response = $this->http->get($tamperedUri); | ||
|
|
||
| $this->assertSame(Status::FORBIDDEN, $response->status); | ||
| } | ||
|
|
||
| #[Test] | ||
| public function expired_signature_returns_forbidden(): void | ||
| { | ||
| $clock = $this->clock(); | ||
|
|
||
| $uri = $this->generator->createTemporarySignedUri( | ||
| action: [SignedUrlController::class, 'signedAction'], | ||
| duration: Duration::minutes(10), | ||
| token: 'abc123', | ||
| ); | ||
|
|
||
| // Advance time past expiration | ||
| $clock->plus(Duration::minutes(15)); | ||
|
|
||
| $response = $this->http->get($uri); | ||
|
|
||
| $this->assertSame(Status::FORBIDDEN, $response->status); | ||
| } | ||
|
|
||
| #[Test] | ||
| public function temporary_signature_valid_before_expiration(): void | ||
| { | ||
| $clock = $this->clock(); | ||
|
|
||
| $uri = $this->generator->createTemporarySignedUri( | ||
| action: [SignedUrlController::class, 'signedAction'], | ||
| duration: Duration::minutes(10), | ||
| token: 'abc123', | ||
| ); | ||
|
|
||
| // Advance time but stay within expiration | ||
| $clock->plus(Duration::minutes(5)); | ||
|
|
||
| $response = $this->http->get($uri); | ||
|
|
||
| $this->assertSame(Status::OK, $response->status); | ||
| } | ||
|
|
||
| #[Test] | ||
| public function unsigned_route_works_without_signature(): void | ||
| { | ||
| // Routes without #[ValidSignature] should work normally | ||
| $response = $this->http->get('/unsigned-action/abc123'); | ||
|
|
||
| $this->assertSame(Status::OK, $response->status); | ||
| } | ||
|
|
||
| #[Test] | ||
| public function tampered_expiration_returns_forbidden(): void | ||
| { | ||
| $clock = $this->clock(); | ||
|
|
||
| $uri = $this->generator->createTemporarySignedUri( | ||
| action: [SignedUrlController::class, 'signedAction'], | ||
| duration: Duration::minutes(10), | ||
| token: 'abc123', | ||
| ); | ||
|
|
||
| // Get the current timestamp and extend it in the URL | ||
| $timestamp = $clock->now()->plusMinutes(10)->getTimestamp()->getSeconds(); | ||
| $tamperedUri = str_replace( | ||
| 'expires_at=' . $timestamp, | ||
| 'expires_at=' . ($timestamp + 3600), | ||
| $uri | ||
| ); | ||
|
|
||
| $response = $this->http->get($tamperedUri); | ||
|
|
||
| $this->assertSame(Status::FORBIDDEN, $response->status); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.