diff --git a/.github/workflows/code_checks.yaml b/.github/workflows/code_checks.yaml index 65df556..c369cde 100644 --- a/.github/workflows/code_checks.yaml +++ b/.github/workflows/code_checks.yaml @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: ['7.1', '7.2', '7.4', '8.0', '8.1', '8.2', '8.3'] + php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] symfony: ['^3.4', '^4.0', '^5.0', '^6.0', '^7.0'] exclude: - symfony: ^3.4 @@ -52,26 +52,18 @@ jobs: php: 8.2 - symfony: ^3.4 php: 8.3 + - symfony: ^3.4 + php: 8.4 - symfony: ^4.0 php: 8.1 - symfony: ^4.0 php: 8.2 - symfony: ^4.0 php: 8.3 - - symfony: ^5.0 - php: 7.1 - - symfony: ^6.0 - php: 7.1 - - symfony: ^6.0 - php: 7.2 + - symfony: ^4.0 + php: 8.4 - symfony: ^6.0 php: 7.4 - - symfony: ^6.0 - php: 8.3 - - symfony: ^7.0 - php: 7.1 - - symfony: ^7.0 - php: 7.2 - symfony: ^7.0 php: 7.4 - symfony: ^7.0 diff --git a/README.md b/README.md index 87f9638..20509be 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,7 @@ With [composer](https://getcomposer.org), require: `composer require karser/karser-recaptcha3-bundle` -You can quickly configure this bundle by using symfony/flex: -- answer **no** for `google/recaptcha` -- answer **yes** for `karser/karser-recaptcha3-bundle` -![image](https://user-images.githubusercontent.com/1675033/73133604-d5a39a00-4033-11ea-9ef1-0fed12a8763b.png) +You can quickly configure this bundle by using symfony/flex. Configuration without symfony/flex: -------------------- @@ -265,7 +262,7 @@ App\Services\YourService: ```php #App/Services/YourService.php -use ReCaptcha\ReCaptcha; +use Karser\Recaptcha3Bundle\ReCaptcha\ReCaptcha; class YourService { private $reCaptcha; diff --git a/ReCaptcha/ReCaptcha.php b/ReCaptcha/ReCaptcha.php new file mode 100644 index 0000000..ef68021 --- /dev/null +++ b/ReCaptcha/ReCaptcha.php @@ -0,0 +1,274 @@ +secret = $secret; + $this->requestMethod = (is_null($requestMethod)) ? new RequestMethod\Post() : $requestMethod; + } + + /** + * Calls the reCAPTCHA siteverify API to verify whether the user passes + * CAPTCHA test and additionally runs any specified additional checks + * + * @param string $response The user response token provided by reCAPTCHA, verifying the user on your site. + * @param string $remoteIp The end user's IP address. + * @return Response Response from the service. + */ + public function verify(string $response, ?string $remoteIp = null) + { + // Discard empty solution submissions + if ('' === $response) { + $recaptchaResponse = new Response(false, array(self::E_MISSING_INPUT_RESPONSE)); + return $recaptchaResponse; + } + + $params = new RequestParameters($this->secret, $response, $remoteIp, self::VERSION); + $rawResponse = $this->requestMethod->submit($params); + $initialResponse = Response::fromJson($rawResponse); + $validationErrors = array(); + + if (isset($this->hostname) && strcasecmp($this->hostname, $initialResponse->getHostname()) !== 0) { + $validationErrors[] = self::E_HOSTNAME_MISMATCH; + } + + if (isset($this->apkPackageName) && strcasecmp($this->apkPackageName, $initialResponse->getApkPackageName()) !== 0) { + $validationErrors[] = self::E_APK_PACKAGE_NAME_MISMATCH; + } + + if (isset($this->action) && strcasecmp($this->action, $initialResponse->getAction()) !== 0) { + $validationErrors[] = self::E_ACTION_MISMATCH; + } + + if (isset($this->threshold) && $this->threshold > $initialResponse->getScore()) { + $validationErrors[] = self::E_SCORE_THRESHOLD_NOT_MET; + } + + if (isset($this->timeoutSeconds)) { + $challengeTs = strtotime($initialResponse->getChallengeTs()); + + if ($challengeTs > 0 && time() - $challengeTs > $this->timeoutSeconds) { + $validationErrors[] = self::E_CHALLENGE_TIMEOUT; + } + } + + if ([] === $validationErrors) { + return $initialResponse; + } + + return new Response( + false, + array_merge($initialResponse->getErrorCodes(), $validationErrors), + $initialResponse->getHostname(), + $initialResponse->getChallengeTs(), + $initialResponse->getApkPackageName(), + $initialResponse->getScore(), + $initialResponse->getAction() + ); + } + + /** + * Provide a hostname to match against in verify() + * This should be without a protocol or trailing slash, e.g. www.google.com + * + * @param string $hostname Expected hostname + * @return ReCaptcha Current instance for fluent interface + */ + public function setExpectedHostname($hostname) + { + $this->hostname = $hostname; + return $this; + } + + /** + * Provide an APK package name to match against in verify() + * + * @param string $apkPackageName Expected APK package name + * @return ReCaptcha Current instance for fluent interface + */ + public function setExpectedApkPackageName($apkPackageName) + { + $this->apkPackageName = $apkPackageName; + return $this; + } + + /** + * Provide an action to match against in verify() + * This should be set per page. + * + * @param string $action Expected action + * @return ReCaptcha Current instance for fluent interface + */ + public function setExpectedAction($action) + { + $this->action = $action; + return $this; + } + + /** + * Provide a threshold to meet or exceed in verify() + * Threshold should be a float between 0 and 1 which will be tested as response >= threshold. + * + * @param float $threshold Expected threshold + * @return ReCaptcha Current instance for fluent interface + */ + public function setScoreThreshold($threshold) + { + $this->threshold = floatval($threshold); + return $this; + } + + /** + * Provide a timeout in seconds to test against the challenge timestamp in verify() + * + * @param int $timeoutSeconds Expected hostname + * @return ReCaptcha Current instance for fluent interface + */ + public function setChallengeTimeout($timeoutSeconds) + { + $this->timeoutSeconds = $timeoutSeconds; + return $this; + } +} diff --git a/ReCaptcha/RequestMethod.php b/ReCaptcha/RequestMethod.php new file mode 100644 index 0000000..e633a0d --- /dev/null +++ b/ReCaptcha/RequestMethod.php @@ -0,0 +1,49 @@ + $options + * @return bool + */ + public function setoptArray($ch, array $options): bool + { + return curl_setopt_array($ch, $options); + } + + /** + * @see http://php.net/curl_exec + * @param \CurlHandle|resource $ch + * @return string|bool + */ + public function exec($ch) + { + return curl_exec($ch); + } + + /** + * @see http://php.net/curl_close + * @param \CurlHandle|resource $ch + * @return void + */ + public function close($ch): void + { + curl_close($ch); + } +} \ No newline at end of file diff --git a/ReCaptcha/RequestMethod/CurlPost.php b/ReCaptcha/RequestMethod/CurlPost.php new file mode 100644 index 0000000..38c4914 --- /dev/null +++ b/ReCaptcha/RequestMethod/CurlPost.php @@ -0,0 +1,104 @@ +curl = (is_null($curl)) ? new Curl() : $curl; + $this->siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : $siteVerifyUrl; + } + + /** + * Submit the cURL request with the specified parameters. + * + * @param RequestParameters $params Request parameters + * @return string Body of the reCAPTCHA response + */ + public function submit(RequestParameters $params) + { + $handle = $this->curl->init($this->siteVerifyUrl); + + $options = array( + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $params->toQueryString(), + CURLOPT_HTTPHEADER => array( + 'Content-Type: application/x-www-form-urlencoded' + ), + CURLINFO_HEADER_OUT => false, + CURLOPT_HEADER => false, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_SSL_VERIFYPEER => true + ); + $this->curl->setoptArray($handle, $options); + + $response = $this->curl->exec($handle); + $this->curl->close($handle); + + if ($response !== false) { + return $response; + } + + return '{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}'; + } +} diff --git a/ReCaptcha/RequestMethod/Post.php b/ReCaptcha/RequestMethod/Post.php new file mode 100644 index 0000000..6b3a401 --- /dev/null +++ b/ReCaptcha/RequestMethod/Post.php @@ -0,0 +1,88 @@ +siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : $siteVerifyUrl; + } + + /** + * Submit the POST request with the specified parameters. + * + * @param RequestParameters $params Request parameters + * @return string Body of the reCAPTCHA response + */ + public function submit(RequestParameters $params) + { + $options = array( + 'http' => array( + 'header' => "Content-type: application/x-www-form-urlencoded\r\n", + 'method' => 'POST', + 'content' => $params->toQueryString(), + // Force the peer to validate (not needed in 5.6.0+, but still works) + 'verify_peer' => true, + ), + ); + $context = stream_context_create($options); + $response = file_get_contents($this->siteVerifyUrl, false, $context); + + if ($response !== false) { + return $response; + } + + return '{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}'; + } +} diff --git a/ReCaptcha/RequestMethod/Socket.php b/ReCaptcha/RequestMethod/Socket.php new file mode 100644 index 0000000..142cd16 --- /dev/null +++ b/ReCaptcha/RequestMethod/Socket.php @@ -0,0 +1,112 @@ +handle = fsockopen($hostname, $port, $errno, $errstr, (is_null($timeout) ? ini_get("default_socket_timeout") : $timeout)); + + if ($this->handle != false && $errno === 0 && $errstr === '') { + return $this->handle; + } + return null; + } + + /** + * fwrite + * + * @see http://php.net/fwrite + * @param string $string + * @param int $length + * @return int | bool + */ + public function fwrite($string, $length = null) + { + return fwrite($this->handle, $string, (is_null($length) ? strlen($string) : $length)); + } + + /** + * fgets + * + * @see http://php.net/fgets + * @param int $length + * @return string + */ + public function fgets($length = null) + { + return fgets($this->handle, $length); + } + + /** + * feof + * + * @see http://php.net/feof + * @return bool + */ + public function feof() + { + return feof($this->handle); + } + + /** + * fclose + * + * @see http://php.net/fclose + * @return bool + */ + public function fclose() + { + return fclose($this->handle); + } +} diff --git a/ReCaptcha/RequestMethod/SocketPost.php b/ReCaptcha/RequestMethod/SocketPost.php new file mode 100644 index 0000000..6b37973 --- /dev/null +++ b/ReCaptcha/RequestMethod/SocketPost.php @@ -0,0 +1,110 @@ +socket = (is_null($socket)) ? new Socket() : $socket; + $this->siteVerifyUrl = (is_null($siteVerifyUrl)) ? ReCaptcha::SITE_VERIFY_URL : $siteVerifyUrl; + } + + /** + * Submit the POST request with the specified parameters. + * + * @param RequestParameters $params Request parameters + * @return string Body of the reCAPTCHA response + */ + public function submit(RequestParameters $params) + { + $errno = 0; + $errstr = ''; + $urlParsed = parse_url($this->siteVerifyUrl); + + if (null === $this->socket->fsockopen('ssl://' . $urlParsed['host'], 443, $errno, $errstr, 30)) { + return '{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}'; + } + + $content = $params->toQueryString(); + + $request = "POST " . $urlParsed['path'] . " HTTP/1.0\r\n"; + $request .= "Host: " . $urlParsed['host'] . "\r\n"; + $request .= "Content-Type: application/x-www-form-urlencoded\r\n"; + $request .= "Content-length: " . strlen($content) . "\r\n"; + $request .= "Connection: close\r\n\r\n"; + $request .= $content . "\r\n\r\n"; + + $this->socket->fwrite($request); + $response = ''; + + while (!$this->socket->feof()) { + $response .= $this->socket->fgets(4096); + } + + $this->socket->fclose(); + + if (0 !== strpos($response, 'HTTP/1.0 200 OK')) { + return '{"success": false, "error-codes": ["'.ReCaptcha::E_BAD_RESPONSE.'"]}'; + } + + $parts = preg_split("#\n\s*\n#Uis", $response); + + return $parts[1]; + } +} diff --git a/ReCaptcha/RequestParameters.php b/ReCaptcha/RequestParameters.php new file mode 100644 index 0000000..fe338a6 --- /dev/null +++ b/ReCaptcha/RequestParameters.php @@ -0,0 +1,111 @@ +secret = $secret; + $this->response = $response; + $this->remoteIp = $remoteIp; + $this->version = $version; + } + + /** + * Array representation. + * + * @return array Array formatted parameters. + */ + public function toArray() + { + $params = array('secret' => $this->secret, 'response' => $this->response); + + if (!is_null($this->remoteIp)) { + $params['remoteip'] = $this->remoteIp; + } + + if (!is_null($this->version)) { + $params['version'] = $this->version; + } + + return $params; + } + + /** + * Query string representation for HTTP request. + * + * @return string Query string formatted parameters. + */ + public function toQueryString() + { + return http_build_query($this->toArray(), '', '&'); + } +} diff --git a/ReCaptcha/Response.php b/ReCaptcha/Response.php new file mode 100644 index 0000000..8a8a261 --- /dev/null +++ b/ReCaptcha/Response.php @@ -0,0 +1,219 @@ +success = $success; + $this->hostname = $hostname; + $this->challengeTs = $challengeTs; + $this->apkPackageName = $apkPackageName; + $this->score = $score; + $this->action = $action; + $this->errorCodes = $errorCodes; + } + + /** + * Is success? + * + * @return boolean + */ + public function isSuccess() + { + return $this->success; + } + + /** + * Get error codes. + * + * @return array + */ + public function getErrorCodes() + { + return $this->errorCodes; + } + + /** + * Get hostname. + * + * @return string + */ + public function getHostname() + { + return $this->hostname; + } + + /** + * Get challenge timestamp + * + * @return string + */ + public function getChallengeTs() + { + return $this->challengeTs; + } + + /** + * Get APK package name + * + * @return string + */ + public function getApkPackageName() + { + return $this->apkPackageName; + } + /** + * Get score + * + * @return float + */ + public function getScore() + { + return $this->score; + } + + /** + * Get action + * + * @return string + */ + public function getAction() + { + return $this->action; + } + + public function toArray() + { + return array( + 'success' => $this->isSuccess(), + 'hostname' => $this->getHostname(), + 'challenge_ts' => $this->getChallengeTs(), + 'apk_package_name' => $this->getApkPackageName(), + 'score' => $this->getScore(), + 'action' => $this->getAction(), + 'error-codes' => $this->getErrorCodes(), + ); + } +} diff --git a/RequestMethod/SymfonyHttpClient.php b/RequestMethod/SymfonyHttpClient.php index 1b7f252..522f3ff 100644 --- a/RequestMethod/SymfonyHttpClient.php +++ b/RequestMethod/SymfonyHttpClient.php @@ -4,9 +4,9 @@ namespace Karser\Recaptcha3Bundle\RequestMethod; -use ReCaptcha\ReCaptcha; -use ReCaptcha\RequestMethod; -use ReCaptcha\RequestParameters; +use Karser\Recaptcha3Bundle\ReCaptcha\ReCaptcha; +use Karser\Recaptcha3Bundle\ReCaptcha\RequestMethod; +use Karser\Recaptcha3Bundle\ReCaptcha\RequestParameters; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; diff --git a/Resources/config/services.php b/Resources/config/services.php index 8d4f8f1..61a52dc 100644 --- a/Resources/config/services.php +++ b/Resources/config/services.php @@ -7,9 +7,9 @@ use Karser\Recaptcha3Bundle\Services\IpResolver; use Karser\Recaptcha3Bundle\RequestMethod\SymfonyHttpClient; use Karser\Recaptcha3Bundle\Validator\Constraints\Recaptcha3Validator; -use ReCaptcha\ReCaptcha; -use ReCaptcha\RequestMethod\Curl; -use ReCaptcha\RequestMethod\CurlPost; +use Karser\Recaptcha3Bundle\ReCaptcha\ReCaptcha; +use Karser\Recaptcha3Bundle\ReCaptcha\RequestMethod\Curl; +use Karser\Recaptcha3Bundle\ReCaptcha\RequestMethod\CurlPost; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\DependencyInjection\Loader\Configurator\ReferenceConfigurator; use Symfony\Component\ExpressionLanguage\Expression; diff --git a/Services/HostProvider.php b/Services/HostProvider.php index 3d3a099..4828e53 100644 --- a/Services/HostProvider.php +++ b/Services/HostProvider.php @@ -4,7 +4,7 @@ namespace Karser\Recaptcha3Bundle\Services; -use ReCaptcha\ReCaptcha; +use Karser\Recaptcha3Bundle\ReCaptcha\ReCaptcha; final class HostProvider implements HostProviderInterface { diff --git a/Tests/ReCaptcha/ReCaptchaTest.php b/Tests/ReCaptcha/ReCaptchaTest.php new file mode 100644 index 0000000..f38ced4 --- /dev/null +++ b/Tests/ReCaptcha/ReCaptchaTest.php @@ -0,0 +1,199 @@ +expectException(\RuntimeException::class); + new ReCaptcha($invalid); + } + + /** + * @return array> + */ + public static function invalidSecretProvider(): array + { + return [ + [''], + [null], + ]; + } + + public function testVerifyReturnsErrorOnMissingResponse(): void + { + $rc = new ReCaptcha('secret'); + $response = $rc->verify(''); + self::assertFalse($response->isSuccess()); + self::assertEquals([ReCaptcha::E_MISSING_INPUT_RESPONSE], $response->getErrorCodes()); + } + + private function getMockRequestMethod(string $responseJson): RequestMethod + { + $method = $this->getMockBuilder(RequestMethod::class) + ->disableOriginalConstructor() + ->getMock(); + $method->expects(self::any()) + ->method('submit') + ->with(self::callback(function ($params) { + return true; + })) + ->willReturn($responseJson); + + return $method; + } + + public function testVerifyReturnsResponse(): void + { + $method = $this->getMockRequestMethod('{"success": true}'); + $rc = new ReCaptcha('secret', $method); + $response = $rc->verify('response'); + self::assertTrue($response->isSuccess()); + } + + public function testVerifyReturnsInitialResponseWithoutAdditionalChecks(): void + { + $method = $this->getMockRequestMethod('{"success": true}'); + $rc = new ReCaptcha('secret', $method); + $initialResponse = $rc->verify('response'); + self::assertEquals($initialResponse, $rc->verify('response')); + } + + public function testVerifyHostnameMatch(): void + { + $method = $this->getMockRequestMethod('{"success": true, "hostname": "host.name"}'); + $rc = new ReCaptcha('secret', $method); + $response = $rc->setExpectedHostname('host.name')->verify('response'); + self::assertTrue($response->isSuccess()); + } + + public function testVerifyHostnameMisMatch(): void + { + $method = $this->getMockRequestMethod('{"success": true, "hostname": "host.NOTname"}'); + $rc = new ReCaptcha('secret', $method); + $response = $rc->setExpectedHostname('host.name')->verify('response'); + self::assertFalse($response->isSuccess()); + self::assertEquals([ReCaptcha::E_HOSTNAME_MISMATCH], $response->getErrorCodes()); + } + + public function testVerifyApkPackageNameMatch(): void + { + $method = $this->getMockRequestMethod('{"success": true, "apk_package_name": "apk.name"}'); + $rc = new ReCaptcha('secret', $method); + $response = $rc->setExpectedApkPackageName('apk.name')->verify('response'); + self::assertTrue($response->isSuccess()); + } + + public function testVerifyApkPackageNameMisMatch(): void + { + $method = $this->getMockRequestMethod('{"success": true, "apk_package_name": "apk.NOTname"}'); + $rc = new ReCaptcha('secret', $method); + $response = $rc->setExpectedApkPackageName('apk.name')->verify('response'); + self::assertFalse($response->isSuccess()); + self::assertEquals([ReCaptcha::E_APK_PACKAGE_NAME_MISMATCH], $response->getErrorCodes()); + } + + public function testVerifyActionMatch(): void + { + $method = $this->getMockRequestMethod('{"success": true, "action": "action/name"}'); + $rc = new ReCaptcha('secret', $method); + $response = $rc->setExpectedAction('action/name')->verify('response'); + self::assertTrue($response->isSuccess()); + } + + public function testVerifyActionMisMatch(): void + { + $method = $this->getMockRequestMethod('{"success": true, "action": "action/NOTname"}'); + $rc = new ReCaptcha('secret', $method); + $response = $rc->setExpectedAction('action/name')->verify('response'); + self::assertFalse($response->isSuccess()); + self::assertEquals([ReCaptcha::E_ACTION_MISMATCH], $response->getErrorCodes()); + } + + public function testVerifyAboveThreshold(): void + { + $method = $this->getMockRequestMethod('{"success": true, "score": "0.9"}'); + $rc = new ReCaptcha('secret', $method); + $response = $rc->setScoreThreshold(0.5)->verify('response'); + self::assertTrue($response->isSuccess()); + } + + public function testVerifyBelowThreshold(): void + { + $method = $this->getMockRequestMethod('{"success": true, "score": "0.1"}'); + $rc = new ReCaptcha('secret', $method); + $response = $rc->setScoreThreshold(0.5)->verify('response'); + self::assertFalse($response->isSuccess()); + self::assertEquals([ReCaptcha::E_SCORE_THRESHOLD_NOT_MET], $response->getErrorCodes()); + } + + public function testVerifyWithinTimeout(): void + { + $challengeTs = date('Y-M-d\TH:i:s\Z', time()); + $method = $this->getMockRequestMethod('{"success": true, "challenge_ts": "'.$challengeTs.'"}'); + $rc = new ReCaptcha('secret', $method); + $response = $rc->setChallengeTimeout(1000)->verify('response'); + self::assertTrue($response->isSuccess()); + } + + public function testVerifyOverTimeout(): void + { + // Responses come back like 2018-07-31T13:48:41Z + $challengeTs = date('Y-M-d\TH:i:s\Z', time() - 600); + $method = $this->getMockRequestMethod('{"success": true, "challenge_ts": "'.$challengeTs.'"}'); + $rc = new ReCaptcha('secret', $method); + $response = $rc->setChallengeTimeout(60)->verify('response'); + self::assertFalse($response->isSuccess()); + self::assertEquals([ReCaptcha::E_CHALLENGE_TIMEOUT], $response->getErrorCodes()); + } + + public function testVerifyMergesErrors(): void + { + $method = $this->getMockRequestMethod('{"success": false, "error-codes": ["initial-error"], "score": "0.1"}'); + $rc = new ReCaptcha('secret', $method); + $response = $rc->setScoreThreshold(0.5)->verify('response'); + self::assertFalse($response->isSuccess()); + self::assertEquals(['initial-error', ReCaptcha::E_SCORE_THRESHOLD_NOT_MET], $response->getErrorCodes()); + } +} \ No newline at end of file diff --git a/Tests/ReCaptcha/RequestMethod/CurlPostTest.php b/Tests/ReCaptcha/RequestMethod/CurlPostTest.php new file mode 100644 index 0000000..25daefe --- /dev/null +++ b/Tests/ReCaptcha/RequestMethod/CurlPostTest.php @@ -0,0 +1,135 @@ +getMockBuilder(Curl::class) + ->disableOriginalConstructor() + ->getMock(); + + $curl->expects(self::once()) + ->method('init') + ->willReturn(new \stdClass()); + + $curl->expects(self::once()) + ->method('setoptArray') + ->willReturn(true); + + $curl->expects(self::once()) + ->method('exec') + ->willReturn('RESPONSEBODY'); + + $curl->expects(self::once()) + ->method('close'); + + $pc = new CurlPost($curl); + $response = $pc->submit(new RequestParameters("secret", "response")); + self::assertEquals('RESPONSEBODY', $response); + } + + public function testOverrideSiteVerifyUrl(): void + { + $url = 'OVERRIDE'; + + $curl = $this->getMockBuilder(Curl::class) + ->disableOriginalConstructor() + ->getMock(); + + $curl->expects(self::once()) + ->method('init') + ->with($url) + ->willReturn(new \stdClass()); + + $curl->expects(self::once()) + ->method('setoptArray') + ->willReturn(true); + + $curl->expects(self::once()) + ->method('exec') + ->willReturn('RESPONSEBODY'); + + $curl->expects(self::once()) + ->method('close'); + + $pc = new CurlPost($curl, $url); + $response = $pc->submit(new RequestParameters("secret", "response")); + self::assertEquals('RESPONSEBODY', $response); + } + + public function testConnectionFailureReturnsError(): void + { + $curl = $this->getMockBuilder(Curl::class) + ->disableOriginalConstructor() + ->getMock(); + + $curl->expects(self::once()) + ->method('init') + ->willReturn(new \stdClass()); + + $curl->expects(self::once()) + ->method('setoptArray') + ->willReturn(true); + + $curl->expects(self::once()) + ->method('exec') + ->willReturn(false); + + $curl->expects(self::once()) + ->method('close'); + + $pc = new CurlPost($curl); + $response = $pc->submit(new RequestParameters("secret", "response")); + self::assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}', $response); + } +} \ No newline at end of file diff --git a/Tests/ReCaptcha/RequestMethod/PostTest.php b/Tests/ReCaptcha/RequestMethod/PostTest.php new file mode 100644 index 0000000..f28157a --- /dev/null +++ b/Tests/ReCaptcha/RequestMethod/PostTest.php @@ -0,0 +1,150 @@ +parameters = new RequestParameters('secret', 'response', 'remoteip', 'version'); + } + + public function tearDown(): void + { + self::$assert = null; + } + + public function testHTTPContextOptions() + { + $req = new Post(); + self::$assert = array($this, 'httpContextOptionsCallback'); + $req->submit($this->parameters); + static::assertEquals(1, $this->runcount, 'The assertion was ran'); + } + + public function testSSLContextOptions() + { + $req = new Post(); + self::$assert = array($this, 'sslContextOptionsCallback'); + $req->submit($this->parameters); + static::assertEquals(1, $this->runcount, 'The assertion was ran'); + } + + public function testOverrideVerifyUrl() + { + $req = new Post('https://over.ride/some/path'); + self::$assert = array($this, 'overrideUrlOptions'); + $req->submit($this->parameters); + static::assertEquals(1, $this->runcount, 'The assertion was ran'); + } + + public function testConnectionFailureReturnsError() + { + $req = new Post('https://bad.connection/'); + self::$assert = array($this, 'connectionFailureResponse'); + $response = $req->submit($this->parameters); + static::assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}', $response); + } + + public function connectionFailureResponse() + { + return false; + } + + public function overrideUrlOptions(array $args) + { + $this->runcount++; + static::assertEquals('https://over.ride/some/path', $args[0]); + } + + public function httpContextOptionsCallback(array $args) + { + $this->runcount++; + $this->assertCommonOptions($args); + + $options = stream_context_get_options($args[2]); + static::assertArrayHasKey('http', $options); + + static::assertArrayHasKey('method', $options['http']); + static::assertEquals('POST', $options['http']['method']); + + static::assertArrayHasKey('content', $options['http']); + static::assertEquals($this->parameters->toQueryString(), $options['http']['content']); + + static::assertArrayHasKey('header', $options['http']); + static::assertStringContainsStringIgnoringCase('Content-type: application/x-www-form-urlencoded', $options['http']['header']); + } + + public function sslContextOptionsCallback(array $args) + { + $this->runcount++; + $this->assertCommonOptions($args); + + $options = stream_context_get_options($args[2]); + static::assertArrayHasKey('http', $options); + static::assertArrayHasKey('verify_peer', $options['http']); + static::assertTrue($options['http']['verify_peer']); + } + + protected function assertCommonOptions(array $args) + { + static::assertCount(3, $args); + static::assertStringStartsWith('https://www.google.com/', $args[0]); + static::assertFalse($args[1]); + static::assertTrue(is_resource($args[2]), 'The context options should be a resource'); + } +} + +namespace Karser\Recaptcha3Bundle\ReCaptcha\RequestMethod; + +use Karser\Recaptcha3Bundle\Tests\ReCaptcha\RequestMethod\PostTest; + +function file_get_contents() +{ + if (PostTest::$assert) { + return call_user_func(PostTest::$assert, func_get_args()); + } + // Since we can't represent maxlen in userland... + return call_user_func_array('file_get_contents', func_get_args()); +} diff --git a/Tests/ReCaptcha/RequestMethod/SocketPostTest.php b/Tests/ReCaptcha/RequestMethod/SocketPostTest.php new file mode 100644 index 0000000..de379d8 --- /dev/null +++ b/Tests/ReCaptcha/RequestMethod/SocketPostTest.php @@ -0,0 +1,142 @@ +getMockBuilder(Socket::class) + ->disableOriginalConstructor() + ->getMock(); + + $socket->expects(self::once()) + ->method('fsockopen') + ->willReturn(true); + $socket->expects(self::once()) + ->method('fwrite'); + $socket->expects(self::once()) + ->method('fgets') + ->willReturn("HTTP/1.0 200 OK\n\nRESPONSEBODY"); + $socket->expects(self::exactly(2)) + ->method('feof') + ->willReturnOnConsecutiveCalls(false, true); + $socket->expects(self::once()) + ->method('fclose') + ->willReturn(true); + + $ps = new SocketPost($socket); + $response = $ps->submit(new RequestParameters("secret", "response", "remoteip", "version")); + self::assertEquals('RESPONSEBODY', $response); + } + + public function testOverrideSiteVerifyUrl() + { + /** @var Socket&\PHPUnit\Framework\MockObject\MockObject $socket */ + $socket = $this->getMockBuilder(Socket::class) + ->disableOriginalConstructor() + ->getMock(); + + $socket->expects(self::once()) + ->method('fsockopen') + ->with('ssl://over.ride', 443, 0, '', 30) + ->willReturn(true); + $socket->expects(self::once()) + ->method('fwrite') + ->with(self::matchesRegularExpression('/^POST \/some\/path.*Host: over\.ride/s')); + $socket->expects(self::once()) + ->method('fgets') + ->willReturn("HTTP/1.0 200 OK\n\nRESPONSEBODY"); + $socket->expects(self::exactly(2)) + ->method('feof') + ->willReturnOnConsecutiveCalls(false, true); + $socket->expects(self::once()) + ->method('fclose') + ->willReturn(true); + + $ps = new SocketPost($socket, 'https://over.ride/some/path'); + $response = $ps->submit(new RequestParameters("secret", "response", "remoteip", "version")); + self::assertEquals('RESPONSEBODY', $response); + } + + public function testSubmitBadResponse() + { + /** @var Socket&\PHPUnit\Framework\MockObject\MockObject $socket */ + $socket = $this->getMockBuilder(Socket::class) + ->disableOriginalConstructor() + ->getMock(); + + $socket->expects(self::once()) + ->method('fsockopen') + ->willReturn(true); + $socket->expects(self::once()) + ->method('fwrite'); + $socket->expects(self::once()) + ->method('fgets') + ->willReturn("HTTP/1.0 500 NOPEn\\nBOBBINS"); + $socket->expects(self::exactly(2)) + ->method('feof') + ->willReturnOnConsecutiveCalls(false, true); + $socket->expects(self::once()) + ->method('fclose') + ->willReturn(true); + + $ps = new SocketPost($socket); + $response = $ps->submit(new RequestParameters("secret", "response", "remoteip", "version")); + self::assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_BAD_RESPONSE.'"]}', $response); + } + + public function testConnectionFailureReturnsError() + { + /** @var Socket&\PHPUnit\Framework\MockObject\MockObject $socket */ + $socket = $this->getMockBuilder(Socket::class) + ->disableOriginalConstructor() + ->getMock(); + + $socket->expects(self::once()) + ->method('fsockopen') + ->willReturn(null); + $ps = new SocketPost($socket); + $response = $ps->submit(new RequestParameters("secret", "response", "remoteip", "version")); + self::assertEquals('{"success": false, "error-codes": ["'.ReCaptcha::E_CONNECTION_FAILED.'"]}', $response); + } +} \ No newline at end of file diff --git a/Tests/ReCaptcha/RequestParametersTest.php b/Tests/ReCaptcha/RequestParametersTest.php new file mode 100644 index 0000000..3034614 --- /dev/null +++ b/Tests/ReCaptcha/RequestParametersTest.php @@ -0,0 +1,70 @@ + 'SECRET', 'response' => 'RESPONSE', 'remoteip' => 'REMOTEIP', 'version' => 'VERSION'), + 'secret=SECRET&response=RESPONSE&remoteip=REMOTEIP&version=VERSION'), + array('SECRET', 'RESPONSE', null, null, + array('secret' => 'SECRET', 'response' => 'RESPONSE'), + 'secret=SECRET&response=RESPONSE'), + ); + } + + /** + * @dataProvider provideValidData + */ + public function testToArray($secret, $response, $remoteIp, $version, $expectedArray, $expectedQuery) + { + $params = new RequestParameters($secret, $response, $remoteIp, $version); + self::assertEquals($params->toArray(), $expectedArray); + } + + /** + * @dataProvider provideValidData + */ + public function testToQueryString($secret, $response, $remoteIp, $version, $expectedArray, $expectedQuery) + { + $params = new RequestParameters($secret, $response, $remoteIp, $version); + self::assertEquals($params->toQueryString(), $expectedQuery); + } +} diff --git a/Tests/ReCaptcha/ResponseTest.php b/Tests/ReCaptcha/ResponseTest.php new file mode 100644 index 0000000..e33d50b --- /dev/null +++ b/Tests/ReCaptcha/ResponseTest.php @@ -0,0 +1,172 @@ +isSuccess()); + self::assertEquals($errorCodes, $response->getErrorCodes()); + self::assertEquals($hostname, $response->getHostname()); + self::assertEquals($challengeTs, $response->getChallengeTs()); + self::assertEquals($apkPackageName, $response->getApkPackageName()); + self::assertEquals($score, $response->getScore()); + self::assertEquals($action, $response->getAction()); + } + + public static function provideJson() + { + return array( + array( + '{"success": true}', + true, array(), null, null, null, null, null, + ), + array( + '{"success": true, "hostname": "google.com"}', + true, array(), 'google.com', null, null, null, null, + ), + array( + '{"success": false, "error-codes": ["test"]}', + false, array('test'), null, null, null, null, null, + ), + array( + '{"success": false, "error-codes": ["test"], "hostname": "google.com"}', + false, array('test'), 'google.com', null, null, null, null, + ), + array( + '{"success": false, "error-codes": ["test"], "hostname": "google.com", "challenge_ts": "timestamp", "apk_package_name": "apk", "score": "0.5", "action": "action"}', + false, array('test'), 'google.com', 'timestamp', 'apk', 0.5, 'action', + ), + array( + '{"success": true, "error-codes": ["test"]}', + true, array(), null, null, null, null, null, + ), + array( + '{"success": true, "error-codes": ["test"], "hostname": "google.com"}', + true, array(), 'google.com', null, null, null, null, + ), + array( + '{"success": false}', + false, array(ReCaptcha::E_UNKNOWN_ERROR), null, null, null, null, null, + ), + array( + '{"success": false, "hostname": "google.com"}', + false, array(ReCaptcha::E_UNKNOWN_ERROR), 'google.com', null, null, null, null, + ), + array( + 'BAD JSON', + false, array(ReCaptcha::E_INVALID_JSON), null, null, null, null, null, + ), + ); + } + + public function testIsSuccess() + { + $response = new Response(true); + self::assertTrue($response->isSuccess()); + + $response = new Response(false); + self::assertFalse($response->isSuccess()); + + $response = new Response(true, array(), 'example.com'); + self::assertEquals('example.com', $response->getHostname()); + } + + public function testGetErrorCodes() + { + $errorCodes = array('test'); + $response = new Response(true, $errorCodes); + self::assertEquals($errorCodes, $response->getErrorCodes()); + } + + public function testGetHostname() + { + $hostname = 'google.com'; + $errorCodes = array(); + $response = new Response(true, $errorCodes, $hostname); + self::assertEquals($hostname, $response->getHostname()); + } + + public function testGetChallengeTs() + { + $timestamp = 'timestamp'; + $errorCodes = array(); + $response = new Response(true, array(), 'hostname', $timestamp); + self::assertEquals($timestamp, $response->getChallengeTs()); + } + + public function TestGetApkPackageName() + { + $apk = 'apk'; + $response = new Response(true, array(), 'hostname', 'timestamp', 'apk'); + self::assertEquals($apk, $response->getApkPackageName()); + } + + public function testGetScore() + { + $score = 0.5; + $response = new Response(true, array(), 'hostname', 'timestamp', 'apk', $score); + self::assertEquals($score, $response->getScore()); + } + + public function testGetAction() + { + $action = 'homepage'; + $response = new Response(true, array(), 'hostname', 'timestamp', 'apk', 0.5, 'homepage'); + self::assertEquals($action, $response->getAction()); + } + + public function testToArray() + { + $response = new Response(true, array(), 'hostname', 'timestamp', 'apk', 0.5, 'homepage'); + $expected = array( + 'success' => true, + 'error-codes' => array(), + 'hostname' => 'hostname', + 'challenge_ts' => 'timestamp', + 'apk_package_name' => 'apk', + 'score' => 0.5, + 'action' => 'homepage', + ); + self::assertEquals($expected, $response->toArray()); + } +} diff --git a/Tests/RequestMethod/SymfonyHttpClientTest.php b/Tests/RequestMethod/SymfonyHttpClientTest.php index 6e7a5c9..722896e 100644 --- a/Tests/RequestMethod/SymfonyHttpClientTest.php +++ b/Tests/RequestMethod/SymfonyHttpClientTest.php @@ -6,7 +6,7 @@ use Karser\Recaptcha3Bundle\RequestMethod\SymfonyHttpClient; use PHPUnit\Framework\TestCase; -use ReCaptcha\RequestParameters; +use Karser\Recaptcha3Bundle\ReCaptcha\RequestParameters; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; diff --git a/Tests/fixtures/RecaptchaMock.php b/Tests/fixtures/RecaptchaMock.php index 025f9ab..adf0582 100644 --- a/Tests/fixtures/RecaptchaMock.php +++ b/Tests/fixtures/RecaptchaMock.php @@ -2,7 +2,7 @@ namespace Karser\Recaptcha3Bundle\Tests\fixtures; -use ReCaptcha\Response; +use Karser\Recaptcha3Bundle\ReCaptcha\Response; class RecaptchaMock { diff --git a/Validator/Constraints/Recaptcha3Validator.php b/Validator/Constraints/Recaptcha3Validator.php index cccc475..49da987 100644 --- a/Validator/Constraints/Recaptcha3Validator.php +++ b/Validator/Constraints/Recaptcha3Validator.php @@ -3,8 +3,8 @@ namespace Karser\Recaptcha3Bundle\Validator\Constraints; use Karser\Recaptcha3Bundle\Services\IpResolverInterface; -use ReCaptcha\ReCaptcha; -use ReCaptcha\Response; +use Karser\Recaptcha3Bundle\ReCaptcha\ReCaptcha; +use Karser\Recaptcha3Bundle\ReCaptcha\Response; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\Exception\UnexpectedTypeException; diff --git a/composer.json b/composer.json index 00c9ccf..a8f8f76 100644 --- a/composer.json +++ b/composer.json @@ -35,8 +35,8 @@ } ], "require": { - "php": ">=7.1", - "google/recaptcha": "^1.2", + "php": ">=7.4", + "ext-json": "*", "symfony/form": "^3.4|^4.0|^5.0|^6.0|^7.0", "symfony/framework-bundle": "^3.4.26|^4.2.7|^5.0|^6.0|^7.0", "symfony/expression-language": "^3.4|^4.0|^5.0|^6.0|^7.0", diff --git a/phpstan.neon b/phpstan.neon index fbe57b7..b8dd331 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,18 +5,16 @@ includes: parameters: level: 5 reportUnmatchedIgnoredErrors: false - checkMissingIterableValueType: false - checkGenericClassInNonGenericObjectType: false inferPrivatePropertyTypeFromConstructor: true paths: - %currentWorkingDirectory% ignoreErrors: + - + identifier: missingType.generics - '#Class Symfony\\Component\\Config\\Definition\\Builder\\TreeBuilder constructor invoked with 0 parameters, 1-3 required#' - '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\TreeBuilder::root\(\)#' - '#Call to function method_exists\(\) with .*?TreeBuilder.*? and .*?getRootNode.*? will always evaluate to true#' - - '#Comparison operation "<" between 70006 and 50200 is always false#' - - '#Comparison operation "<" between 70007 and 50200 is always false#' - - '#Comparison operation ">=" between 7 and 6 is always true#' + - '#Comparison operation ".*?" between \d+ and \d+ is always (true|false)#' - '#Else branch is unreachable because ternary operator condition is always true#' excludePaths: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f32f070..dd1f11a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,23 +1,22 @@ - - - ./Tests - - - - - . - - - Resources - Tests - vendor - - + + + ./Tests + + + + + . + + + Resources + Tests + vendor + +