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

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

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