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 / HCaptcha / HCaptchaService.php

HCaptchaService.php in The Innovative Form Builder – IvyForms 0.9, at backend/src/Services/Security/HCaptcha/HCaptchaService.php

415 lines 12.2 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\HCaptcha;
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 HCaptchaService
23 *
24 * @package IvyForms\Services\Security
25 */
26 class HCaptchaService implements CaptchaServiceInterface
27 {
28 private const HCAPTCHA_VERIFY_URL = 'https://hcaptcha.com/siteverify';
29 private const HCAPTCHA_SCRIPT_URL = 'https://js.hcaptcha.com/1/api.js';
30
31 private SettingsService $settingsService;
32
33 public function __construct(SettingsService $settingsService)
34 {
35 $this->settingsService = $settingsService;
36 }
37
38 /**
39 * Verify hCaptcha response with hCaptcha API
40 *
41 * @param string $response The hCaptcha response token
42 * @param string $secretKey The secret key
43 * @param string $type The hCaptcha type (checkbox, invisible)
44 * @param string $userIp Optional user IP address
45 * @return bool
46 */
47 public function verify(string $response, string $secretKey, string $type = 'checkbox', string $userIp = ''): bool
48 {
49 if (empty($response) || empty($secretKey)) {
50 return false;
51 }
52
53 $verificationResult = $this->callHCaptchaApi($response, $secretKey, $userIp);
54
55 if (!$verificationResult) {
56 return false;
57 }
58
59 return $verificationResult['success'] === true;
60 }
61
62 /**
63 * Call hCaptcha 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 callHCaptchaApi(string $response, string $secretKey, string $userIp = '')
71 {
72 $settings = $this->getSettings();
73 $siteKey = $settings['siteKey'] ?? '';
74
75 $postData = [
76 'secret' => $secretKey,
77 'response' => $response,
78 ];
79
80 if (!empty($userIp)) {
81 $postData['remoteip'] = $userIp;
82 }
83
84 if (!empty($siteKey)) {
85 $postData['sitekey'] = $siteKey;
86 }
87
88 $args = [
89 'body' => $postData,
90 'method' => 'POST',
91 'timeout' => 15,
92 'headers' => [
93 'Content-Type' => 'application/x-www-form-urlencoded',
94 ],
95 ];
96
97 $response = wp_remote_post(self::HCAPTCHA_VERIFY_URL, $args);
98
99 if (is_wp_error($response)) {
100 error_log(sprintf(
101 BackendStrings::getExceptionStrings()['hcaptcha_error_verification'],
102 $response->get_error_message()
103 ));
104 return false;
105 }
106
107 $responseCode = wp_remote_retrieve_response_code($response);
108 if ($responseCode !== 200) {
109 error_log(sprintf(
110 BackendStrings::getExceptionStrings()['hcaptcha_error_http_code'],
111 $responseCode
112 ));
113 return false;
114 }
115
116 $body = wp_remote_retrieve_body($response);
117 $result = json_decode($body, true);
118
119 if (json_last_error() !== JSON_ERROR_NONE) {
120 error_log(BackendStrings::getExceptionStrings()['hcaptcha_error_invalid_json']);
121 return false;
122 }
123
124 return $result;
125 }
126
127 /**
128 * Get the appropriate hCaptcha script URL
129 *
130 * @param string $type The hCaptcha type (checkbox, invisible)
131 * @param string $siteKey The site key
132 * @return string The complete script URL
133 */
134 public function getScriptUrl(string $type, string $siteKey, string $language = 'auto'): string
135 {
136 return self::HCAPTCHA_SCRIPT_URL . '?render=explicit&onload=onHCaptchaLoad';
137 }
138
139 /**
140 * Get hCaptcha 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 return [
149 'type' => $type,
150 'siteKey' => $siteKey,
151 'scriptUrl' => $this->getScriptUrl($type, $siteKey),
152 ];
153 }
154
155 /**
156 * Validate hCaptcha credentials with hCaptcha API
157 *
158 * @param string $siteKey The site key to validate
159 * @param string $secretKey The secret key to validate
160 * @return array{success: bool, message: string}
161 */
162 public function validateCredentials(string $siteKey, string $secretKey): array
163 {
164 // Basic format validation first
165 if (empty($siteKey) || empty($secretKey)) {
166 return [
167 'success' => false,
168 'message' => BackendStrings::getExceptionStrings()['hcaptcha_keys_incomplete'],
169 ];
170 }
171
172 // hCaptcha keys validation - check format
173 if (!$this->isValidKeyFormat($siteKey, 'site') || !$this->isValidKeyFormat($secretKey, 'secret')) {
174 return [
175 'success' => false,
176 'message' => BackendStrings::getExceptionStrings()['hcaptcha_invalid_key_format'],
177 ];
178 }
179
180 // Note: Unlike reCAPTCHA, hCaptcha doesn't provide a simple credentials validation endpoint
181 // We can only verify the format here. Actual validation happens during form submission.
182 return [
183 'success' => true,
184 'message' => BackendStrings::getExceptionStrings()['hcaptcha_keys_valid'],
185 ];
186 }
187
188 /**
189 * Validate hCaptcha key format
190 *
191 * hCaptcha keys typically follow these formats:
192 * - Site keys: UUID-like format (e.g., 10000000-ffff-ffff-ffff-000000000001)
193 * - Secret keys: "0x" followed by 38-40 hex characters (40-42 chars total)
194 *
195 * @param string $key
196 * @param string $keyType Either 'site' or 'secret'
197 * @return bool
198 */
199 private function isValidKeyFormat(string $key, string $keyType = 'site'): bool
200 {
201 if ($keyType === 'site') {
202 // Site keys are UUID-like: 8-4-4-4-12 hex characters separated by hyphens
203 $pattern = '/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-'
204 . '[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/';
205 return preg_match($pattern, $key) === 1;
206 }
207
208 // Secret keys start with "0x" followed by hex characters (40-42 chars total)
209 return preg_match('/^0x[0-9a-fA-F]{38,40}$/', $key) === 1;
210 }
211
212 /**
213 * Check if hCaptcha 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 hCaptcha type from settings
237 *
238 * @return string
239 */
240 public function getType(): string
241 {
242 $settings = $this->getSettings();
243 return $settings['type'] ?? 'checkbox';
244 }
245
246 /**
247 * Get hCaptcha settings
248 *
249 * @return array<string, mixed>
250 */
251 public function getSettings(): array
252 {
253 try {
254 return $this->settingsService->getSetting('security', 'hcaptcha') ?? [];
255 } catch (\Exception $e) {
256 error_log(sprintf(
257 BackendStrings::getExceptionStrings()['hcaptcha_error_loading_settings'],
258 $e->getMessage()
259 ));
260 return [];
261 }
262 }
263
264 /**
265 * Validate form submission with hCaptcha
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 hCaptcha field in the form
275 $hcaptchaFieldInfo = $this->findHCaptchaField($formFields);
276
277 // If no hCaptcha field, skip validation
278 if (!$hcaptchaFieldInfo['hasField']) {
279 return true;
280 }
281
282 // Check if hCaptcha is configured
283 if (!$this->isConfigured()) {
284 throw new ForbiddenException(
285 BackendStrings::getExceptionStrings()['hcaptcha_not_configured']
286 );
287 }
288
289 // Get and validate hCaptcha token
290 $hcaptchaToken = $this->extractHCaptchaToken($submissionData, $hcaptchaFieldInfo['fieldKey']);
291
292 // Perform validation
293 return $this->performTokenValidation($hcaptchaToken);
294 }
295
296 /**
297 * Find hCaptcha field in form fields
298 *
299 * @param array<object> $formFields
300 * @return array{hasField: bool, fieldKey: string}
301 */
302 private function findHCaptchaField(array $formFields): array
303 {
304 foreach ($formFields as $field) {
305 if ($field->getType() === 'hcaptcha') {
306 return [
307 'hasField' => true,
308 'fieldKey' => 'hcaptcha_' . $field->getIndex()
309 ];
310 }
311 }
312
313 return ['hasField' => false, 'fieldKey' => ''];
314 }
315
316 /**
317 * Extract hCaptcha 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 extractHCaptchaToken(array $submissionData, string $fieldKey): string
325 {
326 $hcaptchaToken = $submissionData['values'][$fieldKey] ?? '';
327
328 if (empty($hcaptchaToken)) {
329 throw new ForbiddenException(
330 BackendStrings::getExceptionStrings()['hcaptcha_response_required']
331 );
332 }
333
334 return $hcaptchaToken;
335 }
336
337 /**
338 * Perform token validation with hCaptcha API
339 *
340 * @param string $hcaptchaToken
341 * @return bool
342 * @throws ForbiddenException If validation fails
343 */
344 private function performTokenValidation(string $hcaptchaToken): bool
345 {
346 // Get settings
347 $settings = $this->getSettings();
348 $secretKey = $settings['secretKey'] ?? '';
349 $type = $settings['type'] ?? 'checkbox';
350
351 // Get user IP
352 $userIp = IpDetectionService::getUserIpAddress();
353
354 // Verify with hCaptcha
355 $isValid = $this->verify($hcaptchaToken, $secretKey, $type, $userIp);
356
357 if (!$isValid) {
358 if (defined('WP_DEBUG') && WP_DEBUG) {
359 error_log(sprintf(
360 BackendStrings::getExceptionStrings()['hcaptcha_error_validation_failed'],
361 substr($hcaptchaToken, 0, 20) . '...'
362 ));
363 }
364 throw new ForbiddenException(
365 BackendStrings::getExceptionStrings()['hcaptcha_verification_failed']
366 );
367 }
368
369 return true;
370 }
371
372 /**
373 * Validate hCaptcha response (main validation method)
374 *
375 * @param string $response
376 * @param string $userIp
377 * @return bool
378 */
379 public function validateResponse(string $response, string $userIp = ''): bool
380 {
381 if (!$this->isConfigured()) {
382 return true; // If not configured, pass validation
383 }
384
385 $settings = $this->getSettings();
386
387 return $this->verify(
388 $response,
389 $settings['secretKey'],
390 $settings['type'] ?? 'checkbox',
391 $userIp
392 );
393 }
394
395 /**
396 * Validate settings for hCaptcha
397 *
398 * @param array<string, mixed> $settings The settings to validate
399 * @return array{success: bool, message: string}
400 */
401 public function validateSettings(array $settings): array
402 {
403 // Validate required fields
404 if (!isset($settings['siteKey']) || !isset($settings['secretKey'])) {
405 return [
406 'success' => false,
407 'message' => BackendStrings::getExceptionStrings()['hcaptcha_keys_incomplete'],
408 ];
409 }
410
411 // Validate credentials
412 return $this->validateCredentials($settings['siteKey'], $settings['secretKey']);
413 }
414 }
415