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 / Turnstile / TurnstileService.php

TurnstileService.php in The Innovative Form Builder – IvyForms 1.5.1, at backend/src/Services/Security/Turnstile/TurnstileService.php

386 lines 11.7 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\Turnstile;
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 TurnstileService
18 *
19 * @package IvyForms\Services\Security
20 */
21 class TurnstileService implements CaptchaServiceInterface
22 {
23 private const CLOUDFLARE_TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
24 private const CLOUDFLARE_TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
25
26 private SettingsService $settingsService;
27
28 public function __construct(SettingsService $settingsService)
29 {
30 $this->settingsService = $settingsService;
31 }
32
33 /**
34 * Verify Turnstile response with Cloudflare
35 *
36 * @param string $response The Turnstile response token
37 * @param string $secretKey The secret key
38 * @param string $type The Turnstile type (deprecated, kept for compatibility)
39 * @param string $userIp Optional user IP address
40 * @return bool
41 */
42 public function verify(string $response, string $secretKey, string $type = '', string $userIp = ''): bool
43 {
44 if (empty($response) || empty($secretKey)) {
45 return false;
46 }
47
48 $verificationResult = $this->callCloudflareApi($response, $secretKey, $userIp);
49
50 if (!$verificationResult) {
51 return false;
52 }
53
54 return $verificationResult['success'] === true;
55 }
56
57 /**
58 * Call Cloudflare Turnstile API for verification
59 *
60 * @param string $response
61 * @param string $secretKey
62 * @param string $userIp
63 * @return array<string, mixed>|false
64 */
65 private function callCloudflareApi(string $response, string $secretKey, string $userIp = '')
66 {
67 $postData = [
68 'secret' => $secretKey,
69 'response' => $response,
70 ];
71
72 if (!empty($userIp)) {
73 $postData['remoteip'] = $userIp;
74 }
75
76 $args = [
77 'body' => $postData,
78 'method' => 'POST',
79 'timeout' => 15,
80 'headers' => [
81 'Content-Type' => 'application/x-www-form-urlencoded',
82 ],
83 ];
84
85 $response = wp_remote_post(self::CLOUDFLARE_TURNSTILE_VERIFY_URL, $args);
86
87 if (is_wp_error($response)) {
88 error_log(sprintf(
89 BackendStrings::getExceptionStrings()['turnstile_error_verification'],
90 $response->get_error_message()
91 ));
92 return false;
93 }
94
95 $responseCode = wp_remote_retrieve_response_code($response);
96 if ($responseCode !== 200) {
97 error_log(sprintf(
98 BackendStrings::getExceptionStrings()['turnstile_error_http_code'],
99 $responseCode
100 ));
101 return false;
102 }
103
104 $body = wp_remote_retrieve_body($response);
105 $result = json_decode($body, true);
106
107 if (json_last_error() !== JSON_ERROR_NONE) {
108 error_log(BackendStrings::getExceptionStrings()['turnstile_error_invalid_json']);
109 return false;
110 }
111
112 return $result;
113 }
114
115 /**
116 * Get the appropriate Turnstile script URL
117 *
118 * @param string $type The Turnstile type (deprecated, kept for compatibility)
119 * @param string $siteKey The site key
120 * @param string $language Optional language code (default: 'auto')
121 * @return string The complete script URL
122 */
123 public function getScriptUrl(string $type, string $siteKey, string $language = 'auto'): string
124 {
125 $url = self::CLOUDFLARE_TURNSTILE_SCRIPT_URL . '?render=explicit';
126
127 if (!empty($language) && $language !== 'auto') {
128 $url .= '&hl=' . $language;
129 }
130
131 return $url;
132 }
133
134 /**
135 * Get Turnstile configuration for frontend
136 *
137 * @param string $type
138 * @param string $siteKey
139 * @return array<string, mixed>
140 */
141 public function getFrontendConfig(string $type, string $siteKey): array
142 {
143 $settings = $this->getSettings();
144 $theme = $settings['theme'] ?? 'auto';
145 $language = $settings['language'] ?? 'auto';
146
147 return [
148 'siteKey' => $siteKey,
149 'scriptUrl' => $this->getScriptUrl($type, $siteKey, $language),
150 'theme' => $theme,
151 'language' => $language,
152 'size' => 'normal',
153 ];
154 }
155
156 /**
157 * Validate Turnstile credentials with Cloudflare
158 *
159 * @param string $siteKey The site key to validate
160 * @param string $secretKey The secret key to validate
161 * @return array{success: bool, message: string}
162 */
163 public function validateCredentials(string $siteKey, string $secretKey): array
164 {
165 // Basic format validation first
166 if (empty($siteKey) || empty($secretKey)) {
167 return [
168 'success' => false,
169 'message' => BackendStrings::getExceptionStrings()['turnstile_keys_incomplete'],
170 ];
171 }
172
173 // Turnstile keys validation - check format
174 if (!$this->isValidKeyFormat($siteKey) || !$this->isValidKeyFormat($secretKey)) {
175 return [
176 'success' => false,
177 'message' => BackendStrings::getExceptionStrings()['turnstile_invalid_key_format'],
178 ];
179 }
180
181 // Note: Unlike reCAPTCHA, Cloudflare Turnstile doesn't provide a simple credentials validation endpoint
182 // We can only verify the format here. Actual validation happens during form submission.
183 return [
184 'success' => true,
185 'message' => BackendStrings::getExceptionStrings()['turnstile_keys_valid'],
186 ];
187 }
188
189 /**
190 * Validate Turnstile key format
191 *
192 * Cloudflare Turnstile keys typically follow these formats:
193 * - Production keys: "0x" followed by 38 hex characters (40 chars total)
194 * - Test site keys: "1x", "2x", or "3x" followed by hex chars (26 chars total)
195 * - Test secret keys: "1x", "2x", or "3x" followed by hex chars (35 chars total)
196 *
197 * @param string $key
198 * @return bool
199 */
200 private function isValidKeyFormat(string $key): bool
201 {
202 // Accept production keys (0x + 38 hex chars = 40 total)
203 // or test keys (1x/2x/3x + varying hex chars, typically 26 or 35 chars)
204 return preg_match('/^[0-3]x[0-9a-fA-F]{24,38}$/', $key) === 1;
205 }
206
207 /**
208 * Check if Turnstile is configured
209 *
210 * @return bool
211 */
212 public function isConfigured(): bool
213 {
214 $settings = $this->getSettings();
215
216 return !empty($settings['siteKey']) && !empty($settings['secretKey']);
217 }
218
219 /**
220 * Get site key from settings
221 *
222 * @return string
223 */
224 public function getSiteKey(): string
225 {
226 $settings = $this->getSettings();
227 return $settings['siteKey'] ?? '';
228 }
229
230 /**
231 * Get Turnstile type from settings
232 * Note: Turnstile type modes have been removed, this returns empty string for compatibility
233 *
234 * @return string
235 */
236 public function getType(): string
237 {
238 return '';
239 }
240
241 /**
242 * Get Turnstile settings
243 *
244 * @return array<string, mixed>
245 */
246 private function getSettings(): array
247 {
248 try {
249 return $this->settingsService->getSetting('security', 'turnstile') ?? [];
250 } catch (\Exception $e) {
251 error_log(sprintf(
252 BackendStrings::getExceptionStrings()['turnstile_error_loading_settings'],
253 $e->getMessage()
254 ));
255 return [];
256 }
257 }
258
259 /**
260 * Validate form submission with Turnstile
261 *
262 * @param array<string, mixed> $submissionData The form submission data
263 * @param array<object> $formFields The form field objects
264 * @return bool
265 * @throws ForbiddenException
266 */
267 public function validateFormSubmission(array $submissionData, array $formFields): bool
268 {
269 // Find Turnstile field in the form
270 $turnstileFieldInfo = $this->findTurnstileField($formFields);
271
272 // If no Turnstile field, skip validation
273 if (!$turnstileFieldInfo['hasField']) {
274 return true;
275 }
276
277 // Check if Turnstile is configured
278 if (!$this->isConfigured()) {
279 throw new ForbiddenException(
280 BackendStrings::getExceptionStrings()['turnstile_not_configured']
281 );
282 }
283
284 // Get and validate Turnstile token
285 $turnstileToken = $this->extractTurnstileToken($submissionData, $turnstileFieldInfo['fieldKey']);
286
287 // Perform validation
288 return $this->performTokenValidation($turnstileToken);
289 }
290
291 /**
292 * Find Turnstile field in form fields
293 *
294 * @param array<object> $formFields
295 * @return array{hasField: bool, fieldKey: string}
296 */
297 private function findTurnstileField(array $formFields): array
298 {
299 foreach ($formFields as $field) {
300 if ($field->getType() === 'turnstile') {
301 return [
302 'hasField' => true,
303 'fieldKey' => 'turnstile_' . $field->getIndex()
304 ];
305 }
306 }
307
308 return ['hasField' => false, 'fieldKey' => ''];
309 }
310
311 /**
312 * Extract Turnstile token from submission data
313 *
314 * @param array<string, mixed> $submissionData
315 * @param string $fieldKey
316 * @return string
317 * @throws ForbiddenException If token is missing
318 */
319 private function extractTurnstileToken(array $submissionData, string $fieldKey): string
320 {
321 $turnstileToken = $submissionData['values'][$fieldKey] ?? '';
322
323 if (empty($turnstileToken)) {
324 throw new ForbiddenException(
325 BackendStrings::getExceptionStrings()['turnstile_response_required']
326 );
327 }
328
329 return $turnstileToken;
330 }
331
332 /**
333 * Perform token validation with Cloudflare API
334 *
335 * @param string $turnstileToken
336 * @return bool
337 * @throws ForbiddenException If validation fails
338 */
339 private function performTokenValidation(string $turnstileToken): bool
340 {
341 // Get settings
342 $settings = $this->getSettings();
343 $secretKey = $settings['secretKey'] ?? '';
344
345 // Get user IP
346 $userIp = IpDetectionService::getUserIpAddress();
347
348 // Verify with Cloudflare
349 $isValid = $this->verify($turnstileToken, $secretKey, '', $userIp);
350
351 if (!$isValid) {
352 if (defined('WP_DEBUG') && WP_DEBUG) {
353 error_log(sprintf(
354 BackendStrings::getExceptionStrings()['turnstile_error_validation_failed'],
355 substr($turnstileToken, 0, 20) . '...'
356 ));
357 }
358 throw new ForbiddenException(
359 BackendStrings::getExceptionStrings()['turnstile_verification_failed']
360 );
361 }
362
363 return true;
364 }
365
366 /**
367 * Validate settings for Turnstile
368 *
369 * @param array<string, mixed> $settings The settings to validate
370 * @return array{success: bool, message: string}
371 */
372 public function validateSettings(array $settings): array
373 {
374 // Validate required fields
375 if (!isset($settings['siteKey']) || !isset($settings['secretKey'])) {
376 return [
377 'success' => false,
378 'message' => BackendStrings::getExceptionStrings()['turnstile_keys_incomplete'],
379 ];
380 }
381
382 // Validate credentials
383 return $this->validateCredentials($settings['siteKey'], $settings['secretKey']);
384 }
385 }
386