PluginProbe
The Innovative Form Builder – IvyForms / 1.5.1
The Innovative Form Builder – IvyForms v1.5.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 1.5.1, at backend/src/Services/Security/Recaptcha/RecaptchaService.php

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