PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 260927
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v260927
260927 260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 111220 All 190 releases
s2member / src / includes / classes / gateway-checkouts.inc.php

gateway-checkouts.inc.php in s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions 260927, at src/includes/classes/gateway-checkouts.inc.php

766 lines 29.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // @codingStandardsIgnoreFile
3 /**
4 * Gateway Checkout state utilities.
5 *
6 * A Gateway Checkout represents one logical customer checkout that can create
7 * either a one-time payment or a recurring subscription. Its server-side state preserves
8 * the checkout identity and recovery data across reloads, retries, redirects, and lost
9 * gateway responses, so the same checkout can be reconciled safely instead of duplicated.
10 *
11 * @package s2Member\Gateway_Checkouts
12 * @since 260829.2325
13 */
14 if(!defined('WPINC')) //260829.2325 MUST have WordPress.
15 exit ('Do not access this file directly.');
16
17 if(!class_exists('c_ws_plugin__s2member_gateway_checkouts'))
18 {
19 /**
20 * Gateway Checkout state utilities.
21 *
22 * @package s2Member\Gateway_Checkouts
23 * @since 260829.2325
24 */
25 class c_ws_plugin__s2member_gateway_checkouts
26 {
27 /**
28 * Generates a Gateway Checkout ID.
29 *
30 * @package s2Member\Gateway_Checkouts
31 * @since 260829.2325
32 *
33 * @return string Gateway Checkout ID.
34 */
35 public static function generate_id()
36 {
37 //260829.2325 Prefer WordPress UUIDs, while retaining a sufficiently unique fallback for older WordPress versions supported by s2Member.
38 return function_exists('wp_generate_uuid4') ? wp_generate_uuid4() : md5(uniqid('s2member-gateway-checkout-', TRUE).wp_rand());
39 }
40
41 /**
42 * Validates a Gateway Checkout ID.
43 *
44 * @package s2Member\Gateway_Checkouts
45 * @since 260829.2325
46 *
47 * @param string $gateway_checkout_id Gateway Checkout ID.
48 *
49 * @return bool TRUE if valid; else FALSE.
50 */
51 public static function valid_id($gateway_checkout_id = '')
52 {
53 $gateway_checkout_id = (string)$gateway_checkout_id;
54
55 return (bool)preg_match('/^(?:[a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})$/i', $gateway_checkout_id);
56 }
57
58 /**
59 * Builds a deterministic fingerprint from Gateway Checkout purchase terms.
60 *
61 * @package s2Member\Gateway_Checkouts
62 * @since 260829.2325
63 *
64 * @param array $purchase_terms Purchase terms.
65 *
66 * @return string SHA-256 fingerprint.
67 */
68 public static function purchase_fingerprint($purchase_terms = array())
69 {
70 //260829.2325 Sort associative purchase data recursively so equivalent server-side terms fingerprint identically regardless of insertion order.
71 $purchase_terms = c_ws_plugin__s2member_utils_arrays::ksort_deep((array)$purchase_terms, SORT_STRING);
72
73 return hash('sha256', serialize($purchase_terms));
74 }
75
76 /**
77 * Creates durable state for a new Gateway Checkout.
78 *
79 * @package s2Member\Gateway_Checkouts
80 * @since 260829.2325
81 *
82 * @param string $gateway Gateway identifier.
83 * @param string $operation Gateway operation identifier.
84 * @param string $purchase_fingerprint Purchase fingerprint, if already known.
85 * @param integer $user_id WordPress user ID, if already known.
86 * @param integer $ttl Optional state TTL in seconds.
87 *
88 * @return array|bool Gateway Checkout state, else FALSE.
89 */
90 public static function create($gateway = '', $operation = '', $purchase_fingerprint = '', $user_id = 0, $ttl = 0)
91 {
92 $gateway = sanitize_key((string)$gateway);
93 $operation = sanitize_key((string)$operation);
94 $user_id = abs((int)$user_id);
95 $ttl = self::ttl($ttl);
96
97 if(!$gateway || !$operation || !$ttl)
98 return FALSE;
99
100 //260829.2325 Use add_option() so an extremely unlikely ID collision cannot overwrite another in-progress checkout.
101 for($attempt = 0; $attempt < 3; $attempt++)
102 {
103 $state = self::create_with_id(self::generate_id(), $gateway, $operation, $purchase_fingerprint, $user_id, $ttl);
104 if($state)
105 return $state;
106 }
107 return FALSE;
108 }
109
110 /**
111 * Resumes a signed browser Gateway Checkout, or creates its durable state on first use.
112 *
113 * @package s2Member\Gateway_Checkouts
114 * @since 260830.0059
115 *
116 * @param string $gateway Gateway identifier.
117 * @param string $operation Gateway operation identifier.
118 * @param string $gateway_checkout_id Browser Gateway Checkout ID, if any.
119 * @param string $browser_token Signed browser token, if any.
120 * @param string $purchase_fingerprint Finalized purchase fingerprint, if known.
121 * @param integer $user_id Current WordPress user ID, if any.
122 * @param integer $ttl Optional state TTL in seconds when a replacement identity is needed.
123 *
124 * @return array|bool Gateway Checkout state, else FALSE.
125 */
126 public static function create_or_resume($gateway = '', $operation = '', $gateway_checkout_id = '', $browser_token = '', $purchase_fingerprint = '', $user_id = 0, $ttl = 0)
127 {
128 $gateway = sanitize_key((string)$gateway);
129 $operation = sanitize_key((string)$operation);
130 $purchase_fingerprint = (string)$purchase_fingerprint;
131 $user_id = abs((int)$user_id);
132
133 if(!$gateway || !$operation)
134 return FALSE;
135
136 if($gateway_checkout_id && $browser_token && self::browser_token_verify($gateway_checkout_id, $browser_token))
137 {
138 $browser_expires_at = self::browser_token_expires_at($browser_token);
139 $state = self::load_state($gateway_checkout_id);
140
141 if(!$state && $browser_expires_at > time())
142 {
143 //260830.0059 Form renders use a signed provisional identity without writing to the database; persist it only when checkout processing actually begins.
144 $state = self::create_with_id($gateway_checkout_id, $gateway, $operation, $purchase_fingerprint, $user_id, 0, $browser_expires_at);
145 if(!$state)
146 $state = self::load_state($gateway_checkout_id); // Another concurrent request may have created the same signed checkout first.
147 }
148 if($state && (string)$state['gateway'] === $gateway && (string)$state['operation'] === $operation
149 && (empty($state['user_id']) || ($user_id && (int)$state['user_id'] === $user_id)))
150 {
151 if($purchase_fingerprint)
152 {
153 //260830.0308 Once bound, a checkout cannot be reassigned to different purchase terms or a different known WordPress user.
154 if(!empty($state['purchase_fingerprint']) && !hash_equals((string)$state['purchase_fingerprint'], $purchase_fingerprint))
155 return FALSE;
156
157 $state['purchase_fingerprint'] = $purchase_fingerprint;
158 if(!$state['user_id'] && $user_id)
159 $state['user_id'] = $user_id;
160 $state['updated_at'] = time();
161
162 if(!update_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id, $state, FALSE))
163 {
164 $persisted_state = self::load_state($gateway_checkout_id);
165 if($persisted_state !== $state)
166 return FALSE;
167 }
168 }
169 return $state;
170 }
171 }
172 return self::create($gateway, $operation, $purchase_fingerprint, $user_id, $ttl);
173 }
174
175 /**
176 * Creates or preserves a signed provisional browser identity without durable state.
177 *
178 * @package s2Member\Gateway_Checkouts
179 * @since 260830.0059
180 *
181 * @param string $gateway_checkout_id Existing browser Gateway Checkout ID, if any.
182 * @param string $browser_token Existing signed browser token, if any.
183 * @param integer $ttl Optional token TTL in seconds.
184 *
185 * @return array Browser identity containing `id`, `token`, and `expires_at`.
186 */
187 public static function browser_identity($gateway_checkout_id = '', $browser_token = '', $ttl = 0)
188 {
189 if($gateway_checkout_id && $browser_token && self::browser_token_verify($gateway_checkout_id, $browser_token))
190 return array('id' => (string)$gateway_checkout_id, 'token' => (string)$browser_token, 'expires_at' => self::browser_token_expires_at($browser_token));
191
192 $gateway_checkout_id = self::generate_id();
193 $expires_at = time() + self::ttl($ttl);
194 $browser_token = self::browser_token($gateway_checkout_id, $expires_at);
195
196 return array('id' => $gateway_checkout_id, 'token' => $browser_token, 'expires_at' => $expires_at);
197 }
198
199 /**
200 * Gets durable Gateway Checkout state.
201 *
202 * @package s2Member\Gateway_Checkouts
203 * @since 260829.2325
204 *
205 * @param string $gateway_checkout_id Gateway Checkout ID.
206 * @param bool $allow_expired Optional. Return expired state instead of removing it.
207 *
208 * @return array|bool Gateway Checkout state, else FALSE.
209 */
210 public static function load_state($gateway_checkout_id = '', $allow_expired = FALSE)
211 {
212 if(!self::valid_id($gateway_checkout_id))
213 return FALSE;
214
215 $option_name = 'ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id;
216 $state = get_option($option_name, FALSE);
217 if(!is_array($state) || empty($state['id']) || !hash_equals((string)$gateway_checkout_id, (string)$state['id']) || empty($state['expires_at']))
218 return FALSE;
219
220 if(!$allow_expired && (int)$state['expires_at'] <= time())
221 {
222 //260829.2325 Expired checkout state is unusable for recovery; remove it lazily when encountered.
223 self::delete($gateway_checkout_id);
224 return FALSE;
225 }
226 return $state;
227 }
228
229 /**
230 * Gets durable Gateway Checkout state.
231 *
232 * Backward-compatible alias for load_state().
233 *
234 * @package s2Member\Gateway_Checkouts
235 * @since 260829.2325
236 *
237 * @param string $gateway_checkout_id Gateway Checkout ID.
238 * @param bool $allow_expired Optional. Return expired state instead of removing it.
239 *
240 * @return array|bool Gateway Checkout state, else FALSE.
241 */
242 public static function get($gateway_checkout_id = '', $allow_expired = FALSE)
243 {
244 return self::load_state($gateway_checkout_id, $allow_expired); //260927.0432 Preserve the original public method for extensions written against the first Gateway Checkout implementation.
245 }
246
247 /**
248 * Loads the latest durable Gateway Checkout state, bypassing this request's option cache.
249 *
250 * @package s2Member\Gateway_Checkouts
251 * @since 260925.0411
252 *
253 * @param string $gateway_checkout_id Gateway Checkout ID.
254 * @param bool $allow_expired Optional. Return expired state instead of removing it.
255 *
256 * @return array|bool Gateway Checkout state, else FALSE.
257 */
258 public static function load_state_uncached($gateway_checkout_id = '', $allow_expired = FALSE)
259 {
260 if(!self::valid_id($gateway_checkout_id))
261 return FALSE;
262
263 //260925.0411 Concurrent webhook/browser requests can leave this PHP request's option cache stale after another worker commits a checkout patch.
264 wp_cache_delete('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id, 'options');
265
266 return self::load_state($gateway_checkout_id, $allow_expired);
267 }
268
269 /**
270 * Updates operational Gateway Checkout state.
271 *
272 * @package s2Member\Gateway_Checkouts
273 * @since 260829.2325
274 *
275 * @param string $gateway_checkout_id Gateway Checkout ID.
276 * @param array $updates Operational state values to update.
277 *
278 * @return array|bool Updated state, else FALSE.
279 */
280 public static function update($gateway_checkout_id = '', $updates = array())
281 {
282 $state = self::load_state($gateway_checkout_id);
283 if(!$state || !is_array($updates))
284 return FALSE;
285
286 //260830.0408 Only operational fields are mutable here; gateway, purchase, and user identity are established when the checkout is created/resumed.
287 $updates = array_intersect_key($updates, array('gateway_ids' => TRUE, 'gateway_status' => TRUE, 'fulfillment_status' => TRUE, 'context' => TRUE));
288 $state = array_merge($state, $updates);
289 $state['updated_at'] = time();
290
291 if(!update_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id, $state, FALSE))
292 {
293 //260829.2325 WordPress returns FALSE when an update makes no database change; return the persisted state if it already matches.
294 $persisted_state = self::load_state($gateway_checkout_id);
295 if($persisted_state !== $state)
296 return FALSE;
297 }
298 return $state;
299 }
300
301 /**
302 * Atomically patches operational Gateway Checkout state without replacing unrelated nested keys.
303 *
304 * This is intentionally separate from update(), whose full-field replacement semantics are relied on by
305 * callers that deliberately remove stale context. Nested `gateway_ids` and `context` values supplied here
306 * are merged into the latest persisted state using compare-and-swap retries, preventing concurrent browser
307 * and webhook requests from overwriting each other's independently-owned keys.
308 *
309 * @package s2Member\Gateway_Checkouts
310 * @since 260925.0232
311 *
312 * @param string $gateway_checkout_id Gateway Checkout ID.
313 * @param array $updates Operational values to patch. Nested gateway_ids/context keys are merged.
314 * @param int $max_attempts Maximum compare-and-swap attempts under contention.
315 *
316 * @return array|bool Updated state, else FALSE.
317 */
318 public static function patch($gateway_checkout_id = '', $updates = array(), $max_attempts = 8)
319 {
320 global $wpdb;
321
322 if(!self::valid_id($gateway_checkout_id) || !is_array($updates))
323 return FALSE;
324
325 $updates = array_intersect_key($updates, array('gateway_ids' => TRUE, 'gateway_status' => TRUE, 'fulfillment_status' => TRUE, 'context' => TRUE));
326 $max_attempts = max(1, min(20, abs((int)$max_attempts)));
327 $option_name = 'ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id;
328
329 for($attempt = 0; $attempt < $max_attempts; $attempt++)
330 {
331 //260925.0232 Read directly from the options table so every retry starts from the latest committed version, not a possibly stale object-cache copy.
332 $raw_state = $wpdb->get_var($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", $option_name));
333 $state = maybe_unserialize($raw_state);
334 if(!is_array($state) || empty($state['id']) || !hash_equals((string)$gateway_checkout_id, (string)$state['id']) || empty($state['expires_at']) || (int)$state['expires_at'] <= time())
335 return FALSE;
336
337 $patched_state = $state;
338 if(isset($updates['gateway_ids']) && is_array($updates['gateway_ids']))
339 $patched_state['gateway_ids'] = array_merge((array)$patched_state['gateway_ids'], $updates['gateway_ids']);
340 if(array_key_exists('gateway_status', $updates))
341 $patched_state['gateway_status'] = (string)$updates['gateway_status'];
342 if(array_key_exists('fulfillment_status', $updates))
343 {
344 //260925.0232 Fulfillment is terminal; a slower pending browser request must never downgrade a checkout already fulfilled by a webhook.
345 if((string)@$patched_state['fulfillment_status'] !== 'fulfilled' || (string)$updates['fulfillment_status'] === 'fulfilled')
346 $patched_state['fulfillment_status'] = (string)$updates['fulfillment_status'];
347 }
348 if(isset($updates['context']) && is_array($updates['context']))
349 $patched_state['context'] = array_merge((array)$patched_state['context'], $updates['context']);
350 $patched_state['updated_at'] = time();
351
352 $serialized_state = maybe_serialize($patched_state);
353 if($serialized_state === (string)$raw_state)
354 return $patched_state;
355
356 //260925.0232 Update only the exact state version read above. A concurrent winner makes this affect zero rows, then we reload and reapply the patch without losing its keys.
357 $updated = $wpdb->query($wpdb->prepare("UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND BINARY option_value = BINARY %s", $serialized_state, $option_name, (string)$raw_state));
358 if($updated)
359 {
360 wp_cache_delete($option_name, 'options');
361 return $patched_state;
362 }
363 }
364 return FALSE;
365 }
366
367 /**
368 * Stores private encrypted recovery context for a Gateway Checkout.
369 *
370 * @package s2Member\Gateway_Checkouts
371 * @since 260831.0723
372 *
373 * @param string $gateway_checkout_id Gateway Checkout ID.
374 * @param array $context Private recovery context; passwords/credentials are not allowed.
375 *
376 * @return bool TRUE if stored or cleared; else FALSE.
377 */
378 public static function private_context_set($gateway_checkout_id = '', $context = array())
379 {
380 $state = self::load_state($gateway_checkout_id);
381 if(!$state || !is_array($context) || !self::private_context_is_safe($context))
382 return FALSE;
383
384 $encrypted = '';
385 if($context)
386 {
387 //260831.0723 Bind ciphertext to this checkout ID so copied/tampered private state cannot be accepted by another checkout.
388 $payload = array('version' => 1, 'gateway_checkout_id' => (string)$gateway_checkout_id, 'context' => $context);
389 $encrypted = c_ws_plugin__s2member_utils_encryption::encrypt(serialize($payload));
390 if(!$encrypted)
391 return FALSE;
392 }
393
394 $state['private_context'] = $encrypted;
395 $state['updated_at'] = time();
396
397 if(!update_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id, $state, FALSE))
398 {
399 $persisted_state = self::load_state($gateway_checkout_id);
400 if($persisted_state !== $state)
401 return FALSE;
402 }
403 return TRUE;
404 }
405
406 /**
407 * Gets private encrypted recovery context for a Gateway Checkout.
408 *
409 * @package s2Member\Gateway_Checkouts
410 * @since 260831.0723
411 *
412 * @param string $gateway_checkout_id Gateway Checkout ID.
413 *
414 * @return array|bool Private recovery context, an empty array when none exists, else FALSE on invalid/corrupt state.
415 */
416 public static function private_context_get($gateway_checkout_id = '')
417 {
418 $state = self::load_state($gateway_checkout_id);
419 if(!$state)
420 return FALSE;
421 if(empty($state['private_context']))
422 return array();
423 if(!is_string($state['private_context']))
424 return FALSE;
425
426 $payload = c_ws_plugin__s2member_utils_arrays::maybe_unserialize(c_ws_plugin__s2member_utils_encryption::decrypt($state['private_context']));
427 if(!is_array($payload) || empty($payload['version']) || (int)$payload['version'] !== 1
428 || empty($payload['gateway_checkout_id']) || !hash_equals((string)$gateway_checkout_id, (string)$payload['gateway_checkout_id'])
429 || !isset($payload['context']) || !is_array($payload['context']) || !self::private_context_is_safe($payload['context']))
430 return FALSE;
431
432 return $payload['context'];
433 }
434
435 /**
436 * Clears private encrypted recovery context for a Gateway Checkout.
437 *
438 * @package s2Member\Gateway_Checkouts
439 * @since 260831.0723
440 *
441 * @param string $gateway_checkout_id Gateway Checkout ID.
442 *
443 * @return bool TRUE if cleared; else FALSE.
444 */
445 public static function private_context_delete($gateway_checkout_id = '')
446 {
447 return self::private_context_set($gateway_checkout_id, array());
448 }
449
450 /**
451 * Acquires an atomic processing lock for a Gateway Checkout.
452 *
453 * @package s2Member\Gateway_Checkouts
454 * @since 260830.0408
455 *
456 * @param string $gateway_checkout_id Gateway Checkout ID.
457 * @param integer $timeout Optional stale-lock timeout in seconds.
458 *
459 * @return string|bool Lock token if acquired; else FALSE.
460 */
461 public static function processing_lock($gateway_checkout_id = '', $timeout = 300)
462 {
463 global $wpdb;
464
465 if(!self::valid_id($gateway_checkout_id) || !self::load_state($gateway_checkout_id))
466 return FALSE;
467
468 $option_name = 's2m_gateway_checkout_lock_'.$gateway_checkout_id;
469 $timeout = max(30, abs((int)$timeout));
470 $token = self::generate_id();
471 $lock = array('token' => $token, 'time' => time());
472
473 if(add_option($option_name, $lock, '', 'no'))
474 return $token;
475
476 $existing = get_option($option_name, FALSE);
477 if(!is_array($existing) || empty($existing['time']) || time() - (int)$existing['time'] >= $timeout)
478 {
479 //260830.1504 Delete only the stale lock version we inspected; another request may replace it between get_option() and this delete.
480 $deleted = $wpdb->delete($wpdb->options, array('option_name' => $option_name, 'option_value' => maybe_serialize($existing)), array('%s', '%s'));
481 if($deleted)
482 {
483 wp_cache_delete($option_name, 'options');
484 if(add_option($option_name, $lock, '', 'no'))
485 return $token;
486 }
487 }
488 return FALSE;
489 }
490
491 /**
492 * Releases a Gateway Checkout processing lock owned by the supplied token.
493 *
494 * @package s2Member\Gateway_Checkouts
495 * @since 260830.0408
496 *
497 * @param string $gateway_checkout_id Gateway Checkout ID.
498 * @param string $token Lock token returned by processing_lock().
499 *
500 * @return bool TRUE if released; else FALSE.
501 */
502 public static function processing_unlock($gateway_checkout_id = '', $token = '')
503 {
504 global $wpdb;
505
506 if(!self::valid_id($gateway_checkout_id) || !self::valid_id($token))
507 return FALSE;
508
509 $option_name = 's2m_gateway_checkout_lock_'.$gateway_checkout_id;
510 $existing = get_option($option_name, FALSE);
511 if(!is_array($existing) || empty($existing['token']) || !hash_equals((string)$existing['token'], (string)$token))
512 return FALSE;
513
514 //260830.1504 Release only the lock version owned by this token; a stale owner must never delete a newer replacement lock.
515 $deleted = $wpdb->delete($wpdb->options, array('option_name' => $option_name, 'option_value' => maybe_serialize($existing)), array('%s', '%s'));
516 if($deleted)
517 wp_cache_delete($option_name, 'options');
518
519 return (bool)$deleted;
520 }
521
522 /**
523 * Deletes durable Gateway Checkout state.
524 *
525 * @package s2Member\Gateway_Checkouts
526 * @since 260829.2325
527 *
528 * @param string $gateway_checkout_id Gateway Checkout ID.
529 *
530 * @return bool TRUE if state was deleted; else FALSE.
531 */
532 public static function delete($gateway_checkout_id = '')
533 {
534 if(!self::valid_id($gateway_checkout_id))
535 return FALSE;
536
537 delete_option('s2m_gateway_checkout_lock_'.$gateway_checkout_id);
538
539 return delete_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id);
540 }
541
542 /**
543 * Creates a signed browser token for a Gateway Checkout identity.
544 *
545 * @package s2Member\Gateway_Checkouts
546 * @since 260829.2325
547 *
548 * @param string $gateway_checkout_id Gateway Checkout ID.
549 * @param integer $expires_at Optional absolute expiration time for a provisional identity.
550 *
551 * @return string Signed browser token, else an empty string.
552 */
553 public static function browser_token($gateway_checkout_id = '', $expires_at = 0)
554 {
555 if(!self::valid_id($gateway_checkout_id))
556 return '';
557
558 if(!$expires_at)
559 {
560 $state = self::load_state($gateway_checkout_id);
561 if(!$state)
562 return '';
563
564 $expires_at = (int)$state['expires_at'];
565 }
566 if((int)$expires_at <= time())
567 return '';
568
569 $payload = (string)$gateway_checkout_id.'|'.(int)$expires_at;
570 $key = hash_hmac('sha256', 's2member:gateway-checkout:browser-token', c_ws_plugin__s2member_utils_encryption::key());
571 $signature = hash_hmac('sha256', $payload, $key);
572
573 return (int)$expires_at.'.'.$signature;
574 }
575
576 /**
577 * Verifies a signed Gateway Checkout browser token.
578 *
579 * @package s2Member\Gateway_Checkouts
580 * @since 260829.2325
581 *
582 * @param string $gateway_checkout_id Gateway Checkout ID.
583 * @param string $browser_token Signed browser token.
584 *
585 * @return bool TRUE if valid; else FALSE.
586 */
587 public static function browser_token_verify($gateway_checkout_id = '', $browser_token = '')
588 {
589 if(!self::valid_id($gateway_checkout_id) || !is_string($browser_token) || !preg_match('/^([0-9]+)\.([a-f0-9]{64})$/i', $browser_token, $matches))
590 return FALSE;
591
592 $expires_at = (int)$matches[1];
593 if($expires_at <= time())
594 return FALSE;
595
596 $payload = (string)$gateway_checkout_id.'|'.$expires_at;
597 $key = hash_hmac('sha256', 's2member:gateway-checkout:browser-token', c_ws_plugin__s2member_utils_encryption::key());
598 $expected = hash_hmac('sha256', $payload, $key);
599 if(!hash_equals($expected, strtolower($matches[2])))
600 return FALSE;
601
602 $state = self::load_state($gateway_checkout_id);
603 //260830.0059 A provisional browser identity has no state yet; once state exists, its expiration must remain bound to the signed token.
604 return !$state || $expires_at === (int)$state['expires_at'];
605 }
606
607 /**
608 * Gets the expiration time encoded in a syntactically valid browser token.
609 *
610 * @package s2Member\Gateway_Checkouts
611 * @since 260830.0059
612 *
613 * @param string $browser_token Signed browser token.
614 *
615 * @return integer Absolute expiration time, else 0.
616 */
617 protected static function browser_token_expires_at($browser_token = '')
618 {
619 return is_string($browser_token) && preg_match('/^([0-9]+)\.[a-f0-9]{64}$/i', $browser_token, $matches) ? (int)$matches[1] : 0;
620 }
621
622 /**
623 * Removes a bounded batch of expired Gateway Checkout states.
624 *
625 * @package s2Member\Gateway_Checkouts
626 * @since 260829.2325
627 *
628 * @param integer $limit Maximum candidate options to inspect.
629 *
630 * @return integer Number of expired/malformed state options removed.
631 */
632 public static function cleanup_expired($limit = 50)
633 {
634 global $wpdb;
635
636 $limit = min(500, max(1, abs((int)$limit)));
637 $option_prefix = 'ws_plugin__s2member_gateway_checkout_';
638 $option_names = $wpdb->get_col($wpdb->prepare("SELECT `option_name` FROM `{$wpdb->options}` WHERE `option_name` LIKE %s ORDER BY `option_id` ASC LIMIT %d", $wpdb->esc_like($option_prefix).'%', $limit));
639 $removed = 0;
640
641 foreach((array)$option_names as $option_name)
642 {
643 $gateway_checkout_id = substr((string)$option_name, strlen($option_prefix));
644 if(!self::valid_id($gateway_checkout_id))
645 continue;
646
647 $state = get_option($option_name, FALSE);
648 if(!is_array($state) || empty($state['expires_at']) || (int)$state['expires_at'] <= time())
649 {
650 if(self::delete($gateway_checkout_id))
651 $removed++;
652 }
653 }
654 return $removed;
655 }
656
657 /**
658 * Gets the configured Gateway Checkout state TTL.
659 *
660 * @package s2Member\Gateway_Checkouts
661 * @since 260829.2325
662 *
663 * @param integer $ttl Optional explicit TTL in seconds.
664 *
665 * @return integer TTL in seconds.
666 */
667 public static function ttl($ttl = 0)
668 {
669 if((int)$ttl > 0)
670 return abs((int)$ttl);
671
672 //260831.0626 Keep checkout recovery state beyond supported gateway idempotency windows and long enough for delayed browser/webhook recovery; sites can tune this without changing the storage contract.
673 return max(HOUR_IN_SECONDS, abs((int)apply_filters('ws_plugin__s2member_gateway_checkout_ttl', 7 * DAY_IN_SECONDS)));
674 }
675
676 /**
677 * Validates private recovery context before encryption/persistence.
678 *
679 * @package s2Member\Gateway_Checkouts
680 * @since 260831.0723
681 *
682 * @param array $context Private recovery context.
683 *
684 * @return bool TRUE if safe to persist; else FALSE.
685 */
686 protected static function private_context_is_safe($context = array())
687 {
688 foreach((array)$context as $key => $value)
689 {
690 $normalized_key = strtolower(trim(preg_replace('/[^a-z0-9]+/i', '_', (string)$key), '_'));
691 //260901.0722 Never persist actual login credentials, while allowing unrelated site-defined profile fields such as `password_hint` in otherwise valid private recovery context.
692 $credential_keys = array('pass', 'pass1', 'pass2', 'passwd', 'password', 'password1', 'password2', 'pwd', 'user_pass', 'user_password', 'current_password', 'new_password', 'old_password', 'confirm_password', 'password_confirmation');
693 if(in_array($normalized_key, $credential_keys, TRUE))
694 return FALSE;
695 if(is_array($value))
696 {
697 if(!self::private_context_is_safe($value))
698 return FALSE;
699 }
700 else if(is_object($value) || is_resource($value))
701 return FALSE;
702 }
703 return TRUE;
704 }
705
706 /**
707 * Creates durable Gateway Checkout state with a specific signed identity.
708 *
709 * @package s2Member\Gateway_Checkouts
710 * @since 260830.0059
711 *
712 * @param string $gateway_checkout_id Gateway Checkout ID.
713 * @param string $gateway Gateway identifier.
714 * @param string $operation Gateway operation identifier.
715 * @param string $purchase_fingerprint Purchase fingerprint, if already known.
716 * @param integer $user_id WordPress user ID, if already known.
717 * @param integer $ttl State TTL in seconds.
718 * @param integer $expires_at Optional absolute expiration time; used by signed provisional browser identities.
719 *
720 * @return array|bool Gateway Checkout state, else FALSE.
721 */
722 protected static function create_with_id($gateway_checkout_id = '', $gateway = '', $operation = '', $purchase_fingerprint = '', $user_id = 0, $ttl = 0, $expires_at = 0)
723 {
724 $gateway = sanitize_key((string)$gateway);
725 $operation = sanitize_key((string)$operation);
726 $user_id = abs((int)$user_id);
727 $expires_at = abs((int)$expires_at);
728 $ttl = $expires_at ? 0 : self::ttl($ttl);
729
730 if(!self::valid_id($gateway_checkout_id) || !$gateway || !$operation || (!$ttl && $expires_at <= time()))
731 return FALSE;
732
733 $option_name = 'ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id;
734
735 $now = time();
736 $expires_at = $expires_at ?: $now + $ttl;
737 $state = array(
738 'version' => 1,
739 'id' => (string)$gateway_checkout_id,
740 'gateway' => $gateway,
741 'operation' => $operation,
742 'purchase_fingerprint' => (string)$purchase_fingerprint,
743 'user_id' => $user_id,
744 'gateway_ids' => array(),
745 'gateway_status' => '',
746 'context' => array(),
747 //260831.0723 Keep private recovery data encrypted inside the same non-autoloaded checkout option so it shares the checkout lifecycle without exposing plaintext in normal state reads.
748 'private_context' => '',
749 'fulfillment_status' => 'pending',
750 'created_at' => $now,
751 'updated_at' => $now,
752 'expires_at' => $expires_at,
753 );
754
755 if(!add_option($option_name, $state, '', 'no'))
756 return FALSE;
757
758 //260830.1504 Keep opportunistic cleanup ahead of steady-state creation; a 1-in-50 run can remove up to 100 expired states, providing cleanup headroom without another scheduled task.
759 if(wp_rand(1, 50) === 1)
760 self::cleanup_expired(100);
761
762 return $state;
763 }
764 }
765 }
766