settingsService = $settingsService; } /** * Verify reCAPTCHA response with Google * * @param string $response The reCAPTCHA response token * @param string $secretKey The secret key * @param string $type The reCAPTCHA type (v2, v3, invisible) * @param string $userIp Optional user IP address * @return bool */ public function verify(string $response, string $secretKey, string $type = 'v2', string $userIp = ''): bool { if (empty($response) || empty($secretKey)) { return false; } $verificationResult = $this->callGoogleApi($response, $secretKey, $userIp); if (!$verificationResult) { return false; } // For reCAPTCHA v3, also check the score if ($type === 'v3') { return $this->validateV3Score($verificationResult); } return $verificationResult['success'] === true; } /** * Call Google reCAPTCHA API for verification * * @param string $response * @param string $secretKey * @param string $userIp * @return array|false */ private function callGoogleApi(string $response, string $secretKey, string $userIp = '') { $postData = [ 'secret' => $secretKey, 'response' => $response, ]; if (!empty($userIp)) { $postData['remoteip'] = $userIp; } $args = [ 'body' => $postData, 'method' => 'POST', 'timeout' => 15, 'headers' => [ 'Content-Type' => 'application/x-www-form-urlencoded', ], ]; $response = wp_remote_post(self::GOOGLE_RECAPTCHA_VERIFY_URL, $args); if (is_wp_error($response)) { error_log('reCAPTCHA verification error: ' . $response->get_error_message()); return false; } $responseCode = wp_remote_retrieve_response_code($response); if ($responseCode !== 200) { error_log('reCAPTCHA verification failed with HTTP code: ' . $responseCode); return false; } $body = wp_remote_retrieve_body($response); $result = json_decode($body, true); if (json_last_error() !== JSON_ERROR_NONE) { error_log('reCAPTCHA verification: Invalid JSON response'); return false; } return $result; } /** * Validate reCAPTCHA v3 score * * @param array $verificationResult * @return bool */ private function validateV3Score(array $verificationResult): bool { if (!isset($verificationResult['success']) || $verificationResult['success'] !== true) { return false; } if (!isset($verificationResult['score'])) { return false; } $score = (float) $verificationResult['score']; // Apply filter to allow customization of minimum score $minScore = apply_filters('ivyforms_recaptcha_v3_min_score', self::MIN_SCORE_V3); return $score >= $minScore; } /** * Get the appropriate reCAPTCHA script URL based on type * * @param string $type The reCAPTCHA type (v2, v3, invisible) * @param string $siteKey The site key * @param string $language Optional language code (default: 'en') * @return string The complete script URL */ public function getScriptUrl(string $type, string $siteKey, string $language = 'en'): string { if ($type === 'v3') { // v3 uses render=site_key return self::GOOGLE_RECAPTCHA_SCRIPT_URL . '?render=' . $siteKey . "&hl=" . $language; } // v2 and invisible use render=explicit // For v2 checkbox and invisible, we still need to pass the site key as a parameter // The widget itself will use the site key when rendering $url = self::GOOGLE_RECAPTCHA_SCRIPT_URL . '?render=explicit'; if (!empty($language)) { $url .= '&hl=' . $language; } // Add site key as onload parameter for better initialization if (!empty($siteKey)) { $url .= '&onload=onRecaptchaLoad'; } return $url; } /** * Get reCAPTCHA configuration for frontend * * @param string $type * @param string $siteKey * @return array */ public function getFrontendConfig(string $type, string $siteKey): array { $settings = $this->getSettings(); $language = $settings['language'] ?? 'en'; return [ 'type' => $type, 'siteKey' => $siteKey, 'scriptUrl' => $this->getScriptUrl($type, $siteKey, $language), 'size' => $type === 'invisible' ? 'invisible' : 'normal', ]; } /** * Get reCAPTCHA error messages * * @param array $verificationResult * @return array */ public function getErrorMessages(array $verificationResult): array { if (!isset($verificationResult['error-codes'])) { return []; } $errorMessages = []; $errorCodes = $verificationResult['error-codes']; $strings = BackendStrings::getSecurityStrings(); $errorMap = [ 'missing-input-secret' => $strings['recaptcha_error_missing_secret'], 'invalid-input-secret' => $strings['recaptcha_error_invalid_secret'], 'missing-input-response' => $strings['recaptcha_error_missing_response'], 'invalid-input-response' => $strings['recaptcha_error_invalid_response'], 'bad-request' => $strings['recaptcha_error_bad_request'], 'timeout-or-duplicate' => $strings['recaptcha_error_timeout_duplicate'], ]; foreach ($errorCodes as $code) { if (isset($errorMap[$code])) { $errorMessages[] = $errorMap[$code]; continue; } // If the code isn't found in $errorMap, add the default message $errorMessages[] = sprintf($strings['recaptcha_error_unknown'], $code); } return $errorMessages; } /** * Validate form submission with reCAPTCHA * * @param array $submissionData Raw form submission data * @param array $formFields Array of form field objects * @return bool * @throws ForbiddenException If reCAPTCHA validation fails */ public function validateFormSubmission(array $submissionData, array $formFields): bool { // Find reCAPTCHA field in the form $recaptchaFieldInfo = $this->findRecaptchaField($formFields); // If no reCAPTCHA field, skip validation if (!$recaptchaFieldInfo['hasField']) { return true; } // Check if reCAPTCHA is configured if (!$this->isConfigured()) { if (defined('WP_DEBUG') && WP_DEBUG) { error_log('reCAPTCHA field present but service not configured'); } return true; } // Get and validate reCAPTCHA token $recaptchaToken = $this->extractRecaptchaToken($submissionData, $recaptchaFieldInfo['fieldKey']); // Perform validation return $this->performTokenValidation($recaptchaToken); } /** * Find reCAPTCHA field in form fields * * @param array $formFields * @return array{hasField: bool, fieldKey: string} */ private function findRecaptchaField(array $formFields): array { foreach ($formFields as $field) { if ($field->getType() === 'recaptcha') { return [ 'hasField' => true, 'fieldKey' => 'recaptcha_' . $field->getIndex() ]; } } return ['hasField' => false, 'fieldKey' => '']; } /** * Extract reCAPTCHA token from submission data * * @param array $submissionData * @param string $fieldKey * @return string * @throws ForbiddenException If token is missing */ private function extractRecaptchaToken(array $submissionData, string $fieldKey): string { $recaptchaToken = $submissionData['values'][$fieldKey] ?? ''; if (empty($recaptchaToken)) { $strings = BackendStrings::getSecurityStrings(); throw new ForbiddenException($strings['recaptcha_verification_required']); } return $recaptchaToken; } /** * Perform token validation with Google API * * @param string $recaptchaToken * @return bool * @throws ForbiddenException If validation fails */ private function performTokenValidation(string $recaptchaToken): bool { $userIp = IpDetectionService::getUserIpAddress(); $isValid = $this->validateResponse($recaptchaToken, $userIp); if (!$isValid) { if (defined('WP_DEBUG') && WP_DEBUG) { error_log('reCAPTCHA validation failed for token: ' . substr($recaptchaToken, 0, 20) . '...'); } $strings = BackendStrings::getSecurityStrings(); throw new ForbiddenException($strings['recaptcha_verification_failed']); } return true; } /** * Validate reCAPTCHA key format * * @param string $key * @return bool */ public static function validateKeyFormat(string $key): bool { return RecaptchaCredentialsValidator::validateKeyFormat($key); } /** * Validate reCAPTCHA credentials by testing with Google API * * @param string $siteKey * @param string $secretKey * @return array{success: bool, message: string} */ public function validateCredentials(string $siteKey, string $secretKey): array { // Validate key formats first $formatValidation = RecaptchaCredentialsValidator::validateKeyFormats($siteKey, $secretKey); if (!$formatValidation['success']) { return $formatValidation; } // Test keys with Google API using dummy verification $testResponse = 'test-validation-token-' . uniqid(); $verificationResult = $this->callGoogleApi($testResponse, $secretKey); return RecaptchaCredentialsValidator::processVerificationResult($verificationResult); } /** * Get reCAPTCHA settings * * @return array */ public function getSettings(): array { $settings = $this->settingsService->getSetting('security', 'recaptcha'); return [ 'type' => $settings['type'] ?? 'v2', 'siteKey' => $settings['siteKey'] ?? '', 'secretKey' => $settings['secretKey'] ?? '', 'language' => $settings['language'] ?? '', ]; } /** * Check if reCAPTCHA is configured * * @return bool */ public function isConfigured(): bool { $settings = $this->getSettings(); return !empty($settings['siteKey']) && !empty($settings['secretKey']); } /** * Get reCAPTCHA site key for frontend * * @return string */ public function getSiteKey(): string { $settings = $this->getSettings(); return $settings['siteKey'] ?? ''; } /** * Get reCAPTCHA type * * @return string */ public function getType(): string { $settings = $this->getSettings(); return $settings['type'] ?? 'v2'; } /** * Validate reCAPTCHA response (main validation method) * * @param string $response * @param string $userIp * @return bool */ public function validateResponse(string $response, string $userIp = ''): bool { if (!$this->isConfigured()) { return true; // If not configured, pass validation } $settings = $this->getSettings(); return $this->verify( $response, $settings['secretKey'], $settings['type'], $userIp ); } /** * Validate settings for this CAPTCHA provider * * @param array $settings The settings to validate * @return array{success: bool, message: string} */ public function validateSettings(array $settings): array { $siteKey = $settings['siteKey'] ?? ''; $secretKey = $settings['secretKey'] ?? ''; if (empty($siteKey) || empty($secretKey)) { $strings = BackendStrings::getSecurityStrings(); return [ 'success' => false, 'message' => $strings['recaptcha_keys_required'] ]; } return $this->validateCredentials($siteKey, $secretKey); } }