PluginProbe
The Innovative Form Builder – IvyForms / 0.8.1
The Innovative Form Builder – IvyForms v0.8.1
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / backend / src / Services / Security / Recaptcha / RecaptchaService.php

RecaptchaService.php in The Innovative Form Builder – IvyForms 0.8.1, at backend/src/Services/Security/Recaptcha/RecaptchaService.php

421 lines 12.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * @copyright © Melograno Venture Studio. All rights reserved.
5 * @licence See COPYING.md for license details.
6 */
7
8 namespace IvyForms\Services\Security\Recaptcha;
9
10 // phpcs:disable PSR1.Files.SideEffects
11 if (!defined('ABSPATH')) {
12 exit; // Exit if accessed directly
13 }
14
15 use IvyForms\Common\Exceptions\ForbiddenException;
16 use IvyForms\Services\Security\Common\CaptchaServiceInterface;
17 use IvyForms\Services\Security\Common\IpDetectionService;
18 use IvyForms\Services\Settings\SettingsService;
19 use IvyForms\Services\Translations\BackendStrings;
20
21 /**
22 * Class RecaptchaService
23 *
24 * @package IvyForms\Services\Security
25 */
26 class RecaptchaService implements CaptchaServiceInterface
27 {
28 private const GOOGLE_RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
29 private const GOOGLE_RECAPTCHA_SCRIPT_URL = 'https://www.google.com/recaptcha/api.js';
30 private const MIN_SCORE_V3 = 0.5; // Minimum score for reCAPTCHA v3
31
32 private SettingsService $settingsService;
33
34 public function __construct(SettingsService $settingsService)
35 {
36 $this->settingsService = $settingsService;
37 }
38
39 /**
40 * Verify reCAPTCHA response with Google
41 *
42 * @param string $response The reCAPTCHA response token
43 * @param string $secretKey The secret key
44 * @param string $type The reCAPTCHA type (v2, v3, invisible)
45 * @param string $userIp Optional user IP address
46 * @return bool
47 */
48 public function verify(string $response, string $secretKey, string $type = 'v2', string $userIp = ''): bool
49 {
50 if (empty($response) || empty($secretKey)) {
51 return false;
52 }
53
54 $verificationResult = $this->callGoogleApi($response, $secretKey, $userIp);
55
56 if (!$verificationResult) {
57 return false;
58 }
59
60 // For reCAPTCHA v3, also check the score
61 if ($type === 'v3') {
62 return $this->validateV3Score($verificationResult);
63 }
64
65 return $verificationResult['success'] === true;
66 }
67
68 /**
69 * Call Google reCAPTCHA API for verification
70 *
71 * @param string $response
72 * @param string $secretKey
73 * @param string $userIp
74 * @return array<string, mixed>|false
75 */
76 private function callGoogleApi(string $response, string $secretKey, string $userIp = '')
77 {
78 $postData = [
79 'secret' => $secretKey,
80 'response' => $response,
81 ];
82
83 if (!empty($userIp)) {
84 $postData['remoteip'] = $userIp;
85 }
86
87 $args = [
88 'body' => $postData,
89 'method' => 'POST',
90 'timeout' => 15,
91 'headers' => [
92 'Content-Type' => 'application/x-www-form-urlencoded',
93 ],
94 ];
95
96 $response = wp_remote_post(self::GOOGLE_RECAPTCHA_VERIFY_URL, $args);
97
98 if (is_wp_error($response)) {
99 $exceptionStrings = BackendStrings::getExceptionStrings();
100 error_log(sprintf($exceptionStrings['recaptcha_error_verification'], $response->get_error_message()));
101 return false;
102 }
103
104 $responseCode = wp_remote_retrieve_response_code($response);
105 if ($responseCode !== 200) {
106 $exceptionStrings = BackendStrings::getExceptionStrings();
107 error_log(sprintf($exceptionStrings['recaptcha_error_http_code'], $responseCode));
108 return false;
109 }
110
111 $body = wp_remote_retrieve_body($response);
112 $result = json_decode($body, true);
113
114 if (json_last_error() !== JSON_ERROR_NONE) {
115 $exceptionStrings = BackendStrings::getExceptionStrings();
116 error_log($exceptionStrings['recaptcha_error_invalid_json']);
117 return false;
118 }
119
120 return $result;
121 }
122
123 /**
124 * Validate reCAPTCHA v3 score
125 *
126 * @param array<string, mixed> $verificationResult
127 * @return bool
128 */
129 private function validateV3Score(array $verificationResult): bool
130 {
131 $isSuccess = $verificationResult['success'] ?? false;
132 $score = (float) ($verificationResult['score'] ?? 0);
133 $minScore = apply_filters('ivyforms_recaptcha_v3_min_score', self::MIN_SCORE_V3);
134
135 return $isSuccess === true && $score >= $minScore;
136 }
137
138 /**
139 * Get the appropriate reCAPTCHA script URL based on type
140 *
141 * @param string $type The reCAPTCHA type (v2, v3, invisible)
142 * @param string $siteKey The site key
143 * @param string $language Optional language code (default: 'en')
144 * @return string The complete script URL
145 */
146 public function getScriptUrl(string $type, string $siteKey, string $language = 'en'): string
147 {
148 $baseUrl = self::GOOGLE_RECAPTCHA_SCRIPT_URL;
149
150 if ($type === 'v3') {
151 return $baseUrl . '?render=' . $siteKey . "&hl=" . $language;
152 }
153
154 $url = $baseUrl . '?render=explicit&hl=' . $language;
155 return !empty($siteKey) ? $url . '&onload=onRecaptchaLoad' : $url;
156 }
157
158 /**
159 * Get reCAPTCHA configuration for frontend
160 *
161 * @param string $type
162 * @param string $siteKey
163 * @return array<string, mixed>
164 */
165 public function getFrontendConfig(string $type, string $siteKey): array
166 {
167 $settings = $this->getSettings();
168 $language = $settings['language'] ?? 'en';
169
170 return [
171 'type' => $type,
172 'siteKey' => $siteKey,
173 'scriptUrl' => $this->getScriptUrl($type, $siteKey, $language),
174 'size' => $type === 'invisible' ? 'invisible' : 'normal',
175 ];
176 }
177
178 /**
179 * Validate form submission with reCAPTCHA
180 *
181 * @param array<string, mixed> $submissionData Raw form submission data
182 * @param array<object> $formFields Array of form field objects
183 * @return bool
184 * @throws ForbiddenException If reCAPTCHA validation fails
185 */
186 public function validateFormSubmission(array $submissionData, array $formFields): bool
187 {
188 // Find reCAPTCHA field in the form
189 $recaptchaFieldInfo = $this->findRecaptchaField($formFields);
190
191 // If no reCAPTCHA field, skip validation
192 if (!$recaptchaFieldInfo['hasField']) {
193 return true;
194 }
195
196 // Check if reCAPTCHA is configured
197 if (!$this->isConfigured()) {
198 if (defined('WP_DEBUG') && WP_DEBUG) {
199 $exceptionStrings = BackendStrings::getExceptionStrings();
200 error_log($exceptionStrings['recaptcha_error_not_configured']);
201 }
202 return true;
203 }
204
205 // Get and validate reCAPTCHA token
206 $recaptchaToken = $this->extractRecaptchaToken($submissionData, $recaptchaFieldInfo['fieldKey']);
207
208 // Perform validation
209 return $this->performTokenValidation($recaptchaToken);
210 }
211
212 /**
213 * Find reCAPTCHA field in form fields
214 *
215 * @param array<object> $formFields
216 * @return array{hasField: bool, fieldKey: string}
217 */
218 private function findRecaptchaField(array $formFields): array
219 {
220 foreach ($formFields as $field) {
221 if ($field->getType() === 'recaptcha') {
222 return [
223 'hasField' => true,
224 'fieldKey' => 'recaptcha_' . $field->getIndex()
225 ];
226 }
227 }
228
229 return ['hasField' => false, 'fieldKey' => ''];
230 }
231
232 /**
233 * Extract reCAPTCHA token from submission data
234 *
235 * @param array<string, mixed> $submissionData
236 * @param string $fieldKey
237 * @return string
238 * @throws ForbiddenException If token is missing
239 */
240 private function extractRecaptchaToken(array $submissionData, string $fieldKey): string
241 {
242 $recaptchaToken = $submissionData['values'][$fieldKey] ?? '';
243
244 if (empty($recaptchaToken)) {
245 $strings = BackendStrings::getSecurityStrings();
246 throw new ForbiddenException($strings['recaptcha_verification_required']);
247 }
248
249 return $recaptchaToken;
250 }
251
252 /**
253 * Perform token validation with Google API
254 *
255 * @param string $recaptchaToken
256 * @return bool
257 * @throws ForbiddenException If validation fails
258 */
259 private function performTokenValidation(string $recaptchaToken): bool
260 {
261 $userIp = IpDetectionService::getUserIpAddress();
262 $isValid = $this->validateResponse($recaptchaToken, $userIp);
263
264 if ($isValid) {
265 return true;
266 }
267
268 $this->logValidationFailure($recaptchaToken);
269 $strings = BackendStrings::getSecurityStrings();
270 throw new ForbiddenException($strings['recaptcha_verification_failed']);
271 }
272
273 /**
274 * Log validation failure in debug mode
275 *
276 * @param string $recaptchaToken
277 * @return void
278 */
279 private function logValidationFailure(string $recaptchaToken): void
280 {
281 if (defined('WP_DEBUG') && WP_DEBUG) {
282 $exceptionStrings = BackendStrings::getExceptionStrings();
283 error_log(
284 sprintf(
285 $exceptionStrings['recaptcha_error_validation_failed'],
286 substr($recaptchaToken, 0, 20) . '...'
287 )
288 );
289 }
290 }
291
292
293 /**
294 * Validate reCAPTCHA key format
295 *
296 * @param string $key
297 * @return bool
298 */
299 public static function validateKeyFormat(string $key): bool
300 {
301 return RecaptchaCredentialsValidator::validateKeyFormat($key);
302 }
303
304 /**
305 * Validate reCAPTCHA credentials by testing with Google API
306 *
307 * @param string $siteKey
308 * @param string $secretKey
309 * @return array{success: bool, message: string}
310 */
311 public function validateCredentials(string $siteKey, string $secretKey): array
312 {
313 // Validate key formats first
314 $formatValidation = RecaptchaCredentialsValidator::validateKeyFormats($siteKey, $secretKey);
315 if (!$formatValidation['success']) {
316 return $formatValidation;
317 }
318
319 // Test keys with Google API using dummy verification
320 $testResponse = 'test-validation-token-' . uniqid();
321 $verificationResult = $this->callGoogleApi($testResponse, $secretKey);
322
323 return RecaptchaCredentialsValidator::processVerificationResult($verificationResult);
324 }
325
326 /**
327 * Get reCAPTCHA settings
328 *
329 * @return array<string, mixed>
330 */
331 public function getSettings(): array
332 {
333 $settings = $this->settingsService->getSetting('security', 'recaptcha');
334
335 return [
336 'type' => $settings['type'] ?? 'v2',
337 'siteKey' => $settings['siteKey'] ?? '',
338 'secretKey' => $settings['secretKey'] ?? '',
339 'language' => $settings['language'] ?? '',
340 ];
341 }
342
343 /**
344 * Check if reCAPTCHA is configured
345 *
346 * @return bool
347 */
348 public function isConfigured(): bool
349 {
350 $settings = $this->getSettings();
351 return !empty($settings['siteKey']) && !empty($settings['secretKey']);
352 }
353
354 /**
355 * Get reCAPTCHA site key for frontend
356 *
357 * @return string
358 */
359 public function getSiteKey(): string
360 {
361 $settings = $this->getSettings();
362 return $settings['siteKey'] ?? '';
363 }
364
365 /**
366 * Get reCAPTCHA type
367 *
368 * @return string
369 */
370 public function getType(): string
371 {
372 $settings = $this->getSettings();
373 return $settings['type'] ?? 'v2';
374 }
375
376 /**
377 * Validate reCAPTCHA response (main validation method)
378 *
379 * @param string $response
380 * @param string $userIp
381 * @return bool
382 */
383 public function validateResponse(string $response, string $userIp = ''): bool
384 {
385 if (!$this->isConfigured()) {
386 return true; // If not configured, pass validation
387 }
388
389 $settings = $this->getSettings();
390
391 return $this->verify(
392 $response,
393 $settings['secretKey'],
394 $settings['type'],
395 $userIp
396 );
397 }
398
399 /**
400 * Validate settings for this CAPTCHA provider
401 *
402 * @param array<string, mixed> $settings The settings to validate
403 * @return array{success: bool, message: string}
404 */
405 public function validateSettings(array $settings): array
406 {
407 $siteKey = $settings['siteKey'] ?? '';
408 $secretKey = $settings['secretKey'] ?? '';
409
410 if (empty($siteKey) || empty($secretKey)) {
411 $strings = BackendStrings::getSecurityStrings();
412 return [
413 'success' => false,
414 'message' => $strings['recaptcha_keys_required']
415 ];
416 }
417
418 return $this->validateCredentials($siteKey, $secretKey);
419 }
420 }
421