From 130c69ae6204f460771b8c1209c71faebdc695b6 Mon Sep 17 00:00:00 2001 From: Kiran Timsina <50225225+timsinakiran@users.noreply.github.com> Date: Wed, 9 Apr 2025 22:12:21 +0545 Subject: [PATCH 1/6] Support for Laravel 12 Laravel 12 requires PHP Unit 11. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5e9d8b3..796a84f 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "illuminate/support": "5.*|6.*|7.*|8.*|^9.0|10.*|^11.0|^12.0" }, "require-dev": { - "phpunit/phpunit": "~4.8|^9.5.10|^10.0" + "phpunit/phpunit": "~4.8|^9.5.10|^10.0|^11.0" }, "autoload": { "psr-4": { From 501db57ebccbe5a0fabc9daf78e7535960432d51 Mon Sep 17 00:00:00 2001 From: Usama Munir Date: Mon, 28 Jul 2025 18:41:46 +0100 Subject: [PATCH 2/6] updated composer json --- composer.json | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 796a84f..5e8c8ff 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "scyllaly/hcaptcha", - "description": "hCaptcha for Laravel", + "name": "cmsmax/hcaptcha", + "description": "hCaptcha for Laravel, forked by CMSMax and made compatible with Laravel 12", "keywords": [ "hcaptcha", "captcha", @@ -14,8 +14,9 @@ } ], "require": { - "php": ">=5.5.5", - "illuminate/support": "5.*|6.*|7.*|8.*|^9.0|10.*|^11.0|^12.0" + "php": "^8.2", + "illuminate/support": "^10.0|^11.0|^12.0", + "guzzlehttp/guzzle": "^7.0" }, "require-dev": { "phpunit/phpunit": "~4.8|^9.5.10|^10.0|^11.0" @@ -25,6 +26,14 @@ "Scyllaly\\HCaptcha\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Scyllaly\\HCaptcha\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit" + }, "extra": { "laravel": { "providers": [ From 5404e04531bdf65a65d6329c80b28d62791dc367 Mon Sep 17 00:00:00 2001 From: Usama Munir Date: Mon, 28 Jul 2025 18:42:03 +0100 Subject: [PATCH 3/6] added more config options for shadow banning --- src/config/config.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/config/config.php b/src/config/config.php index 8f11400..9ec8a4f 100644 --- a/src/config/config.php +++ b/src/config/config.php @@ -8,6 +8,11 @@ 'options' => [ 'timeout' => 30, ], - 'score_verification_enabled' => false, // This is an exclusive Enterprise feature - 'score_threshold' => 0.7 // Any requests above this score will be considered as spam + 'score_verification_enabled' => false, // Enterprise feature to enable score-based verification + 'score_threshold' => 0.7, // Any requests above this score will be considered as spam + 'suspicious_score_threshold' => 0.3, // Score threshold for triggering re-authentication + 'shadow_ban_duration' => 86400, // Shadow ban duration in seconds (24 hours) + 'enable_shadow_banning' => true, // Enable shadow banning feature + 'enable_detailed_logging' => true, // Enable detailed logging for shadow bans + 'validation_return_detailed' => false, // Whether validation rules return detailed results by default ]; From 7723f42dacc03deb7a59c101e9bf7a1804a742b1 Mon Sep 17 00:00:00 2001 From: Usama Munir Date: Mon, 28 Jul 2025 18:42:11 +0100 Subject: [PATCH 4/6] fixed phpunit.xml --- phpunit.xml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/phpunit.xml b/phpunit.xml index 3347b75..713aec8 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,18 +1,14 @@ - - ./tests/ + + ./tests/HCaptchaTest.php + ./tests/BasicHCaptchaTest.php From 28770eed620ea7337ecd5035fab26ad4378da971 Mon Sep 17 00:00:00 2001 From: Usama Munir Date: Mon, 28 Jul 2025 18:43:15 +0100 Subject: [PATCH 5/6] added shadow ban logic and code refactoring --- src/HCaptcha.php | 612 +++++++++++++++++++++++++------- src/HCaptchaServiceProvider.php | 11 +- 2 files changed, 487 insertions(+), 136 deletions(-) diff --git a/src/HCaptcha.php b/src/HCaptcha.php index 369044c..5453468 100644 --- a/src/HCaptcha.php +++ b/src/HCaptcha.php @@ -12,52 +12,50 @@ class HCaptcha /** * The hCaptcha secret key. - * - * @var string */ - protected $secret; + protected string $secret; /** * The hCaptcha sitekey key. - * - * @var string */ - protected $sitekey; + protected string $sitekey; /** - * @var \GuzzleHttp\Client + * The HTTP client used to send requests. */ - protected $http; + protected \GuzzleHttp\Client $http; /** * The cached verified responses. - * - * @var array */ - protected $verifiedResponses = []; + protected array $verifiedResponses = []; /** - * @var null * lastScore */ - protected $lastScore = null; + protected ?float $lastScore = null; /** * Whether to use hCaptcha or not. - * - * @var bool */ - protected $enabled; + protected bool $enabled; /** - * HCaptcha. - * - * @param string $secret - * @param string $sitekey - * @param array $options - * @param bool $enabled + * Cache of response verifications with timestamps + * @var array */ - public function __construct($secret, $sitekey, $options = [], $enabled = true) + protected array $responseCache = []; + + /** + * Cache duration in seconds + */ + protected int $cacheDuration = 120; + + + /** + * HCaptcha constructor. + */ + public function __construct(string $secret, string $sitekey, array $options = [], bool $enabled = true) { $this->secret = $secret; $this->sitekey = $sitekey; @@ -66,13 +64,395 @@ public function __construct($secret, $sitekey, $options = [], $enabled = true) } /** - * Render HTML captcha. + * Get the hCaptcha verification details for a given response + */ + public function getResponseDetails(string $response, ?string $clientIp = null): array + { + if (!$this->enabled) { + return [ + 'success' => true, + 'challenge_ts' => date('Y-m-d\TH:i:s\Z'), + 'hostname' => $_SERVER['HTTP_HOST'] ?? 'unknown', + ]; + } + + if (empty($response)) { + return [ + 'success' => false, + 'challenge_ts' => date('Y-m-d\TH:i:s\Z'), + 'hostname' => $_SERVER['HTTP_HOST'] ?? 'unknown', + 'error-codes' => ['missing-input-response'] + ]; + } + + $verifyResponse = $this->getVerificationResponse($response, $clientIp, true); + + // Store verification state + if (isset($verifyResponse['success']) && $verifyResponse['success'] === true) { + $this->lastScore = $verifyResponse['score'] ?? null; + $this->verifiedResponses[] = $response; + } + + return $verifyResponse; + } + + /** + * Verify the hCaptcha response. + */ + public function verifyResponse(string $response, ?string $clientIp = null, ?bool $detailed = null) + { + // Use config value if $detailed is not explicitly provided + if ($detailed === null) { + $detailed = function_exists('config') ? config('HCaptcha.validation_return_detailed', false) : false; + } + + if (!$this->enabled) { + if ($detailed) { + return [ + 'allowed' => true, + 'action' => 'disabled', + 'message' => 'hCaptcha is disabled', + 'score' => null, + 'success' => true + ]; + } + return true; // Always true if hCaptcha is disabled + } + + if (empty($response)) { + if ($detailed) { + return [ + 'allowed' => false, + 'action' => 'missing_response', + 'message' => 'No hCaptcha response provided', + 'score' => null, + 'success' => false + ]; + } + return false; + } + + // Check for existing shadow ban first + $userId = $this->getUserIdFromIp($clientIp); + if ($userId && $this->isUserShadowBanned($userId)) { + $banDetails = $this->getShadowBanDetails($userId); + if ($detailed) { + return [ + 'allowed' => false, + 'action' => 'shadow_ban', + 'message' => 'Your account has been temporarily restricted due to suspicious activity.', + 'score' => $banDetails['score'] ?? null, + 'success' => false, + 'ban_details' => $banDetails + ]; + } + return false; + } + + // Return cached result if response already verified before. + if (in_array($response, $this->verifiedResponses)) { + if ($detailed) { + return [ + 'allowed' => true, + 'action' => 'cached', + 'message' => 'Response already verified', + 'score' => $this->lastScore, + 'success' => true + ]; + } + return true; + } + + $verifyResponse = $this->getVerificationResponse($response, $clientIp); + $success = isset($verifyResponse['success']) && $verifyResponse['success'] === true; + $score = isset($verifyResponse['score']) ? (float)$verifyResponse['score'] : null; + + $this->lastScore = $score; + + // Enhanced score-based decision making + $result = $this->processScoreBasedDecision($success, $score, $clientIp); + + if ($result['allowed']) { + $this->verifiedResponses[] = $response; + } + + if ($detailed) { + return array_merge($result, [ + 'score' => $score, + 'success' => $success, + 'raw_response' => $verifyResponse + ]); + } + + return $result['allowed']; + } + + /** + * Process score-based decision according to hCaptcha recommended flow. * - * @param array $attributes + * Decision Matrix: + * | Success | Score | Action | Result | + * |---------|-------|--------|--------| + * | true | null | Session OK | ✅ Allow | + * | true | < 0.3 | Session OK | ✅ Allow | + * | true | 0.3-0.7 | Trigger Re-auth | ❌ Block + Re-auth | + * | true | ≥ 0.7 | Shadow Ban User | ❌ Block + Ban | + * | false | null | Request New Token | ❌ Block + New Token | + * | false | < 0.7 | Request New Token | ❌ Block + New Token | + * | false | ≥ 0.7 | Shadow Ban User | ❌ Block + Ban | + */ + protected function processScoreBasedDecision(bool $success, ?float $score, ?string $clientIp = null): array + { + $scoreThreshold = function_exists('config') ? config('HCaptcha.score_threshold', 0.7) : 0.7; + $suspiciousThreshold = function_exists('config') ? config('HCaptcha.suspicious_score_threshold', 0.3) : 0.3; + $userId = $this->getUserIdFromIp($clientIp); + + if ($success) { + if ($score === null) { + // Score missing - session OK + return [ + 'allowed' => true, + 'action' => 'session_ok', + 'message' => null + ]; + } elseif ($score >= $scoreThreshold) { + // Score is high - shadow ban user + $this->shadowBanUser($userId, $score); + return [ + 'allowed' => false, + 'action' => 'shadow_ban', + 'message' => 'Your account has been temporarily restricted due to suspicious activity.' + ]; + } elseif ($score >= $suspiciousThreshold) { + // Score is suspicious - trigger re-auth + return [ + 'allowed' => false, + 'action' => 're_auth', + 'message' => 'Additional verification required. Please complete the challenge again.' + ]; + } else { + // Score is low - session OK + return [ + 'allowed' => true, + 'action' => 'session_ok', + 'message' => null + ]; + } + } else { + if ($score === null || $score < $scoreThreshold) { + // Score missing or low - request new token + return [ + 'allowed' => false, + 'action' => 'new_token', + 'message' => 'Please complete the captcha challenge again.' + ]; + } else { + // Score is high but success is false - shadow ban user + $this->shadowBanUser($userId, $score); + return [ + 'allowed' => false, + 'action' => 'shadow_ban', + 'message' => 'Your account has been temporarily restricted due to suspicious activity.' + ]; + } + } + } + + /** + * Check if a user is shadow banned before form submission + * This can be called before displaying forms or processing submissions + */ + public function checkShadowBan(?string $clientIp = null, ?bool $detailed = null): bool|array + { + // Use config value if $detailed is not explicitly provided + if ($detailed === null) { + $detailed = function_exists('config') ? config('HCaptcha.validation_return_detailed', false) : false; + } + + if (!$this->enabled) { + return $detailed ? [ + 'banned' => false, + 'action' => 'disabled', + 'message' => 'hCaptcha is disabled' + ] : false; + } + + $userId = $this->getUserIdFromIp($clientIp); + if ($userId && $this->isUserShadowBanned($userId)) { + $banDetails = $this->getShadowBanDetails($userId); + if ($detailed) { + return [ + 'banned' => true, + 'action' => 'shadow_ban', + 'message' => 'Your account has been temporarily restricted due to suspicious activity.', + 'ban_details' => $banDetails, + 'expires_at' => $banDetails['expires_at'] ?? null + ]; + } + return true; + } + + return $detailed ? [ + 'banned' => false, + 'action' => 'allowed', + 'message' => null + ] : false; + } + + /** + * Get user ID from IP address (can be overridden in child classes) + */ + protected function getUserIdFromIp(?string $clientIp = null): ?string + { + return $clientIp ? 'ip_' . $clientIp : null; + } + + /** + * Shadow ban a user due to high hCaptcha score + */ + public function shadowBanUser(?string $userId, float $score): void + { + if (!$userId || (function_exists('config') && !config('HCaptcha.enable_shadow_banning', true))) { + return; + } + + $banKey = "hcaptcha_shadow_banned_{$userId}"; + $banDuration = config('HCaptcha.shadow_ban_duration', 86400); // 24 hours default + + if (function_exists('cache')) { + $now = function_exists('now') ? now() : new \DateTime(); + $bannedAt = $now instanceof \DateTime ? $now->format('c') : $now->toISOString(); + $expiresAt = function_exists('now') ? $now->addSeconds($banDuration)->toISOString() : date('c', time() + $banDuration); + + cache()->put($banKey, [ + 'score' => $score, + 'banned_at' => $bannedAt, + 'expires_at' => $expiresAt + ], $banDuration); + + // Track shadow ban keys for admin management + $keys = cache()->get('hcaptcha_shadow_ban_keys', []); + if (!in_array($banKey, $keys)) { + $keys[] = $banKey; + cache()->put('hcaptcha_shadow_ban_keys', $keys); + } + } + + // Log the shadow ban if detailed logging is enabled + if (function_exists('logger') && config('HCaptcha.enable_detailed_logging', true)) { + logger()->warning("User {$userId} shadow banned due to high hCaptcha score: {$score}", [ + 'user_id' => $userId, + 'score' => $score, + 'banned_until' => now()->addSeconds($banDuration)->toISOString() + ]); + } + } + + /** + * Check if a user is shadow banned + */ + public function isUserShadowBanned(?string $userId = null): bool + { + if (!$userId) { + return false; + } + + if (function_exists('cache')) { + return cache()->has("hcaptcha_shadow_banned_{$userId}"); + } + + return false; + } + + /** + * Get shadow ban details for a user + */ + public function getShadowBanDetails(?string $userId = null): ?array + { + if (!$userId) { + return null; + } + + if (function_exists('cache')) { + return cache()->get("hcaptcha_shadow_banned_{$userId}"); + } + + return null; + } + + /** + * Remove shadow ban for a user + */ + public function removeShadowBan(?string $userId = null): void + { + if (!$userId) { + return; + } + + if (function_exists('cache')) { + $banKey = "hcaptcha_shadow_banned_{$userId}"; + cache()->forget($banKey); + + // Remove from keys list + $keys = cache()->get('hcaptcha_shadow_ban_keys', []); + $keys = array_diff($keys, [$banKey]); + cache()->put('hcaptcha_shadow_ban_keys', $keys); + } + + if (function_exists('logger') && config('HCaptcha.enable_detailed_logging', true)) { + logger()->info("Shadow ban removed for user {$userId}"); + } + } + + /** + * Verify hCaptcha response by Symfony Request. + */ + public function verifyRequest(Request $request): bool + { + return $this->verifyResponse( + $request->get('h-captcha-response'), + $request->getClientIp() + ); + } + + /** + * Send verify request. + */ + protected function sendRequestVerify(array $query = []): array + { + try { + $response = $this->http->request('POST', static::VERIFY_URL, [ + 'form_params' => $query, + 'timeout' => 5.0 + ]); + + $result = json_decode($response->getBody(), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException('Invalid JSON response from hCaptcha'); + } + + return $result; + + } catch (\Exception $e) { + throw new \RuntimeException('Failed to verify hCaptcha response'); + } + } + + /** + * Get the score from the last successful hCaptcha verification. * - * @return string + * @return float|null The score of the last verification or null if not available. */ - public function display($attributes = []) + public function getScoreFromLastVerification(): ?float + { + return $this->lastScore; + } + + /** + * Render HTML captcha. + */ + public function display($attributes = []): string { if (!$this->enabled) { return ''; @@ -85,7 +465,7 @@ public function display($attributes = []) /** * @see display() */ - public function displayWidget($attributes = []) + public function displayWidget($attributes = []): string { return $this->display($attributes); } @@ -94,12 +474,12 @@ public function displayWidget($attributes = []) * Display a Invisible hCaptcha by embedding a callback into a form submit button. * * @param string $formIdentifier the html ID of the form that should be submitted. - * @param string $text the text inside the form button - * @param array $attributes array of additional html elements + * @param string $text the text inside the form button + * @param array $attributes array of additional html elements * * @return string */ - public function displaySubmit($formIdentifier, $text = 'submit', $attributes = []) + public function displaySubmit(string $formIdentifier, string $text = 'submit', array $attributes = []): string { if (!$this->enabled) { return sprintf('%s', $this->buildAttributes($attributes), $text); @@ -117,7 +497,6 @@ public function displaySubmit($formIdentifier, $text = 'submit', $attributes = [ } $attributes = $this->prepareAttributes($attributes); - $button = sprintf('%s', $this->buildAttributes($attributes), $text); return $button . $javascript; @@ -126,13 +505,13 @@ public function displaySubmit($formIdentifier, $text = 'submit', $attributes = [ /** * Render js source * - * @param null $lang - * @param bool $callback + * @param null $lang + * @param bool $callback * @param string $onLoadClass * * @return string */ - public function renderJs($lang = null, $callback = false, $onLoadClass = 'onloadCallBack') + public function renderJs($lang = null, $callback = false, $onLoadClass = 'onloadCallBack'): string { if (!$this->enabled) { return ''; @@ -141,82 +520,16 @@ public function renderJs($lang = null, $callback = false, $onLoadClass = 'onload return '' . "\n"; } - /** - * Verify hCaptcha response. - * - * @param string $response - * @param string $clientIp - * - * @return bool - */ - public function verifyResponse($response, $clientIp = null) - { - if (!$this->enabled) { - return true; // Always true if hCaptcha is disabled - } - - if (empty($response)) { - return false; - } - - // Return true if response already verified before. - if (in_array($response, $this->verifiedResponses)) { - return true; - } - - $verifyResponse = $this->sendRequestVerify([ - 'secret' => $this->secret, - 'response' => $response, - 'remoteip' => $clientIp, - ]); - - if (isset($verifyResponse['success']) && $verifyResponse['success'] === true) { - $this->lastScore = isset($verifyResponse['score']) ? $verifyResponse['score'] : null; - // Check score if it's enabled. - $isScoreVerificationEnabled = config('HCaptcha.score_verification_enabled', false); - - if ($isScoreVerificationEnabled && !array_key_exists('score', $verifyResponse)) { - throw new \RuntimeException('Score Verification is an exclusive Enterprise feature! Moreover, make sure you are sending the remoteip in your request payload!'); - } - - if ($isScoreVerificationEnabled && $verifyResponse['score'] > config('HCaptcha.score_threshold', 0.7)) { - return false; - } - - // A response can only be verified once from hCaptcha, so we need to - // cache it to make it work in case we want to verify it multiple times. - $this->verifiedResponses[] = $response; - return true; - } else { - return false; - } - } - - /** - * Verify hCaptcha response by Symfony Request. - * - * @param Request $request - * - * @return bool - */ - public function verifyRequest(Request $request) - { - return $this->verifyResponse( - $request->get('h-captcha-response'), - $request->getClientIp() - ); - } - /** * Get hCaptcha js link. * - * @param string $lang + * @param string $lang * @param boolean $callback - * @param string $onLoadClass + * @param string $onLoadClass * * @return string */ - public function getJsLink($lang = null, $callback = false, $onLoadClass = 'onloadCallBack') + public function getJsLink($lang = null, $callback = false, $onLoadClass = 'onloadCallBack'): string { if (!$this->enabled) { return ''; @@ -231,42 +544,16 @@ public function getJsLink($lang = null, $callback = false, $onLoadClass = 'onloa return $client_api . '?' . http_build_query($params); } - /** - * Get the score from the last successful hCaptcha verification. - * - * @return float|null The score of the last verification or null if not available. - */ - public function getScoreFromLastVerification() - { - return $this->lastScore; - } - /** * @param $params * @param $onLoadClass */ - protected function setCallBackParams(&$params, $onLoadClass) + protected function setCallBackParams(&$params, $onLoadClass): void { $params['render'] = 'explicit'; $params['onload'] = $onLoadClass; } - /** - * Send verify request. - * - * @param array $query - * - * @return array - */ - protected function sendRequestVerify(array $query = []) - { - $response = $this->http->request('POST', static::VERIFY_URL, [ - 'form_params' => $query, - ]); - - return json_decode($response->getBody(), true); - } - /** * Prepare HTML attributes and assure that the correct classes and attributes for captcha are inserted. * @@ -274,7 +561,7 @@ protected function sendRequestVerify(array $query = []) * * @return array */ - protected function prepareAttributes(array $attributes) + protected function prepareAttributes(array $attributes): array { $attributes['data-sitekey'] = $this->sitekey; if (!isset($attributes['class'])) { @@ -292,7 +579,7 @@ protected function prepareAttributes(array $attributes) * * @return string */ - protected function buildAttributes(array $attributes) + protected function buildAttributes(array $attributes): string { $html = []; @@ -302,4 +589,61 @@ protected function buildAttributes(array $attributes) return count($html) ? ' ' . implode(' ', $html) : ''; } -} + + /** + * Get cached response if valid, or null if not found/expired + */ + private function getCachedResponse(string $response): ?array + { + if (isset($this->responseCache[$response])) { + $cached = $this->responseCache[$response]; + if (time() - $cached['timestamp'] < $this->cacheDuration) { + return $cached['response']; + } + // Remove expired cache entry + unset($this->responseCache[$response]); + } + return null; + } + + /** + * Cache a verification response + */ + private function cacheResponse(string $response, array $verifyResponse): void + { + $this->responseCache[$response] = [ + 'timestamp' => time(), + 'response' => $verifyResponse + ]; + } + + /** + * Get verification response, either from cache or fresh API call + */ + private function getVerificationResponse(string $response, ?string $clientIp, bool $includeSitekey = false): array + { + // Try to get from cache first + $cachedResponse = $this->getCachedResponse($response); + if ($cachedResponse !== null) { + return $cachedResponse; + } + + $params = [ + 'secret' => $this->secret, + 'response' => $response, + 'remoteip' => $clientIp, + ]; + + if ($includeSitekey) { + $params['sitekey'] = $this->sitekey; + } + + $verifyResponse = $this->sendRequestVerify($params); + + // Cache the response + $this->cacheResponse($response, $verifyResponse); + + return $verifyResponse; + } + +} \ No newline at end of file diff --git a/src/HCaptchaServiceProvider.php b/src/HCaptchaServiceProvider.php index 134b0b4..777fc04 100644 --- a/src/HCaptchaServiceProvider.php +++ b/src/HCaptchaServiceProvider.php @@ -21,9 +21,16 @@ public function boot() $app = $this->app; $this->bootConfig(); - + $app['validator']->extend('HCaptcha', function ($attribute, $value) use ($app) { - return $app['HCaptcha']->verifyResponse($value, $app['request']->getClientIp()); + $result = $app['HCaptcha']->verifyResponse($value, $app['request']->getClientIp()); + return is_array($result) ? $result['allowed'] : $result; + }); + + // Shadow ban validation rule + $app['validator']->extend('shadowBan', function ($attribute, $value, $parameters, $validator) use ($app) { + $banCheck = $app['HCaptcha']->checkShadowBan($app['request']->getClientIp(), true); + return !$banCheck['banned']; }); if ($app->bound('form')) { From 126f4b81bb9f0d0cd1b6fe75909dd1f12fee3dce Mon Sep 17 00:00:00 2001 From: Usama Munir Date: Mon, 28 Jul 2025 19:05:12 +0100 Subject: [PATCH 6/6] added tests --- phpunit.xml | 10 +- src/HCaptcha.php | 2 +- tests/BasicHCaptchaTest.php | 66 ++++++++++++ tests/EnhancedHCaptchaTest.php | 191 +++++++++++++++++++++++++++++++++ tests/HCaptchaTest.php | 17 +-- 5 files changed, 272 insertions(+), 14 deletions(-) create mode 100644 tests/BasicHCaptchaTest.php create mode 100644 tests/EnhancedHCaptchaTest.php diff --git a/phpunit.xml b/phpunit.xml index 713aec8..4c2aa0d 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,14 +1,12 @@ - - - ./tests/HCaptchaTest.php - ./tests/BasicHCaptchaTest.php + + tests diff --git a/src/HCaptcha.php b/src/HCaptcha.php index 5453468..b1b0ae1 100644 --- a/src/HCaptcha.php +++ b/src/HCaptcha.php @@ -317,7 +317,7 @@ public function shadowBanUser(?string $userId, float $score): void } $banKey = "hcaptcha_shadow_banned_{$userId}"; - $banDuration = config('HCaptcha.shadow_ban_duration', 86400); // 24 hours default + $banDuration = function_exists('config') ? config('HCaptcha.shadow_ban_duration', 86400): 86400; // 24 hours default if (function_exists('cache')) { $now = function_exists('now') ? now() : new \DateTime(); diff --git a/tests/BasicHCaptchaTest.php b/tests/BasicHCaptchaTest.php new file mode 100644 index 0000000..fd7f3cd --- /dev/null +++ b/tests/BasicHCaptchaTest.php @@ -0,0 +1,66 @@ +captcha = new HCaptcha('test-secret', 'test-sitekey'); + } + + public function testEnhancedMethodsExist() + { + $this->assertTrue(method_exists($this->captcha, 'verifyResponse')); + $this->assertTrue(method_exists($this->captcha, 'shadowBanUser')); + $this->assertTrue(method_exists($this->captcha, 'isUserShadowBanned')); + $this->assertTrue(method_exists($this->captcha, 'getShadowBanDetails')); + $this->assertTrue(method_exists($this->captcha, 'removeShadowBan')); + $this->assertTrue(method_exists($this->captcha, 'getResponseDetails')); + $this->assertTrue(method_exists($this->captcha, 'getScoreFromLastVerification')); + } + + public function testProtectedMethodsExist() + { + $reflection = new \ReflectionClass($this->captcha); + + $this->assertTrue($reflection->hasMethod('processScoreBasedDecision')); + $this->assertTrue($reflection->hasMethod('getUserIdFromIp')); + } + + public function testBasicDisplayMethods() + { + $this->assertIsString($this->captcha->display()); + $this->assertIsString($this->captcha->displayWidget()); + $this->assertIsString($this->captcha->renderJs()); + $this->assertIsString($this->captcha->getJsLink()); + } + + public function testDisplaySubmit() + { + $result = $this->captcha->displaySubmit('test-form', 'Submit'); + $this->assertIsString($result); + $this->assertStringContainsString('test-form', $result); + $this->assertStringContainsString('Submit', $result); + } + + public function testConstructor() + { + $this->assertInstanceOf(HCaptcha::class, $this->captcha); + } + + public function testDisabledCaptcha() + { + $disabledCaptcha = new HCaptcha('test-secret', 'test-sitekey', [], false); + $this->assertInstanceOf(HCaptcha::class, $disabledCaptcha); + } +} \ No newline at end of file diff --git a/tests/EnhancedHCaptchaTest.php b/tests/EnhancedHCaptchaTest.php new file mode 100644 index 0000000..5d65f4f --- /dev/null +++ b/tests/EnhancedHCaptchaTest.php @@ -0,0 +1,191 @@ +captcha = new HCaptcha('test-secret', 'test-sitekey'); + + } + + public function testBasicVerification() + { + $this->assertInstanceOf(HCaptcha::class, $this->captcha); + } + + public function testGetDetailedVerificationResultWithDisabledCaptcha() + { + $disabledCaptcha = new HCaptcha('test-secret', 'test-sitekey', [], false); + + $result = $disabledCaptcha->verifyResponse('test-response', null, true); + + $this->assertTrue($result['allowed']); + $this->assertEquals('disabled', $result['action']); + $this->assertEquals('hCaptcha is disabled', $result['message']); + $this->assertTrue($result['success']); + $this->assertNull($result['score']); + } + + public function testGetDetailedVerificationResultWithEmptyResponse() + { + $result = $this->captcha->verifyResponse('', null, true); + + $this->assertFalse($result['allowed']); + $this->assertEquals('missing_response', $result['action']); + $this->assertEquals('No hCaptcha response provided', $result['message']); + $this->assertFalse($result['success']); + $this->assertNull($result['score']); + } + + public function testShadowBanUser() + { + $userId = 'test_user_123'; + $score = 0.8; + + $this->captcha->shadowBanUser($userId, $score); + + $this->assertTrue(method_exists($this->captcha, 'isUserShadowBanned')); + $this->assertTrue(method_exists($this->captcha, 'getShadowBanDetails')); + } + + public function testRemoveShadowBan() + { + $userId = 'test_user_456'; + $score = 0.9; + + // Test that the methods exist and don't throw errors + $this->captcha->shadowBanUser($userId, $score); + $this->captcha->removeShadowBan($userId); + + $this->assertTrue(method_exists($this->captcha, 'removeShadowBan')); + } + + public function testShadowBanWithNullUserId() + { + // Test that the method exists and doesn't throw errors with null userId + $this->captcha->shadowBanUser(null, 0.8); + $this->assertTrue(method_exists($this->captcha, 'isUserShadowBanned')); + } + + public function testGetUserIdFromIp() + { + $reflection = new \ReflectionClass($this->captcha); + $method = $reflection->getMethod('getUserIdFromIp'); + $method->setAccessible(true); + + $userId = $method->invoke($this->captcha, '192.168.1.1'); + $this->assertEquals('ip_192.168.1.1', $userId); + + $userId = $method->invoke($this->captcha, null); + $this->assertNull($userId); + } + + public function testProcessScoreBasedDecision() + { + $reflection = new \ReflectionClass($this->captcha); + $method = $reflection->getMethod('processScoreBasedDecision'); + $method->setAccessible(true); + + // Test success with null score (session OK) + $result = $method->invoke($this->captcha, true, null, '192.168.1.1'); + $this->assertTrue($result['allowed']); + $this->assertEquals('session_ok', $result['action']); + $this->assertNull($result['message']); + + // Test success with low score (session OK) - below 0.3 threshold + $result = $method->invoke($this->captcha, true, 0.2, '192.168.1.1'); + $this->assertTrue($result['allowed']); + $this->assertEquals('session_ok', $result['action']); + $this->assertNull($result['message']); + + // Test success with suspicious score (re-auth) - between 0.3 and 0.7 + $result = $method->invoke($this->captcha, true, 0.5, '192.168.1.1'); + $this->assertFalse($result['allowed']); + $this->assertEquals('re_auth', $result['action']); + $this->assertStringContainsString('Additional verification required', $result['message']); + + // Test success with high score (shadow ban) - above 0.7 + $result = $method->invoke($this->captcha, true, 0.8, '192.168.1.1'); + $this->assertFalse($result['allowed']); + $this->assertEquals('shadow_ban', $result['action']); + $this->assertStringContainsString('temporarily restricted', $result['message']); + + // Test failure with low score (new token) - below 0.7 + $result = $method->invoke($this->captcha, false, 0.2, '192.168.1.1'); + $this->assertFalse($result['allowed']); + $this->assertEquals('new_token', $result['action']); + $this->assertStringContainsString('complete the captcha challenge', $result['message']); + + // Test failure with high score (shadow ban) - above 0.7 + $result = $method->invoke($this->captcha, false, 0.8, '192.168.1.1'); + $this->assertFalse($result['allowed']); + $this->assertEquals('shadow_ban', $result['action']); + $this->assertStringContainsString('temporarily restricted', $result['message']); + } + + public function testGetResponseDetailsWithDisabledCaptcha() + { + $disabledCaptcha = new HCaptcha('test-secret', 'test-sitekey', [], false); + + $result = $disabledCaptcha->getResponseDetails('test-response'); + + $this->assertTrue($result['success']); + $this->assertArrayHasKey('challenge_ts', $result); + $this->assertArrayHasKey('hostname', $result); + } + + public function testGetResponseDetailsWithEmptyResponse() + { + $result = $this->captcha->getResponseDetails(''); + + $this->assertFalse($result['success']); + $this->assertArrayHasKey('error-codes', $result); + $this->assertContains('missing-input-response', $result['error-codes']); + } + + public function testGetScoreFromLastVerification() + { + // Initially should be null + $this->assertNull($this->captcha->getScoreFromLastVerification()); + + // After verification, should return the score + $reflection = new \ReflectionClass($this->captcha); + $property = $reflection->getProperty('lastScore'); + $property->setAccessible(true); + $property->setValue($this->captcha, 0.75); + + $this->assertEquals(0.75, $this->captcha->getScoreFromLastVerification()); + } + + public function testVerifyRequest() + { + $this->assertTrue(method_exists($this->captcha, 'verifyRequest')); + } + + public function testDisplayMethods() + { + $this->assertIsString($this->captcha->display()); + $this->assertIsString($this->captcha->displayWidget()); + $this->assertIsString($this->captcha->renderJs()); + $this->assertIsString($this->captcha->getJsLink()); + } + + public function testDisplaySubmit() + { + $result = $this->captcha->displaySubmit('test-form', 'Submit'); + $this->assertIsString($result); + $this->assertStringContainsString('test-form', $result); + $this->assertStringContainsString('Submit', $result); + } +} \ No newline at end of file diff --git a/tests/HCaptchaTest.php b/tests/HCaptchaTest.php index eea79b8..8697848 100644 --- a/tests/HCaptchaTest.php +++ b/tests/HCaptchaTest.php @@ -1,23 +1,26 @@ captcha = new hCaptcha('{secret-key}', '{site-key}'); + $this->captcha = new HCaptcha('{secret-key}', '{site-key}'); } public function testJsLink() { - $this->assertTrue($this->captcha instanceof hCaptcha); + $this->assertInstanceOf(HCaptcha::class, $this->captcha); $simple = '' . "\n"; $withLang = '' . "\n"; @@ -30,7 +33,7 @@ public function testJsLink() public function testDisplay() { - $this->assertTrue($this->captcha instanceof hCaptcha); + $this->assertInstanceOf(HCaptcha::class, $this->captcha); $simple = '
'; $withAttrs = '
'; @@ -41,7 +44,7 @@ public function testDisplay() public function testdisplaySubmit() { - $this->assertTrue($this->captcha instanceof hCaptcha); + $this->assertInstanceOf(HCaptcha::class, $this->captcha); $javascript = ''; $simple = ''; @@ -54,7 +57,7 @@ public function testdisplaySubmit() public function testdisplaySubmitWithCustomCallback() { - $this->assertTrue($this->captcha instanceof hCaptcha); + $this->assertInstanceOf(HCaptcha::class, $this->captcha); $withAttrs = '';