diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4a67df9..8c20d16 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,21 +16,17 @@ jobs: matrix: php: [ 8.1, 8.2, 8.3, 8.4 ] laravel: [ 8, 9, 10, 11 ] - stability: [ 'prefer-lowest', 'prefer-stable' ] - include: - - laravel: 8.* - testbench: ^8.20 - - laravel: 9.* - testbench: ^8.20 - - laravel: 10.* - testbench: ^8.20 - - laravel: 11.* - testbench: ^9.0 exclude: - php: 8.1 - laravel: 11.* + laravel: 11 + - php: 8.4 + laravel: 8 + - php: 8.4 + laravel: 9 + - php: 8.4 + laravel: 10 - name: PHP ${{ matrix.php }} L${{ matrix.laravel }} w/ ${{ matrix.stability }} + name: PHP ${{ matrix.php }} L${{ matrix.laravel }} steps: - name: Checkout code uses: actions/checkout@v3 @@ -49,8 +45,7 @@ jobs: with: timeout_minutes: 5 max_attempts: 5 - command: | - composer update --prefer-dist --no-interaction --no-progress --${{ matrix.stability }} + command: composer update --prefer-dist --no-interaction --no-progress --prefer-stable - name: Execute tests - run: vendor/bin/phpunit --verbose + run: vendor/bin/phpunit diff --git a/composer.json b/composer.json index e6f4169..d307a28 100644 --- a/composer.json +++ b/composer.json @@ -9,13 +9,13 @@ }, "require": { "php": "~8.1.0|~8.2.0|~8.3.0|~8.4.0", - "illuminate/http": "^8.83|^9.33|^10.0|^11.0", - "illuminate/support": "^8.83|^9.33|^10.0|^11.0" + "illuminate/http": "^8.83|^9.33|^10.0|^11.3", + "illuminate/support": "^8.83|^9.33|^10.0|^11.3" }, "require-dev": { - "nunomaduro/larastan": "^2.0", - "orchestra/testbench": "^7.0|^8.0|^9.0", - "phpunit/phpunit": "^9.5.10", + "nunomaduro/larastan": "^1.0|^2.0", + "orchestra/testbench": "^6.0|^7.0|^8.2|^9.0", + "phpunit/phpunit": "^9.5.10|^10.1|^11.3.6", "roave/security-advisories": "dev-latest" }, "autoload": { @@ -34,6 +34,13 @@ "config": { "sort-packages": true }, + "extra": { + "laravel": { + "providers": [ + "LemonSqueezy\\PlainUiComponents\\PlainUiComponentsServiceProvider" + ] + } + }, "minimum-stability": "dev", "prefer-stable": true } diff --git a/config/plain.php b/config/plain.php new file mode 100644 index 0000000..571511c --- /dev/null +++ b/config/plain.php @@ -0,0 +1,13 @@ +<?php + +return [ + + /** + * Your Plain workspace's global HMAC secret. + * + * This secret can be viewed and (re)generated by Plain workspace admins in Settings → Request signing. + * This will be used to verify that request were made by Plain, and not a third party. + */ + 'secret' => env('PLAIN_SECRET'), + +]; diff --git a/src/PlainUiComponentsServiceProvider.php b/src/PlainUiComponentsServiceProvider.php new file mode 100644 index 0000000..d7b84b8 --- /dev/null +++ b/src/PlainUiComponentsServiceProvider.php @@ -0,0 +1,32 @@ +<?php + +namespace LemonSqueezy\PlainUiComponents; + +use Illuminate\Support\ServiceProvider; + +class PlainUiComponentsServiceProvider extends ServiceProvider +{ + /** + * Register the service provider. + * + * @return void + */ + public function register() + { + $this->mergeConfigFrom(__DIR__.'/../config/plain.php', 'plain'); + } + + /** + * Bootstrap the application services. + * + * @return void + */ + public function boot() + { + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__.'/../config/plain.php' => config_path('plain.php'), + ]); + } + } +} diff --git a/src/VerifyPlainSignatureMiddleware.php b/src/VerifyPlainSignatureMiddleware.php new file mode 100644 index 0000000..8ee182a --- /dev/null +++ b/src/VerifyPlainSignatureMiddleware.php @@ -0,0 +1,23 @@ +<?php + +namespace LemonSqueezy\PlainUiComponents; + +use Closure; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Config; +use Symfony\Component\HttpFoundation\Response; + +class VerifyPlainSignatureMiddleware +{ + /** + * Handle the incoming request. + */ + public function handle(Request $request, Closure $next): Response + { + abort_if(empty($signature = $request->header('plain-request-signature')), 400, 'Missing webhook signature.'); + abort_if(is_null($secret = Config::get('plain.secret')), 403, 'No webhook secret configured.'); + abort_unless(hash_equals(hash_hmac('sha256', $request->getContent(), $secret), $signature), 403, 'Invalid signature.'); + + return $next($request); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 3040af9..2c225b9 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,7 +2,14 @@ namespace LemonSqueezy\PlainUiComponents\Tests; -class TestCase extends \PHPUnit\Framework\TestCase +use LemonSqueezy\PlainUiComponents\PlainUiComponentsServiceProvider; + +class TestCase extends \Orchestra\Testbench\TestCase { - // + protected function getPackageProviders($app): array + { + return [ + PlainUiComponentsServiceProvider::class, + ]; + } } diff --git a/tests/VerifyPlainSignatureMiddlewareTest.php b/tests/VerifyPlainSignatureMiddlewareTest.php new file mode 100644 index 0000000..5e03a33 --- /dev/null +++ b/tests/VerifyPlainSignatureMiddlewareTest.php @@ -0,0 +1,80 @@ +<?php + +namespace LemonSqueezy\PlainUiComponents\Tests; + +use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Route; +use LemonSqueezy\PlainUiComponents\VerifyPlainSignatureMiddleware; + +class VerifyPlainSignatureMiddlewareTest extends TestCase +{ + /** @test */ + public function it_blocks_the_request_when_the_plain_signature_header_is_not_provided(): void + { + Config::set('plain.secret', 'my-plain-secret'); + + $called = false; + Route::middleware(VerifyPlainSignatureMiddleware::class)->post('/', function () use (&$called) { + $called = true; + }); + + $response = $this->post('/', ['example' => 'content']); + + $this->assertFalse($called); + $response->assertStatus(400); + } + + /** @test */ + public function it_blocks_the_request_when_the_middleware_is_applied_without_a_secret_being_configured(): void + { + Config::set('plain.secret', ''); + + $called = false; + Route::middleware(VerifyPlainSignatureMiddleware::class)->post('/', function () use (&$called) { + $called = true; + }); + + $response = $this->post('/', ['example' => 'content'], [ + 'plain-request-signature' => 'example-signature', + ]); + + $this->assertFalse($called); + $response->assertStatus(403); + } + + /** @test */ + public function it_blocks_the_request_when_the_plain_signature_header_does_not_match_the_configured_secret(): void + { + Config::set('plain.secret', 'my-plain-secret'); + + $called = false; + Route::middleware(VerifyPlainSignatureMiddleware::class)->post('/', function () use (&$called) { + $called = true; + }); + + $response = $this->post('/', ['example' => 'content'], [ + 'plain-request-signature' => 'example-signature', + ]); + + $this->assertFalse($called); + $response->assertStatus(403); + } + + /** @test */ + public function it_allows_the_request_when_the_plain_signature_header_matches_the_configured_secret(): void + { + Config::set('plain.secret', 'my-plain-secret'); + + $called = false; + Route::middleware(VerifyPlainSignatureMiddleware::class)->post('/', function () use (&$called) { + $called = true; + }); + + $response = $this->post('/', ['example' => 'content'], [ + 'plain-request-signature' => 'e85ab1c2f80714be422adfc9f446f9c48a018c971df12527fba9b2a2819cd17c', + ]); + + $this->assertTrue($called); + $response->assertStatus(200); + } +}