PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / trunk
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More vtrunk
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.7 2.6.8 2.6.5 2.6.4 2.6.3 2.6.2 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 trunk 1.0 1.0.1 1.0.2 1.0.3 1.1 1.1.1 1.1.2 1.2.0 2.0 2.1.0 2.1.1 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1
reviews-feed / class / Common / Utils / EmailVerification.php
reviews-feed / class / Common / Utils Last commit date
AjaxUtil.php 6 months ago EmailVerification.php 4 months ago FreeRetriever.php 2 months ago
EmailVerification.php
430 lines
1 <?php
2
3 /**
4 * Summary of namespace SmashBalloon\Reviews\Common\Utils
5 */
6
7 namespace SmashBalloon\Reviews\Common\Utils;
8
9 use SmashBalloon\Reviews\Common\Integrations\SBRelay;
10 use Smashballoon\Stubs\Services\ServiceProvider;
11
12 /**
13 * Summary of EmailVerification
14 */
15 class EmailVerification extends ServiceProvider
16 {
17 /**
18 * Email Verification Data /
19 * @var string
20 */
21 public static $email_opt_name = 'sbr_email_verification';
22
23 /**
24 * Get Email Verification Options
25 *
26 * @return array
27 */
28 public static function get_email_verification_settings()
29 {
30 return get_option(self::$email_opt_name, []);
31 }
32
33 /**
34 * Get the centralized verification error message
35 *
36 * Single source of truth for the error message shown when
37 * email verification fails.
38 *
39 * @return string
40 */
41 public static function get_verification_error_message(): string
42 {
43 return __('Email verification failed. Please try the verification process again. If the problem persists, contact support.', 'reviews-feed');
44 }
45
46 /**
47 * Summary of catch_email_verification
48 *
49 * Catches email verification parameters from redirect URL.
50 * Uses server-side validation as fallback when nonce fails
51 * (e.g., due to session expiry during email verification).
52 *
53 * @return bool
54 */
55 public static function catch_email_verification()
56 {
57 if (!is_admin()) {
58 return false;
59 }
60
61 // Required parameters must be present
62 if (empty($_GET['sbr_email_token']) || empty($_GET['verified_email'])) {
63 return false;
64 }
65
66 $email = sanitize_email($_GET['verified_email']);
67 $token = sanitize_text_field($_GET['sbr_email_token']);
68
69 // Check if already verified with these credentials to avoid redundant API calls
70 // This prevents unnecessary relay calls on page refresh when nonce is expired
71 $current_settings = self::get_email_verification_settings();
72 if (
73 !empty($current_settings['email'])
74 && $current_settings['email'] === $email
75 && !empty($current_settings['token'])
76 && $current_settings['token'] === $token
77 ) {
78 return true;
79 }
80
81 // Validate email format
82 if (!is_email($email)) {
83 self::log_verification_attempt($email, 'invalid_email_format');
84 return false;
85 }
86
87 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
88 $nonce = !empty($_GET['con_nonce'])
89 ? sanitize_text_field(wp_unslash($_GET['con_nonce']))
90 : '';
91
92 $nonce_valid = wp_verify_nonce($nonce, 'sbr_con');
93
94 // If nonce is valid, proceed with verification
95 if ($nonce_valid) {
96 self::save_verification($email, $token);
97 self::log_verification_attempt($email, 'success_nonce');
98 return true;
99 }
100
101 // Nonce failed - use server-side validation as fallback
102 self::log_verification_attempt($email, 'nonce_failed_trying_relay');
103
104 $relay_valid = self::validate_token_with_relay($email);
105
106 if ($relay_valid) {
107 self::save_verification($email, $token);
108 self::log_verification_attempt($email, 'success_relay_fallback');
109 return true;
110 }
111
112 // Both validations failed - fail closed
113 // Error is displayed via React (emailVerificationError in builder data)
114 self::log_verification_attempt($email, 'both_validations_failed');
115
116 return false;
117 }
118
119 /**
120 * Save verified email and token to options
121 *
122 * @param string $email
123 * @param string $token
124 * @return void
125 */
126 private static function save_verification(string $email, string $token): void
127 {
128 update_option(
129 self::$email_opt_name,
130 [
131 'email' => $email,
132 'token' => $token
133 ]
134 );
135 }
136
137 /**
138 * Validate email ownership with sb-relay API
139 *
140 * Authenticates via Bearer token and verifies the email is associated
141 * with the authenticated site. The token is never sent in the request
142 * body — only used for Bearer auth — preventing token oracle attacks.
143 *
144 * @param string $email
145 * @return bool
146 */
147 private static function validate_token_with_relay(string $email): bool
148 {
149 // Debounce: skip if already attempted in the last 60 seconds
150 // Prevents hammering the relay on rapid page refreshes with expired nonce.
151 // Key is scoped per WP user — a failed attempt by admin A must NOT
152 // block admin B from performing their own fallback validation on the
153 // same site. (Sentry MEDIUM on PR #435.)
154 if (get_transient(self::fallback_transient_key())) {
155 return false;
156 }
157
158 try {
159 $relay = new SBRelay();
160
161 // Authenticate via Bearer token (access_token from settings)
162 // The token is NOT sent in the body — only email is sent
163 $response = $relay->call(
164 'email/validate-token',
165 [
166 'email' => $email,
167 ],
168 'POST',
169 true // Bearer auth required — token validates via Authorization header
170 );
171
172 // Check for successful validation.
173 // Relay's respondWithSuccess() merges payload into the top level —
174 // it does NOT nest under a "data" key. So the actual shape is:
175 // { "message": "OK", "success": true, "valid": true }
176 // (Verified live against /api/v1.0/email/validate-token — earlier
177 // `data.valid` lookup silently returned false, breaking the entire
178 // fallback validation flow when the WP nonce expired.)
179 if (
180 isset($response['success'])
181 && $response['success'] === true
182 && isset($response['valid'])
183 && $response['valid'] === true
184 ) {
185 return true;
186 }
187
188 // Log the failure reason and cache to prevent repeated calls
189 $error_id = $response['id'] ?? 'unknown';
190 self::log_verification_attempt($email, 'relay_validation_failed: ' . $error_id);
191 set_transient(self::fallback_transient_key(), true, MINUTE_IN_SECONDS);
192
193 return false;
194 } catch (\Exception $e) {
195 self::log_verification_attempt($email, 'relay_exception: ' . $e->getMessage());
196 set_transient(self::fallback_transient_key(), true, MINUTE_IN_SECONDS);
197 return false;
198 }
199 }
200
201 /**
202 * Transient key prefix for fallback validation debounce.
203 *
204 * The actual key is per-user — see fallback_transient_key(). A single
205 * site-wide key (the original form) let one admin's failed attempt
206 * lock out every other admin for the transient's lifetime.
207 * (Sentry MEDIUM on PR #435.)
208 *
209 * @var string
210 */
211 private static $fallback_transient_prefix = 'sbr_fallback_validation_checked';
212
213 /**
214 * Build the per-user fallback-validation transient key.
215 *
216 * `get_current_user_id()` returns 0 for unauthenticated contexts (cron,
217 * REST endpoints hit without auth). Those paths never reach this code
218 * in practice, but a shared `_0` bucket would just mirror the old
219 * single-key behavior — no worse than today.
220 *
221 * @return string
222 */
223 private static function fallback_transient_key(): string
224 {
225 return self::$fallback_transient_prefix . '_' . get_current_user_id();
226 }
227
228 /**
229 * Log verification attempt (PII-safe)
230 *
231 * Hashes email for privacy protection in logs.
232 *
233 * @param string $email
234 * @param string $status
235 * @return void
236 */
237 private static function log_verification_attempt(string $email, string $status): void
238 {
239 if (!defined('WP_DEBUG') || !WP_DEBUG) {
240 return;
241 }
242
243 $hashed_email = self::hash_email_for_log($email);
244
245 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
246 error_log(sprintf(
247 '[SBR Email Verification] email_hash=%s status=%s',
248 $hashed_email,
249 $status
250 ));
251 }
252
253 /**
254 * Hash email for logging (PII protection)
255 *
256 * @param string $email
257 * @return string
258 */
259 private static function hash_email_for_log(string $email): string
260 {
261 return substr(hash('sha256', $email), 0, 8);
262 }
263
264 /**
265 * Build Email Verification URL
266 *
267 * @return string
268 */
269 public static function build_email_verification_url($current_page = false)
270 {
271 $settings = get_option('sbr_settings', []);
272 if (!is_array($settings)) {
273 $settings = [];
274 }
275 $args = [
276 'state' => $current_page !== false ? $current_page : admin_url('admin.php?page=sbr-settings'),
277 'wordpress_user' => self::get_current_email(),
278 'con_nonce' => wp_create_nonce('sbr_con'),
279 'site_token' => !empty($settings['access_token']) ? $settings['access_token'] : null
280 ];
281 return add_query_arg($args, SBR_CONNECT_SITE_URL);
282 }
283
284 /**
285 * Transient key prefix for recovery check cache.
286 *
287 * The actual key is per-user — see recovery_transient_key(). A single
288 * site-wide key (the original form) let one admin's "not verified"
289 * result block recovery checks for every other admin on the same
290 * site for 5 minutes, even if a different admin's email WAS verified.
291 * (Sentry MEDIUM on PR #435.)
292 *
293 * @var string
294 */
295 private static $recovery_transient_prefix = 'sbr_recovery_checked';
296
297 /**
298 * Build the per-user recovery-check transient key.
299 *
300 * @return string
301 */
302 private static function recovery_transient_key(): string
303 {
304 return self::$recovery_transient_prefix . '_' . get_current_user_id();
305 }
306
307 /**
308 * Check if email is already verified on relay (RECOVERY)
309 *
310 * Handles the stuck loop case where:
311 * 1. User completed verification on relay
312 * 2. sb-connect failed to redirect back to WordPress
313 * 3. WordPress doesn't have the token
314 *
315 * Performance optimizations:
316 * - Skips API call if verification URL params present (just attempted)
317 * - Caches "not verified" result for 5 minutes to avoid repeated calls
318 *
319 * Call this before redirecting to sb-connect to check if
320 * verification already completed and recover locally.
321 *
322 * @param string|null $email Email to check (defaults to current user)
323 * @return bool True if verification was recovered
324 */
325 public static function check_and_recover_verification(?string $email = null): bool
326 {
327 // Already verified locally - no recovery needed
328 if (self::check_verified()) {
329 return true;
330 }
331
332 // Skip recovery if we just attempted verification (presence of URL params)
333 // This prevents double API calls: validate-token (in catch_email_verification)
334 // followed by check-status (here)
335 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
336 if (!empty($_GET['sbr_email_token']) || !empty($_GET['verified_email'])) {
337 return false;
338 }
339
340 // Check transient cache to avoid repeated API calls on page loads.
341 // Cache lasts 5 minutes - user can clear by clicking "Verify Email" again.
342 // Per-user scoped: a "not verified" result for admin A must NOT
343 // suppress recovery attempts by admin B on the same site.
344 // (Sentry MEDIUM on PR #435.)
345 if (get_transient(self::recovery_transient_key())) {
346 return false;
347 }
348
349 $email = $email ?? self::get_current_email();
350
351 if (empty($email) || !is_email($email)) {
352 return false;
353 }
354
355 try {
356 $relay = new SBRelay();
357
358 $response = $relay->call(
359 'email/check-status',
360 ['email' => $email],
361 'POST',
362 true // Requires auth (site_token)
363 );
364
365 // Check if relay says email is already verified.
366 // Relay's respondWithSuccess() merges payload into the top level —
367 // it does NOT nest under a "data" key. Actual shape:
368 // { "success": true, "verified": true, "email": "...", "token": "..." }
369 // (Verified live against /api/v1.0/email/check-status — earlier
370 // `data.verified` lookup silently returned false, so the recovery
371 // path never recognized an already-verified email and users stayed
372 // stuck in the verification loop.)
373 if (
374 isset($response['success'])
375 && $response['success'] === true
376 && isset($response['verified'])
377 && $response['verified'] === true
378 && !empty($response['token'])
379 && !empty($response['email'])
380 ) {
381 // Recovery successful - save verification locally (sanitize relay response)
382 self::save_verification(
383 sanitize_email($response['email']),
384 sanitize_text_field($response['token'])
385 );
386 self::log_verification_attempt($email, 'recovery_success');
387 // Clear the transient since we're now verified
388 delete_transient(self::recovery_transient_key());
389 return true;
390 }
391
392 // Set transient to cache "not verified" status (5 minutes)
393 set_transient(self::recovery_transient_key(), true, 5 * MINUTE_IN_SECONDS);
394
395 return false;
396 } catch (\Exception $e) {
397 self::log_verification_attempt($email, 'recovery_exception: ' . $e->getMessage());
398 // Cache failure to avoid hammering the API
399 set_transient(self::recovery_transient_key(), true, 5 * MINUTE_IN_SECONDS);
400 return false;
401 }
402 }
403
404 /**
405 * Get Current User Email
406 *
407 * @return string
408 */
409 public static function get_current_email()
410 {
411 if (!is_user_logged_in()) {
412 return get_option('admin_email', '');
413 }
414 $current_user = wp_get_current_user();
415 return $current_user->user_email;
416 }
417
418 /**
419 * Check if it's verified
420 *
421 * @return boolean
422 */
423 public static function check_verified()
424 {
425 $data = self::get_email_verification_settings();
426 return !empty($data['email']) && !empty($data['token']);
427 }
428
429 }
430