PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / app / Services / RecaptchaService.php

RecaptchaService.php in Yatra – Travel Booking & Tour Operator Software trunk, at app/Services/RecaptchaService.php

247 lines 8.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * reCAPTCHA Service (Google reCAPTCHA v3)
4 *
5 * Loads the v3 script (invisible, score-based), verifies tokens server-side
6 * against a score threshold, and exposes per-form gating so operators can
7 * choose which Yatra forms to protect. Previously this service rendered a v2
8 * checkbox and was never wired into any form, so reCAPTCHA never actually ran.
9 *
10 * @package Yatra\Services
11 * @since 3.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace Yatra\Services;
17
18 class RecaptchaService
19 {
20 /** Default v3 score threshold (0.0 = likely bot, 1.0 = likely human). */
21 public const DEFAULT_SCORE_THRESHOLD = 0.5;
22
23 /** Form key => the setting that toggles protection for that form. */
24 private const FORM_SETTINGS = [
25 'enquiry' => 'recaptcha_protect_enquiry',
26 'booking' => 'recaptcha_protect_booking',
27 'registration' => 'recaptcha_protect_registration',
28 ];
29
30 /**
31 * reCAPTCHA is usable only when enabled AND both keys are configured.
32 */
33 public static function isEnabled(): bool
34 {
35 return SettingsService::isEnabled('recaptcha_enabled')
36 && self::getSiteKey() !== ''
37 && self::getSecretKey() !== '';
38 }
39
40 public static function getSiteKey(): string
41 {
42 return trim(SettingsService::getString('recaptcha_site_key', ''));
43 }
44
45 private static function getSecretKey(): string
46 {
47 return trim(SettingsService::getString('recaptcha_secret_key', ''));
48 }
49
50 /**
51 * v3 score threshold, clamped to [0,1] and filterable.
52 */
53 public static function scoreThreshold(): float
54 {
55 $threshold = (float) SettingsService::getFloat('recaptcha_score_threshold', self::DEFAULT_SCORE_THRESHOLD);
56 if ($threshold < 0.0 || $threshold > 1.0) {
57 $threshold = self::DEFAULT_SCORE_THRESHOLD;
58 }
59
60 return (float) apply_filters('yatra_recaptcha_score_threshold', $threshold);
61 }
62
63 /**
64 * Whether a given form (enquiry|booking|registration) is protected.
65 */
66 public static function protectsForm(string $form): bool
67 {
68 if (!self::isEnabled()) {
69 return false;
70 }
71
72 $setting = self::FORM_SETTINGS[$form] ?? '';
73 if ($setting === '') {
74 return false;
75 }
76
77 return (bool) apply_filters(
78 'yatra_recaptcha_protects_form',
79 SettingsService::isEnabled($setting),
80 $form
81 );
82 }
83
84 /**
85 * Verify a token against a form. Returns success when the form is NOT
86 * protected (no-op), so callers can always call this unconditionally.
87 *
88 * @return array{success:bool, message?:string, score?:float|null, action?:string|null}
89 */
90 public static function verifyForm(string $form, string $token, ?string $remoteIp = null): array
91 {
92 if (!self::protectsForm($form)) {
93 return ['success' => true];
94 }
95
96 return self::verify($token, $form, $remoteIp);
97 }
98
99 /**
100 * Verify a reCAPTCHA v3 token with Google (success + score + optional action).
101 *
102 * @param string $token The token from grecaptcha.execute().
103 * @param string|null $expectedAction The action the token should carry.
104 * @param string|null $remoteIp Client IP.
105 * @return array{success:bool, message?:string, score?:float|null, action?:string|null}
106 */
107 public static function verify(string $token, ?string $expectedAction = null, ?string $remoteIp = null): array
108 {
109 if (!SettingsService::isEnabled('recaptcha_enabled')) {
110 return ['success' => true, 'message' => 'reCAPTCHA is disabled'];
111 }
112
113 $secret_key = self::getSecretKey();
114 if ($secret_key === '') {
115 return ['success' => false, 'message' => __('reCAPTCHA is not fully configured.', 'yatra')];
116 }
117
118 $token = trim($token);
119 if ($token === '') {
120 return ['success' => false, 'message' => __('reCAPTCHA verification failed. Please try again.', 'yatra')];
121 }
122
123 $data = [
124 'secret' => $secret_key,
125 'response' => $token,
126 ];
127 if ($remoteIp) {
128 $data['remoteip'] = $remoteIp;
129 }
130
131 $http = wp_remote_post('https://www.google.com/recaptcha/api/siteverify', [
132 'body' => $data,
133 'timeout' => 10,
134 ]);
135
136 if (is_wp_error($http)) {
137 return ['success' => false, 'message' => __('Could not reach the reCAPTCHA service. Please try again.', 'yatra')];
138 }
139
140 $result = json_decode(wp_remote_retrieve_body($http), true);
141 if (!is_array($result) || !isset($result['success'])) {
142 return ['success' => false, 'message' => __('Invalid reCAPTCHA response.', 'yatra')];
143 }
144
145 if (empty($result['success'])) {
146 return [
147 'success' => false,
148 'message' => self::getErrorMessage($result['error-codes'] ?? []),
149 ];
150 }
151
152 $score = isset($result['score']) ? (float) $result['score'] : null;
153 $action = $result['action'] ?? null;
154
155 // v3 score gate. (A v2 token has no score; treat missing score as pass so
156 // a mistakenly-configured v2 key still validates presence.)
157 if ($score !== null && $score < self::scoreThreshold()) {
158 return [
159 'success' => false,
160 'message' => __('reCAPTCHA score too low — your request looked automated. Please try again.', 'yatra'),
161 'score' => $score,
162 'action' => $action,
163 ];
164 }
165
166 // Optional action binding (defence in depth; only enforced when both sides present).
167 if ($expectedAction !== null && $action !== null && $action !== '' && $action !== $expectedAction) {
168 return [
169 'success' => false,
170 'message' => __('reCAPTCHA action mismatch. Please try again.', 'yatra'),
171 'score' => $score,
172 'action' => $action,
173 ];
174 }
175
176 return [
177 'success' => true,
178 'message' => __('reCAPTCHA verification successful', 'yatra'),
179 'score' => $score,
180 'action' => $action,
181 ];
182 }
183
184 /**
185 * Map Google error codes to a friendly message.
186 */
187 private static function getErrorMessage(array $errorCodes): string
188 {
189 if (empty($errorCodes)) {
190 return __('reCAPTCHA verification failed. Please try again.', 'yatra');
191 }
192
193 $messages = [
194 'missing-input-secret' => __('The reCAPTCHA secret key is missing.', 'yatra'),
195 'invalid-input-secret' => __('The reCAPTCHA secret key is invalid or malformed.', 'yatra'),
196 'missing-input-response' => __('reCAPTCHA verification failed. Please try again.', 'yatra'),
197 'invalid-input-response' => __('reCAPTCHA verification failed. Please try again.', 'yatra'),
198 'bad-request' => __('The reCAPTCHA request was invalid or malformed.', 'yatra'),
199 'timeout-or-duplicate' => __('The reCAPTCHA response expired. Please try again.', 'yatra'),
200 ];
201
202 return $messages[$errorCodes[0]] ?? __('reCAPTCHA verification failed. Please try again.', 'yatra');
203 }
204
205 /**
206 * Enqueue the reCAPTCHA v3 script + Yatra helper on the frontend.
207 * Hooked on wp_enqueue_scripts; self-guards on isEnabled().
208 */
209 public static function enqueueScript(): void
210 {
211 if (!self::isEnabled()) {
212 return;
213 }
214
215 // Skip in wp-admin (v3 badge / tokens are for public forms).
216 if (is_admin()) {
217 return;
218 }
219
220 wp_enqueue_script(
221 'google-recaptcha',
222 'https://www.google.com/recaptcha/api.js?render=' . rawurlencode(self::getSiteKey()),
223 [],
224 null,
225 true
226 );
227
228 wp_enqueue_script(
229 'yatra-recaptcha',
230 YATRA_PLUGIN_URL . 'assets/js/recaptcha.js',
231 ['google-recaptcha'],
232 defined('YATRA_VERSION') ? YATRA_VERSION : false,
233 true
234 );
235
236 wp_localize_script('yatra-recaptcha', 'yatraRecaptcha', [
237 'siteKey' => self::getSiteKey(),
238 'enabled' => true,
239 'forms' => [
240 'enquiry' => self::protectsForm('enquiry'),
241 'booking' => self::protectsForm('booking'),
242 'registration' => self::protectsForm('registration'),
243 ],
244 ]);
245 }
246 }
247