| 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::get($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::get($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::get($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 get($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 |
* Updates operational Gateway Checkout state. |
| 231 |
* |
| 232 |
* @package s2Member\Gateway_Checkouts |
| 233 |
* @since 260829.2325 |
| 234 |
* |
| 235 |
* @param string $gateway_checkout_id Gateway Checkout ID. |
| 236 |
* @param array $updates Operational state values to update. |
| 237 |
* |
| 238 |
* @return array|bool Updated state, else FALSE. |
| 239 |
*/ |
| 240 |
public static function update($gateway_checkout_id = '', $updates = array()) |
| 241 |
{ |
| 242 |
$state = self::get($gateway_checkout_id); |
| 243 |
if(!$state || !is_array($updates)) |
| 244 |
return FALSE; |
| 245 |
|
| 246 |
//260830.0408 Only operational fields are mutable here; gateway, purchase, and user identity are established when the checkout is created/resumed. |
| 247 |
$updates = array_intersect_key($updates, array('gateway_ids' => TRUE, 'gateway_status' => TRUE, 'fulfillment_status' => TRUE, 'context' => TRUE)); |
| 248 |
$state = array_merge($state, $updates); |
| 249 |
$state['updated_at'] = time(); |
| 250 |
|
| 251 |
if(!update_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id, $state, FALSE)) |
| 252 |
{ |
| 253 |
//260829.2325 WordPress returns FALSE when an update makes no database change; return the persisted state if it already matches. |
| 254 |
$persisted_state = self::get($gateway_checkout_id); |
| 255 |
if($persisted_state !== $state) |
| 256 |
return FALSE; |
| 257 |
} |
| 258 |
return $state; |
| 259 |
} |
| 260 |
|
| 261 |
/** |
| 262 |
* Stores private encrypted recovery context for a Gateway Checkout. |
| 263 |
* |
| 264 |
* @package s2Member\Gateway_Checkouts |
| 265 |
* @since 260831.0723 |
| 266 |
* |
| 267 |
* @param string $gateway_checkout_id Gateway Checkout ID. |
| 268 |
* @param array $context Private recovery context; passwords/credentials are not allowed. |
| 269 |
* |
| 270 |
* @return bool TRUE if stored or cleared; else FALSE. |
| 271 |
*/ |
| 272 |
public static function private_context_set($gateway_checkout_id = '', $context = array()) |
| 273 |
{ |
| 274 |
$state = self::get($gateway_checkout_id); |
| 275 |
if(!$state || !is_array($context) || !self::private_context_is_safe($context)) |
| 276 |
return FALSE; |
| 277 |
|
| 278 |
$encrypted = ''; |
| 279 |
if($context) |
| 280 |
{ |
| 281 |
//260831.0723 Bind ciphertext to this checkout ID so copied/tampered private state cannot be accepted by another checkout. |
| 282 |
$payload = array('version' => 1, 'gateway_checkout_id' => (string)$gateway_checkout_id, 'context' => $context); |
| 283 |
$encrypted = c_ws_plugin__s2member_utils_encryption::encrypt(serialize($payload)); |
| 284 |
if(!$encrypted) |
| 285 |
return FALSE; |
| 286 |
} |
| 287 |
|
| 288 |
$state['private_context'] = $encrypted; |
| 289 |
$state['updated_at'] = time(); |
| 290 |
|
| 291 |
if(!update_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id, $state, FALSE)) |
| 292 |
{ |
| 293 |
$persisted_state = self::get($gateway_checkout_id); |
| 294 |
if($persisted_state !== $state) |
| 295 |
return FALSE; |
| 296 |
} |
| 297 |
return TRUE; |
| 298 |
} |
| 299 |
|
| 300 |
/** |
| 301 |
* Gets private encrypted recovery context for a Gateway Checkout. |
| 302 |
* |
| 303 |
* @package s2Member\Gateway_Checkouts |
| 304 |
* @since 260831.0723 |
| 305 |
* |
| 306 |
* @param string $gateway_checkout_id Gateway Checkout ID. |
| 307 |
* |
| 308 |
* @return array|bool Private recovery context, an empty array when none exists, else FALSE on invalid/corrupt state. |
| 309 |
*/ |
| 310 |
public static function private_context_get($gateway_checkout_id = '') |
| 311 |
{ |
| 312 |
$state = self::get($gateway_checkout_id); |
| 313 |
if(!$state) |
| 314 |
return FALSE; |
| 315 |
if(empty($state['private_context'])) |
| 316 |
return array(); |
| 317 |
if(!is_string($state['private_context'])) |
| 318 |
return FALSE; |
| 319 |
|
| 320 |
$payload = c_ws_plugin__s2member_utils_arrays::maybe_unserialize(c_ws_plugin__s2member_utils_encryption::decrypt($state['private_context'])); |
| 321 |
if(!is_array($payload) || empty($payload['version']) || (int)$payload['version'] !== 1 |
| 322 |
|| empty($payload['gateway_checkout_id']) || !hash_equals((string)$gateway_checkout_id, (string)$payload['gateway_checkout_id']) |
| 323 |
|| !isset($payload['context']) || !is_array($payload['context']) || !self::private_context_is_safe($payload['context'])) |
| 324 |
return FALSE; |
| 325 |
|
| 326 |
return $payload['context']; |
| 327 |
} |
| 328 |
|
| 329 |
/** |
| 330 |
* Clears private encrypted recovery context for a Gateway Checkout. |
| 331 |
* |
| 332 |
* @package s2Member\Gateway_Checkouts |
| 333 |
* @since 260831.0723 |
| 334 |
* |
| 335 |
* @param string $gateway_checkout_id Gateway Checkout ID. |
| 336 |
* |
| 337 |
* @return bool TRUE if cleared; else FALSE. |
| 338 |
*/ |
| 339 |
public static function private_context_delete($gateway_checkout_id = '') |
| 340 |
{ |
| 341 |
return self::private_context_set($gateway_checkout_id, array()); |
| 342 |
} |
| 343 |
|
| 344 |
/** |
| 345 |
* Acquires an atomic processing lock for a Gateway Checkout. |
| 346 |
* |
| 347 |
* @package s2Member\Gateway_Checkouts |
| 348 |
* @since 260830.0408 |
| 349 |
* |
| 350 |
* @param string $gateway_checkout_id Gateway Checkout ID. |
| 351 |
* @param integer $timeout Optional stale-lock timeout in seconds. |
| 352 |
* |
| 353 |
* @return string|bool Lock token if acquired; else FALSE. |
| 354 |
*/ |
| 355 |
public static function processing_lock($gateway_checkout_id = '', $timeout = 300) |
| 356 |
{ |
| 357 |
global $wpdb; |
| 358 |
|
| 359 |
if(!self::valid_id($gateway_checkout_id) || !self::get($gateway_checkout_id)) |
| 360 |
return FALSE; |
| 361 |
|
| 362 |
$option_name = 's2m_gateway_checkout_lock_'.$gateway_checkout_id; |
| 363 |
$timeout = max(30, abs((int)$timeout)); |
| 364 |
$token = self::generate_id(); |
| 365 |
$lock = array('token' => $token, 'time' => time()); |
| 366 |
|
| 367 |
if(add_option($option_name, $lock, '', 'no')) |
| 368 |
return $token; |
| 369 |
|
| 370 |
$existing = get_option($option_name, FALSE); |
| 371 |
if(!is_array($existing) || empty($existing['time']) || time() - (int)$existing['time'] >= $timeout) |
| 372 |
{ |
| 373 |
//260830.1504 Delete only the stale lock version we inspected; another request may replace it between get_option() and this delete. |
| 374 |
$deleted = $wpdb->delete($wpdb->options, array('option_name' => $option_name, 'option_value' => maybe_serialize($existing)), array('%s', '%s')); |
| 375 |
if($deleted) |
| 376 |
{ |
| 377 |
wp_cache_delete($option_name, 'options'); |
| 378 |
if(add_option($option_name, $lock, '', 'no')) |
| 379 |
return $token; |
| 380 |
} |
| 381 |
} |
| 382 |
return FALSE; |
| 383 |
} |
| 384 |
|
| 385 |
/** |
| 386 |
* Releases a Gateway Checkout processing lock owned by the supplied token. |
| 387 |
* |
| 388 |
* @package s2Member\Gateway_Checkouts |
| 389 |
* @since 260830.0408 |
| 390 |
* |
| 391 |
* @param string $gateway_checkout_id Gateway Checkout ID. |
| 392 |
* @param string $token Lock token returned by processing_lock(). |
| 393 |
* |
| 394 |
* @return bool TRUE if released; else FALSE. |
| 395 |
*/ |
| 396 |
public static function processing_unlock($gateway_checkout_id = '', $token = '') |
| 397 |
{ |
| 398 |
global $wpdb; |
| 399 |
|
| 400 |
if(!self::valid_id($gateway_checkout_id) || !self::valid_id($token)) |
| 401 |
return FALSE; |
| 402 |
|
| 403 |
$option_name = 's2m_gateway_checkout_lock_'.$gateway_checkout_id; |
| 404 |
$existing = get_option($option_name, FALSE); |
| 405 |
if(!is_array($existing) || empty($existing['token']) || !hash_equals((string)$existing['token'], (string)$token)) |
| 406 |
return FALSE; |
| 407 |
|
| 408 |
//260830.1504 Release only the lock version owned by this token; a stale owner must never delete a newer replacement lock. |
| 409 |
$deleted = $wpdb->delete($wpdb->options, array('option_name' => $option_name, 'option_value' => maybe_serialize($existing)), array('%s', '%s')); |
| 410 |
if($deleted) |
| 411 |
wp_cache_delete($option_name, 'options'); |
| 412 |
|
| 413 |
return (bool)$deleted; |
| 414 |
} |
| 415 |
|
| 416 |
/** |
| 417 |
* Deletes durable Gateway Checkout state. |
| 418 |
* |
| 419 |
* @package s2Member\Gateway_Checkouts |
| 420 |
* @since 260829.2325 |
| 421 |
* |
| 422 |
* @param string $gateway_checkout_id Gateway Checkout ID. |
| 423 |
* |
| 424 |
* @return bool TRUE if state was deleted; else FALSE. |
| 425 |
*/ |
| 426 |
public static function delete($gateway_checkout_id = '') |
| 427 |
{ |
| 428 |
if(!self::valid_id($gateway_checkout_id)) |
| 429 |
return FALSE; |
| 430 |
|
| 431 |
delete_option('s2m_gateway_checkout_lock_'.$gateway_checkout_id); |
| 432 |
|
| 433 |
return delete_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id); |
| 434 |
} |
| 435 |
|
| 436 |
/** |
| 437 |
* Creates a signed browser token for a Gateway Checkout identity. |
| 438 |
* |
| 439 |
* @package s2Member\Gateway_Checkouts |
| 440 |
* @since 260829.2325 |
| 441 |
* |
| 442 |
* @param string $gateway_checkout_id Gateway Checkout ID. |
| 443 |
* @param integer $expires_at Optional absolute expiration time for a provisional identity. |
| 444 |
* |
| 445 |
* @return string Signed browser token, else an empty string. |
| 446 |
*/ |
| 447 |
public static function browser_token($gateway_checkout_id = '', $expires_at = 0) |
| 448 |
{ |
| 449 |
if(!self::valid_id($gateway_checkout_id)) |
| 450 |
return ''; |
| 451 |
|
| 452 |
if(!$expires_at) |
| 453 |
{ |
| 454 |
$state = self::get($gateway_checkout_id); |
| 455 |
if(!$state) |
| 456 |
return ''; |
| 457 |
|
| 458 |
$expires_at = (int)$state['expires_at']; |
| 459 |
} |
| 460 |
if((int)$expires_at <= time()) |
| 461 |
return ''; |
| 462 |
|
| 463 |
$payload = (string)$gateway_checkout_id.'|'.(int)$expires_at; |
| 464 |
$key = hash_hmac('sha256', 's2member:gateway-checkout:browser-token', c_ws_plugin__s2member_utils_encryption::key()); |
| 465 |
$signature = hash_hmac('sha256', $payload, $key); |
| 466 |
|
| 467 |
return (int)$expires_at.'.'.$signature; |
| 468 |
} |
| 469 |
|
| 470 |
/** |
| 471 |
* Verifies a signed Gateway Checkout browser token. |
| 472 |
* |
| 473 |
* @package s2Member\Gateway_Checkouts |
| 474 |
* @since 260829.2325 |
| 475 |
* |
| 476 |
* @param string $gateway_checkout_id Gateway Checkout ID. |
| 477 |
* @param string $browser_token Signed browser token. |
| 478 |
* |
| 479 |
* @return bool TRUE if valid; else FALSE. |
| 480 |
*/ |
| 481 |
public static function browser_token_verify($gateway_checkout_id = '', $browser_token = '') |
| 482 |
{ |
| 483 |
if(!self::valid_id($gateway_checkout_id) || !is_string($browser_token) || !preg_match('/^([0-9]+)\.([a-f0-9]{64})$/i', $browser_token, $matches)) |
| 484 |
return FALSE; |
| 485 |
|
| 486 |
$expires_at = (int)$matches[1]; |
| 487 |
if($expires_at <= time()) |
| 488 |
return FALSE; |
| 489 |
|
| 490 |
$payload = (string)$gateway_checkout_id.'|'.$expires_at; |
| 491 |
$key = hash_hmac('sha256', 's2member:gateway-checkout:browser-token', c_ws_plugin__s2member_utils_encryption::key()); |
| 492 |
$expected = hash_hmac('sha256', $payload, $key); |
| 493 |
if(!hash_equals($expected, strtolower($matches[2]))) |
| 494 |
return FALSE; |
| 495 |
|
| 496 |
$state = self::get($gateway_checkout_id); |
| 497 |
//260830.0059 A provisional browser identity has no state yet; once state exists, its expiration must remain bound to the signed token. |
| 498 |
return !$state || $expires_at === (int)$state['expires_at']; |
| 499 |
} |
| 500 |
|
| 501 |
/** |
| 502 |
* Gets the expiration time encoded in a syntactically valid browser token. |
| 503 |
* |
| 504 |
* @package s2Member\Gateway_Checkouts |
| 505 |
* @since 260830.0059 |
| 506 |
* |
| 507 |
* @param string $browser_token Signed browser token. |
| 508 |
* |
| 509 |
* @return integer Absolute expiration time, else 0. |
| 510 |
*/ |
| 511 |
protected static function browser_token_expires_at($browser_token = '') |
| 512 |
{ |
| 513 |
return is_string($browser_token) && preg_match('/^([0-9]+)\.[a-f0-9]{64}$/i', $browser_token, $matches) ? (int)$matches[1] : 0; |
| 514 |
} |
| 515 |
|
| 516 |
/** |
| 517 |
* Removes a bounded batch of expired Gateway Checkout states. |
| 518 |
* |
| 519 |
* @package s2Member\Gateway_Checkouts |
| 520 |
* @since 260829.2325 |
| 521 |
* |
| 522 |
* @param integer $limit Maximum candidate options to inspect. |
| 523 |
* |
| 524 |
* @return integer Number of expired/malformed state options removed. |
| 525 |
*/ |
| 526 |
public static function cleanup_expired($limit = 50) |
| 527 |
{ |
| 528 |
global $wpdb; |
| 529 |
|
| 530 |
$limit = min(500, max(1, abs((int)$limit))); |
| 531 |
$option_prefix = 'ws_plugin__s2member_gateway_checkout_'; |
| 532 |
$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)); |
| 533 |
$removed = 0; |
| 534 |
|
| 535 |
foreach((array)$option_names as $option_name) |
| 536 |
{ |
| 537 |
$gateway_checkout_id = substr((string)$option_name, strlen($option_prefix)); |
| 538 |
if(!self::valid_id($gateway_checkout_id)) |
| 539 |
continue; |
| 540 |
|
| 541 |
$state = get_option($option_name, FALSE); |
| 542 |
if(!is_array($state) || empty($state['expires_at']) || (int)$state['expires_at'] <= time()) |
| 543 |
{ |
| 544 |
if(self::delete($gateway_checkout_id)) |
| 545 |
$removed++; |
| 546 |
} |
| 547 |
} |
| 548 |
return $removed; |
| 549 |
} |
| 550 |
|
| 551 |
/** |
| 552 |
* Gets the configured Gateway Checkout state TTL. |
| 553 |
* |
| 554 |
* @package s2Member\Gateway_Checkouts |
| 555 |
* @since 260829.2325 |
| 556 |
* |
| 557 |
* @param integer $ttl Optional explicit TTL in seconds. |
| 558 |
* |
| 559 |
* @return integer TTL in seconds. |
| 560 |
*/ |
| 561 |
public static function ttl($ttl = 0) |
| 562 |
{ |
| 563 |
if((int)$ttl > 0) |
| 564 |
return abs((int)$ttl); |
| 565 |
|
| 566 |
//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. |
| 567 |
return max(HOUR_IN_SECONDS, abs((int)apply_filters('ws_plugin__s2member_gateway_checkout_ttl', 7 * DAY_IN_SECONDS))); |
| 568 |
} |
| 569 |
|
| 570 |
/** |
| 571 |
* Validates private recovery context before encryption/persistence. |
| 572 |
* |
| 573 |
* @package s2Member\Gateway_Checkouts |
| 574 |
* @since 260831.0723 |
| 575 |
* |
| 576 |
* @param array $context Private recovery context. |
| 577 |
* |
| 578 |
* @return bool TRUE if safe to persist; else FALSE. |
| 579 |
*/ |
| 580 |
protected static function private_context_is_safe($context = array()) |
| 581 |
{ |
| 582 |
foreach((array)$context as $key => $value) |
| 583 |
{ |
| 584 |
$normalized_key = strtolower(trim(preg_replace('/[^a-z0-9]+/i', '_', (string)$key), '_')); |
| 585 |
//260901.0722 Never persist actual login credentials, while allowing unrelated site-defined profile fields such as `password_hint` in otherwise valid private recovery context. |
| 586 |
$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'); |
| 587 |
if(in_array($normalized_key, $credential_keys, TRUE)) |
| 588 |
return FALSE; |
| 589 |
if(is_array($value)) |
| 590 |
{ |
| 591 |
if(!self::private_context_is_safe($value)) |
| 592 |
return FALSE; |
| 593 |
} |
| 594 |
else if(is_object($value) || is_resource($value)) |
| 595 |
return FALSE; |
| 596 |
} |
| 597 |
return TRUE; |
| 598 |
} |
| 599 |
|
| 600 |
/** |
| 601 |
* Creates durable Gateway Checkout state with a specific signed identity. |
| 602 |
* |
| 603 |
* @package s2Member\Gateway_Checkouts |
| 604 |
* @since 260830.0059 |
| 605 |
* |
| 606 |
* @param string $gateway_checkout_id Gateway Checkout ID. |
| 607 |
* @param string $gateway Gateway identifier. |
| 608 |
* @param string $operation Gateway operation identifier. |
| 609 |
* @param string $purchase_fingerprint Purchase fingerprint, if already known. |
| 610 |
* @param integer $user_id WordPress user ID, if already known. |
| 611 |
* @param integer $ttl State TTL in seconds. |
| 612 |
* @param integer $expires_at Optional absolute expiration time; used by signed provisional browser identities. |
| 613 |
* |
| 614 |
* @return array|bool Gateway Checkout state, else FALSE. |
| 615 |
*/ |
| 616 |
protected static function create_with_id($gateway_checkout_id = '', $gateway = '', $operation = '', $purchase_fingerprint = '', $user_id = 0, $ttl = 0, $expires_at = 0) |
| 617 |
{ |
| 618 |
$gateway = sanitize_key((string)$gateway); |
| 619 |
$operation = sanitize_key((string)$operation); |
| 620 |
$user_id = abs((int)$user_id); |
| 621 |
$expires_at = abs((int)$expires_at); |
| 622 |
$ttl = $expires_at ? 0 : self::ttl($ttl); |
| 623 |
|
| 624 |
if(!self::valid_id($gateway_checkout_id) || !$gateway || !$operation || (!$ttl && $expires_at <= time())) |
| 625 |
return FALSE; |
| 626 |
|
| 627 |
$option_name = 'ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id; |
| 628 |
|
| 629 |
$now = time(); |
| 630 |
$expires_at = $expires_at ?: $now + $ttl; |
| 631 |
$state = array( |
| 632 |
'version' => 1, |
| 633 |
'id' => (string)$gateway_checkout_id, |
| 634 |
'gateway' => $gateway, |
| 635 |
'operation' => $operation, |
| 636 |
'purchase_fingerprint' => (string)$purchase_fingerprint, |
| 637 |
'user_id' => $user_id, |
| 638 |
'gateway_ids' => array(), |
| 639 |
'gateway_status' => '', |
| 640 |
'context' => array(), |
| 641 |
//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. |
| 642 |
'private_context' => '', |
| 643 |
'fulfillment_status' => 'pending', |
| 644 |
'created_at' => $now, |
| 645 |
'updated_at' => $now, |
| 646 |
'expires_at' => $expires_at, |
| 647 |
); |
| 648 |
|
| 649 |
if(!add_option($option_name, $state, '', 'no')) |
| 650 |
return FALSE; |
| 651 |
|
| 652 |
//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. |
| 653 |
if(wp_rand(1, 50) === 1) |
| 654 |
self::cleanup_expired(100); |
| 655 |
|
| 656 |
return $state; |
| 657 |
} |
| 658 |
} |
| 659 |
} |
| 660 |
|