PluginProbe
The Innovative Form Builder – IvyForms / 0.9
The Innovative Form Builder – IvyForms v0.9
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 0.9, at backend/src/Services/Security/Turnstile/TurnstileService.php

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