diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6e28d0..59686ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - + - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php with: @@ -31,9 +31,12 @@ jobs: - name: Check for vulnerabilities uses: symfonycorp/security-checker-action@v4 - + - name: Run Easy Coding Standard - run: vendor/bin/ecs check src + run: vendor/bin/ecs check - name: Run phpstan - run: vendor/bin/phpstan analyse \ No newline at end of file + run: vendor/bin/phpstan analyse -c phpstan.neon src + + - name: Run tests + run: vendor/bin/phpunit --configuration=./tests/phpunit.xml.dist diff --git a/composer.json b/composer.json index 4c258e3..2916977 100644 --- a/composer.json +++ b/composer.json @@ -28,10 +28,16 @@ "PixelOpen\\CloudflareTurnstileBundle\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "PixelOpen\\CloudflareTurnstileBundle\\": "tests/" + } + }, "require-dev": { "phpstan/phpstan": "^1.8", "phpstan/phpstan-symfony": "^1.2", "symplify/easy-coding-standard": "^11.1", - "rector/rector": "^0.14.5" + "rector/rector": "^0.14.5", + "phpunit/phpunit": "^9.6" } } diff --git a/ecs.php b/ecs.php index 6c1b7a6..72b1003 100644 --- a/ecs.php +++ b/ecs.php @@ -7,7 +7,10 @@ use Symplify\EasyCodingStandard\ValueObject\Set\SetList; return static function (ECSConfig $ecsConfig): void { - $ecsConfig->paths([__DIR__ . '/src']); + $ecsConfig->paths([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]); $ecsConfig->sets([SetList::PSR_12]); $ecsConfig->ruleWithConfiguration(ArraySyntaxFixer::class, [ diff --git a/phpstan.neon b/phpstan.neon index 4a38a7f..93a0d64 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,12 +2,6 @@ includes: - vendor/phpstan/phpstan-symfony/extension.neon parameters: - paths: - - src level: 7 - excludes_analyse: - - %currentWorkingDirectory%/src/DependencyInjection/Configuration.php - - %currentWorkingDirectory%/vendor/* - - %currentWorkingDirectory%/Tests/* ignoreErrors: - - '#^Access to an undefined property Symfony\\Component\\Validator\\Constraint\:\:\$message\.$#' \ No newline at end of file + - '#^Access to an undefined property Symfony\\Component\\Validator\\Constraint\:\:\$message\.$#' diff --git a/src/Constraints/CloudflareTurnstileValidator.php b/src/Constraints/CloudflareTurnstileValidator.php deleted file mode 100644 index cc4cf95..0000000 --- a/src/Constraints/CloudflareTurnstileValidator.php +++ /dev/null @@ -1,83 +0,0 @@ -secret = $secret; - $this->enable = $enable; - $this->requestStack = $requestStack; - $this->httpClient = $httpClient; - } - - /** - * Checks if the passed value is valid. - * - * @param mixed $value The value that should be validated - * @param Constraint $constraint The constraint for the validation - */ - public function validate($value, Constraint $constraint): void - { - if ($this->enable) { - $request = $this->requestStack->getCurrentRequest(); - $turnstileResponse = $request->request->get('cf-turnstile-response'); - - if (empty($turnstileResponse)) { - $this->context->buildViolation($constraint->message) - ->addviolation(); - return; - } - - $response = $this->httpClient->request( - 'POST', - 'https://challenges.cloudflare.com/turnstile/v0/siteverify', - [ - 'body' => [ - 'response' => $turnstileResponse, - 'secret' => $this->secret, - ], - ] - ); - $content = $response->toArray(); - - if (! $content['success']) { - $this->context->buildViolation($constraint->message) - ->addviolation(); - return; - } - } - } -} diff --git a/src/Http/CloudflareTurnstileHttpClient.php b/src/Http/CloudflareTurnstileHttpClient.php new file mode 100644 index 0000000..d15d3a6 --- /dev/null +++ b/src/Http/CloudflareTurnstileHttpClient.php @@ -0,0 +1,58 @@ +secret = $secret; + $this->httpClient = $httpClient; + $this->logger = $logger; + } + + public function verifyResponse(string $turnstileResponse): bool + { + $response = $this->httpClient->request( + Request::METHOD_POST, + self::SITEVERIFY_ENDPOINT, + [ + 'body' => [ + 'response' => $turnstileResponse, + 'secret' => $this->secret, + ], + ] + ); + + try { + $content = $response->toArray(); + } catch (ExceptionInterface $e) { + $this->logger->error( + \sprintf( + 'Cloudflare Turnstile HTTP exception (%s) with a message: %s', + \get_class($e), + $e->getMessage(), + ), + ); + + return false; + } + + return \array_key_exists('success', $content) && $content['success'] === true; + } +} diff --git a/src/Resources/config/services.yml b/src/Resources/config/services.yml index 6b39bce..88a0d78 100644 --- a/src/Resources/config/services.yml +++ b/src/Resources/config/services.yml @@ -6,9 +6,14 @@ services: $key: '%pixelopen_cloudflare_turnstile.key%' $enable: '%pixelopen_cloudflare_turnstile.enable%' turnstile.validator: - class: PixelOpen\CloudflareTurnstileBundle\Constraints\CloudflareTurnstileValidator + class: PixelOpen\CloudflareTurnstileBundle\Validator\CloudflareTurnstileValidator tags: ['validator.constraint_validator'] arguments: - $secret: '%pixelopen_cloudflare_turnstile.secret%' $enable: '%pixelopen_cloudflare_turnstile.enable%' - autowire: true \ No newline at end of file + $turnstileHttpClient: '@turnstile.http_client' + autowire: true + turnstile.http_client: + class: PixelOpen\CloudflareTurnstileBundle\Http\CloudflareTurnstileHttpClient + arguments: + $secret: '%pixelopen_cloudflare_turnstile.secret%' + autowire: true diff --git a/src/Type/TurnstileType.php b/src/Type/TurnstileType.php index 54b453f..189c393 100644 --- a/src/Type/TurnstileType.php +++ b/src/Type/TurnstileType.php @@ -4,7 +4,7 @@ namespace PixelOpen\CloudflareTurnstileBundle\Type; -use PixelOpen\CloudflareTurnstileBundle\Constraints\CloudflareTurnstile; +use PixelOpen\CloudflareTurnstileBundle\Validator\CloudflareTurnstile; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormInterface; diff --git a/src/Constraints/CloudflareTurnstile.php b/src/Validator/CloudflareTurnstile.php similarity index 59% rename from src/Constraints/CloudflareTurnstile.php rename to src/Validator/CloudflareTurnstile.php index 80ec899..6735dc0 100644 --- a/src/Constraints/CloudflareTurnstile.php +++ b/src/Validator/CloudflareTurnstile.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace PixelOpen\CloudflareTurnstileBundle\Constraints; +namespace PixelOpen\CloudflareTurnstileBundle\Validator; use Symfony\Component\Validator\Constraint; -class CloudflareTurnstile extends Constraint +final class CloudflareTurnstile extends Constraint { /** * @var string diff --git a/src/Validator/CloudflareTurnstileValidator.php b/src/Validator/CloudflareTurnstileValidator.php new file mode 100644 index 0000000..08a099d --- /dev/null +++ b/src/Validator/CloudflareTurnstileValidator.php @@ -0,0 +1,58 @@ +enable = $enable; + $this->requestStack = $requestStack; + $this->turnstileHttpClient = $turnstileHttpClient; + } + + /** + * Checks if the passed value is valid. + * + * @param mixed $value The value that should be validated + * @param Constraint $constraint The constraint for the validation + */ + public function validate($value, Constraint $constraint): void + { + if ($this->enable === false) { + return; + } + + $request = $this->requestStack->getCurrentRequest(); + \assert($request !== null); + $turnstileResponse = (string) $request->request->get('cf-turnstile-response'); + + if ($turnstileResponse === '') { + $this->context->buildViolation($constraint->message) + ->addviolation(); + + return; + } + + if ($this->turnstileHttpClient->verifyResponse($turnstileResponse) === false) { + $this->context->buildViolation($constraint->message) + ->addviolation(); + } + } +} diff --git a/tests/Http/CloudflareTurnstileHttpClientTest.php b/tests/Http/CloudflareTurnstileHttpClientTest.php new file mode 100644 index 0000000..291a860 --- /dev/null +++ b/tests/Http/CloudflareTurnstileHttpClientTest.php @@ -0,0 +1,97 @@ +createMock(HttpClientInterface::class); + $httpClientMock + ->expects(self::once()) + ->method('request') + ->with( + 'POST', + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + [ + 'body' => [ + 'response' => 'dummy-response', + 'secret' => 'dummy-secret', + ], + ], + ) + ->willReturn(new TestResponse($responseContent)); + + $testLogger = new NullLogger(); + $turnstileHttpClient = new CloudflareTurnstileHttpClient(self::DUMMY_SECRET, $httpClientMock, $testLogger); + + $actualVerificationResult = $turnstileHttpClient->verifyResponse(self::DUMMY_TURNSTILE_RESPONSE); + + self::assertSame($expectedVerificationResult, $actualVerificationResult); + } + + public static function provideResponseContents(): iterable + { + yield 'successful verification' => [ + true, [ + 'success' => true, + ]]; + yield 'failed verification' => [ + false, [ + 'success' => false, + ]]; + yield 'invalid response format' => [ + false, [ + 'success' => 'true', + ]]; + yield 'invalid response format 2' => [ + false, [ + 'success' => null, + ]]; + yield 'invalid response - missing success field' => [ + false, [ + 'verified' => true, + ]]; + } + + public function testShouldFailVerificationWhenHttpExceptionThrown(): void + { + $httpClientMock = $this->createMock(HttpClientInterface::class); + $httpClientMock + ->expects(self::once()) + ->method('request') + ->with( + 'POST', + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + [ + 'body' => [ + 'response' => 'dummy-response', + 'secret' => 'dummy-secret', + ], + ], + ) + ->willReturn(new TestResponse([ + 'success' => true, + ], 'throwException')); + + $testLogger = new NullLogger(); + $turnstileHttpClient = new CloudflareTurnstileHttpClient(self::DUMMY_SECRET, $httpClientMock, $testLogger); + + $verificationResult = $turnstileHttpClient->verifyResponse(self::DUMMY_TURNSTILE_RESPONSE); + + self::assertFalse($verificationResult); + } +} diff --git a/tests/Http/TestResponse.php b/tests/Http/TestResponse.php new file mode 100644 index 0000000..931d691 --- /dev/null +++ b/tests/Http/TestResponse.php @@ -0,0 +1,55 @@ +content = $content; + $this->contentAsArray = $contentAsArray; + } + + public function getStatusCode(): int + { + return 200; + } + + public function getHeaders(bool $throw = true): array + { + return []; + } + + public function getContent(bool $throw = true): string + { + return $this->content; + } + + public function cancel(): void + { + // do nothing + } + + public function getInfo(?string $type = null): mixed + { + return []; + } + + public function toArray(bool $throw = true): array + { + if ($throw && $this->content === 'throwException') { + throw new JsonException('test error'); + } + + return $this->contentAsArray; + } +} diff --git a/tests/phpunit.xml.dist b/tests/phpunit.xml.dist new file mode 100644 index 0000000..4a540a5 --- /dev/null +++ b/tests/phpunit.xml.dist @@ -0,0 +1,18 @@ + + + + + + ./ + + +