PluginProbe
The Innovative Form Builder – IvyForms / 0.8
The Innovative Form Builder – IvyForms v0.8
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 1.2 1.3
ivyforms / backend / src / Services / Security / RecaptchaService.php

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

458 lines 13.6 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;
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\Settings\SettingsService;
17 use IvyForms\Services\Translations\BackendStrings;
18
19 /**
20 * Class RecaptchaService
21 *
22 * @package IvyForms\Services\Security
23 * @SuppressWarnings(PHPMD)
24 */
25 class RecaptchaService implements CaptchaServiceInterface
26 {
27 private const GOOGLE_RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
28 private const GOOGLE_RECAPTCHA_SCRIPT_URL = 'https://www.google.com/recaptcha/api.js';
29 private const MIN_SCORE_V3 = 0.5; // Minimum score for reCAPTCHA v3
30
31 private SettingsService $settingsService;
32
33 public function __construct(SettingsService $settingsService)
34 {
35 $this->settingsService = $settingsService;
36 }
37
38 /**
39 * Verify reCAPTCHA response with Google
40 *
41 * @param string $response The reCAPTCHA response token
42 * @param string $secretKey The secret key
43 * @param string $type The reCAPTCHA type (v2, v3, invisible)
44 * @param string $userIp Optional user IP address
45 * @return bool
46 */
47 public function verify(string $response, string $secretKey, string $type = 'v2', string $userIp = ''): bool
48 {
49 if (empty($response) || empty($secretKey)) {
50 return false;
51 }
52
53 $verificationResult = $this->callGoogleApi($response, $secretKey, $userIp);
54
55 if (!$verificationResult) {
56 return false;
57 }
58
59 // For reCAPTCHA v3, also check the score
60 if ($type === 'v3') {
61 return $this->validateV3Score($verificationResult);
62 }
63
64 return $verificationResult['success'] === true;
65 }
66
67 /**
68 * Call Google reCAPTCHA API for verification
69 *
70 * @param string $response
71 * @param string $secretKey
72 * @param string $userIp
73 * @return array<string, mixed>|false
74 */
75 private function callGoogleApi(string $response, string $secretKey, string $userIp = '')
76 {
77 $postData = [
78 'secret' => $secretKey,
79 'response' => $response,
80 ];
81
82 if (!empty($userIp)) {
83 $postData['remoteip'] = $userIp;
84 }
85
86 $args = [
87 'body' => $postData,
88 'method' => 'POST',
89 'timeout' => 15,
90 'headers' => [
91 'Content-Type' => 'application/x-www-form-urlencoded',
92 ],
93 ];
94
95 $response = wp_remote_post(self::GOOGLE_RECAPTCHA_VERIFY_URL, $args);
96
97 if (is_wp_error($response)) {
98 error_log('reCAPTCHA verification error: ' . $response->get_error_message());
99 return false;
100 }
101
102 $responseCode = wp_remote_retrieve_response_code($response);
103 if ($responseCode !== 200) {
104 error_log('reCAPTCHA verification failed with HTTP code: ' . $responseCode);
105 return false;
106 }
107
108 $body = wp_remote_retrieve_body($response);
109 $result = json_decode($body, true);
110
111 if (json_last_error() !== JSON_ERROR_NONE) {
112 error_log('reCAPTCHA verification: Invalid JSON response');
113 return false;
114 }
115
116 return $result;
117 }
118
119 /**
120 * Validate reCAPTCHA v3 score
121 *
122 * @param array<string, mixed> $verificationResult
123 * @return bool
124 */
125 private function validateV3Score(array $verificationResult): bool
126 {
127 if (!isset($verificationResult['success']) || $verificationResult['success'] !== true) {
128 return false;
129 }
130
131 if (!isset($verificationResult['score'])) {
132 return false;
133 }
134
135 $score = (float) $verificationResult['score'];
136
137 // Apply filter to allow customization of minimum score
138 $minScore = apply_filters('ivyforms_recaptcha_v3_min_score', self::MIN_SCORE_V3);
139
140 return $score >= $minScore;
141 }
142
143 /**
144 * Get the appropriate reCAPTCHA script URL based on type
145 *
146 * @param string $type The reCAPTCHA type (v2, v3, invisible)
147 * @param string $siteKey The site key
148 * @param string $language Optional language code (default: 'en')
149 * @return string The complete script URL
150 */
151 public function getScriptUrl(string $type, string $siteKey, string $language = 'en'): string
152 {
153 if ($type === 'v3') {
154 // v3 uses render=site_key
155 return self::GOOGLE_RECAPTCHA_SCRIPT_URL . '?render=' . $siteKey . "&hl=" . $language;
156 }
157
158 // v2 and invisible use render=explicit
159 // For v2 checkbox and invisible, we still need to pass the site key as a parameter
160 // The widget itself will use the site key when rendering
161 $url = self::GOOGLE_RECAPTCHA_SCRIPT_URL . '?render=explicit';
162
163 if (!empty($language)) {
164 $url .= '&hl=' . $language;
165 }
166
167 // Add site key as onload parameter for better initialization
168 if (!empty($siteKey)) {
169 $url .= '&onload=onRecaptchaLoad';
170 }
171
172 return $url;
173 }
174
175 /**
176 * Get reCAPTCHA configuration for frontend
177 *
178 * @param string $type
179 * @param string $siteKey
180 * @return array<string, mixed>
181 */
182 public function getFrontendConfig(string $type, string $siteKey): array
183 {
184 $settings = $this->getSettings();
185 $language = $settings['language'] ?? 'en';
186
187 return [
188 'type' => $type,
189 'siteKey' => $siteKey,
190 'scriptUrl' => $this->getScriptUrl($type, $siteKey, $language),
191 'size' => $type === 'invisible' ? 'invisible' : 'normal',
192 ];
193 }
194
195 /**
196 * Get reCAPTCHA error messages
197 *
198 * @param array<string, mixed> $verificationResult
199 * @return array<string>
200 */
201 public function getErrorMessages(array $verificationResult): array
202 {
203 if (!isset($verificationResult['error-codes'])) {
204 return [];
205 }
206
207 $errorMessages = [];
208 $errorCodes = $verificationResult['error-codes'];
209 $strings = BackendStrings::getSecurityStrings();
210
211 $errorMap = [
212 'missing-input-secret' => $strings['recaptcha_error_missing_secret'],
213 'invalid-input-secret' => $strings['recaptcha_error_invalid_secret'],
214 'missing-input-response' => $strings['recaptcha_error_missing_response'],
215 'invalid-input-response' => $strings['recaptcha_error_invalid_response'],
216 'bad-request' => $strings['recaptcha_error_bad_request'],
217 'timeout-or-duplicate' => $strings['recaptcha_error_timeout_duplicate'],
218 ];
219
220 foreach ($errorCodes as $code) {
221 if (isset($errorMap[$code])) {
222 $errorMessages[] = $errorMap[$code];
223 continue;
224 }
225
226 // If the code isn't found in $errorMap, add the default message
227 $errorMessages[] = sprintf($strings['recaptcha_error_unknown'], $code);
228 }
229
230 return $errorMessages;
231 }
232
233 /**
234 * Validate form submission with reCAPTCHA
235 *
236 * @param array<string, mixed> $submissionData Raw form submission data
237 * @param array<object> $formFields Array of form field objects
238 * @return bool
239 * @throws ForbiddenException If reCAPTCHA validation fails
240 */
241 public function validateFormSubmission(array $submissionData, array $formFields): bool
242 {
243 // Find reCAPTCHA field in the form
244 $recaptchaFieldInfo = $this->findRecaptchaField($formFields);
245
246 // If no reCAPTCHA field, skip validation
247 if (!$recaptchaFieldInfo['hasField']) {
248 return true;
249 }
250
251 // Check if reCAPTCHA is configured
252 if (!$this->isConfigured()) {
253 if (defined('WP_DEBUG') && WP_DEBUG) {
254 error_log('reCAPTCHA field present but service not configured');
255 }
256 return true;
257 }
258
259 // Get and validate reCAPTCHA token
260 $recaptchaToken = $this->extractRecaptchaToken($submissionData, $recaptchaFieldInfo['fieldKey']);
261
262 // Perform validation
263 return $this->performTokenValidation($recaptchaToken);
264 }
265
266 /**
267 * Find reCAPTCHA field in form fields
268 *
269 * @param array<object> $formFields
270 * @return array{hasField: bool, fieldKey: string}
271 */
272 private function findRecaptchaField(array $formFields): array
273 {
274 foreach ($formFields as $field) {
275 if ($field->getType() === 'recaptcha') {
276 return [
277 'hasField' => true,
278 'fieldKey' => 'recaptcha_' . $field->getIndex()
279 ];
280 }
281 }
282
283 return ['hasField' => false, 'fieldKey' => ''];
284 }
285
286 /**
287 * Extract reCAPTCHA token from submission data
288 *
289 * @param array<string, mixed> $submissionData
290 * @param string $fieldKey
291 * @return string
292 * @throws ForbiddenException If token is missing
293 */
294 private function extractRecaptchaToken(array $submissionData, string $fieldKey): string
295 {
296 $recaptchaToken = $submissionData['values'][$fieldKey] ?? '';
297
298 if (empty($recaptchaToken)) {
299 $strings = BackendStrings::getSecurityStrings();
300 throw new ForbiddenException($strings['recaptcha_verification_required']);
301 }
302
303 return $recaptchaToken;
304 }
305
306 /**
307 * Perform token validation with Google API
308 *
309 * @param string $recaptchaToken
310 * @return bool
311 * @throws ForbiddenException If validation fails
312 */
313 private function performTokenValidation(string $recaptchaToken): bool
314 {
315 $userIp = IpDetectionService::getUserIpAddress();
316 $isValid = $this->validateResponse($recaptchaToken, $userIp);
317
318 if (!$isValid) {
319 if (defined('WP_DEBUG') && WP_DEBUG) {
320 error_log('reCAPTCHA validation failed for token: ' . substr($recaptchaToken, 0, 20) . '...');
321 }
322 $strings = BackendStrings::getSecurityStrings();
323 throw new ForbiddenException($strings['recaptcha_verification_failed']);
324 }
325
326 return true;
327 }
328
329
330 /**
331 * Validate reCAPTCHA key format
332 *
333 * @param string $key
334 * @return bool
335 */
336 public static function validateKeyFormat(string $key): bool
337 {
338 return RecaptchaCredentialsValidator::validateKeyFormat($key);
339 }
340
341 /**
342 * Validate reCAPTCHA credentials by testing with Google API
343 *
344 * @param string $siteKey
345 * @param string $secretKey
346 * @return array{success: bool, message: string}
347 */
348 public function validateCredentials(string $siteKey, string $secretKey): array
349 {
350 // Validate key formats first
351 $formatValidation = RecaptchaCredentialsValidator::validateKeyFormats($siteKey, $secretKey);
352 if (!$formatValidation['success']) {
353 return $formatValidation;
354 }
355
356 // Test keys with Google API using dummy verification
357 $testResponse = 'test-validation-token-' . uniqid();
358 $verificationResult = $this->callGoogleApi($testResponse, $secretKey);
359
360 return RecaptchaCredentialsValidator::processVerificationResult($verificationResult);
361 }
362
363 /**
364 * Get reCAPTCHA settings
365 *
366 * @return array<string, mixed>
367 */
368 public function getSettings(): array
369 {
370 $settings = $this->settingsService->getSetting('security', 'recaptcha');
371
372 return [
373 'type' => $settings['type'] ?? 'v2',
374 'siteKey' => $settings['siteKey'] ?? '',
375 'secretKey' => $settings['secretKey'] ?? '',
376 'language' => $settings['language'] ?? '',
377 ];
378 }
379
380 /**
381 * Check if reCAPTCHA is configured
382 *
383 * @return bool
384 */
385 public function isConfigured(): bool
386 {
387 $settings = $this->getSettings();
388 return !empty($settings['siteKey']) && !empty($settings['secretKey']);
389 }
390
391 /**
392 * Get reCAPTCHA site key for frontend
393 *
394 * @return string
395 */
396 public function getSiteKey(): string
397 {
398 $settings = $this->getSettings();
399 return $settings['siteKey'] ?? '';
400 }
401
402 /**
403 * Get reCAPTCHA type
404 *
405 * @return string
406 */
407 public function getType(): string
408 {
409 $settings = $this->getSettings();
410 return $settings['type'] ?? 'v2';
411 }
412
413 /**
414 * Validate reCAPTCHA response (main validation method)
415 *
416 * @param string $response
417 * @param string $userIp
418 * @return bool
419 */
420 public function validateResponse(string $response, string $userIp = ''): bool
421 {
422 if (!$this->isConfigured()) {
423 return true; // If not configured, pass validation
424 }
425
426 $settings = $this->getSettings();
427
428 return $this->verify(
429 $response,
430 $settings['secretKey'],
431 $settings['type'],
432 $userIp
433 );
434 }
435
436 /**
437 * Validate settings for this CAPTCHA provider
438 *
439 * @param array<string, mixed> $settings The settings to validate
440 * @return array{success: bool, message: string}
441 */
442 public function validateSettings(array $settings): array
443 {
444 $siteKey = $settings['siteKey'] ?? '';
445 $secretKey = $settings['secretKey'] ?? '';
446
447 if (empty($siteKey) || empty($secretKey)) {
448 $strings = BackendStrings::getSecurityStrings();
449 return [
450 'success' => false,
451 'message' => $strings['recaptcha_keys_required']
452 ];
453 }
454
455 return $this->validateCredentials($siteKey, $secretKey);
456 }
457 }
458