time()) { //260830.0059 Form renders use a signed provisional identity without writing to the database; persist it only when checkout processing actually begins. $state = self::create_with_id($gateway_checkout_id, $gateway, $operation, $purchase_fingerprint, $user_id, 0, $browser_expires_at); if(!$state) $state = self::get($gateway_checkout_id); // Another concurrent request may have created the same signed checkout first. } if($state && (string)$state['gateway'] === $gateway && (string)$state['operation'] === $operation && (empty($state['user_id']) || ($user_id && (int)$state['user_id'] === $user_id))) { if($purchase_fingerprint) { //260830.0308 Once bound, a checkout cannot be reassigned to different purchase terms or a different known WordPress user. if(!empty($state['purchase_fingerprint']) && !hash_equals((string)$state['purchase_fingerprint'], $purchase_fingerprint)) return FALSE; $state['purchase_fingerprint'] = $purchase_fingerprint; if(!$state['user_id'] && $user_id) $state['user_id'] = $user_id; $state['updated_at'] = time(); if(!update_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id, $state, FALSE)) { $persisted_state = self::get($gateway_checkout_id); if($persisted_state !== $state) return FALSE; } } return $state; } } return self::create($gateway, $operation, $purchase_fingerprint, $user_id, $ttl); } /** * Creates or preserves a signed provisional browser identity without durable state. * * @package s2Member\Gateway_Checkouts * @since 260830.0059 * * @param string $gateway_checkout_id Existing browser Gateway Checkout ID, if any. * @param string $browser_token Existing signed browser token, if any. * @param integer $ttl Optional token TTL in seconds. * * @return array Browser identity containing `id`, `token`, and `expires_at`. */ public static function browser_identity($gateway_checkout_id = '', $browser_token = '', $ttl = 0) { if($gateway_checkout_id && $browser_token && self::browser_token_verify($gateway_checkout_id, $browser_token)) return array('id' => (string)$gateway_checkout_id, 'token' => (string)$browser_token, 'expires_at' => self::browser_token_expires_at($browser_token)); $gateway_checkout_id = self::generate_id(); $expires_at = time() + self::ttl($ttl); $browser_token = self::browser_token($gateway_checkout_id, $expires_at); return array('id' => $gateway_checkout_id, 'token' => $browser_token, 'expires_at' => $expires_at); } /** * Gets durable Gateway Checkout state. * * @package s2Member\Gateway_Checkouts * @since 260829.2325 * * @param string $gateway_checkout_id Gateway Checkout ID. * @param bool $allow_expired Optional. Return expired state instead of removing it. * * @return array|bool Gateway Checkout state, else FALSE. */ public static function get($gateway_checkout_id = '', $allow_expired = FALSE) { if(!self::valid_id($gateway_checkout_id)) return FALSE; $option_name = 'ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id; $state = get_option($option_name, FALSE); if(!is_array($state) || empty($state['id']) || !hash_equals((string)$gateway_checkout_id, (string)$state['id']) || empty($state['expires_at'])) return FALSE; if(!$allow_expired && (int)$state['expires_at'] <= time()) { //260829.2325 Expired checkout state is unusable for recovery; remove it lazily when encountered. self::delete($gateway_checkout_id); return FALSE; } return $state; } /** * Updates operational Gateway Checkout state. * * @package s2Member\Gateway_Checkouts * @since 260829.2325 * * @param string $gateway_checkout_id Gateway Checkout ID. * @param array $updates Operational state values to update. * * @return array|bool Updated state, else FALSE. */ public static function update($gateway_checkout_id = '', $updates = array()) { $state = self::get($gateway_checkout_id); if(!$state || !is_array($updates)) return FALSE; //260830.0408 Only operational fields are mutable here; gateway, purchase, and user identity are established when the checkout is created/resumed. $updates = array_intersect_key($updates, array('gateway_ids' => TRUE, 'gateway_status' => TRUE, 'fulfillment_status' => TRUE, 'context' => TRUE)); $state = array_merge($state, $updates); $state['updated_at'] = time(); if(!update_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id, $state, FALSE)) { //260829.2325 WordPress returns FALSE when an update makes no database change; return the persisted state if it already matches. $persisted_state = self::get($gateway_checkout_id); if($persisted_state !== $state) return FALSE; } return $state; } /** * Stores private encrypted recovery context for a Gateway Checkout. * * @package s2Member\Gateway_Checkouts * @since 260831.0723 * * @param string $gateway_checkout_id Gateway Checkout ID. * @param array $context Private recovery context; passwords/credentials are not allowed. * * @return bool TRUE if stored or cleared; else FALSE. */ public static function private_context_set($gateway_checkout_id = '', $context = array()) { $state = self::get($gateway_checkout_id); if(!$state || !is_array($context) || !self::private_context_is_safe($context)) return FALSE; $encrypted = ''; if($context) { //260831.0723 Bind ciphertext to this checkout ID so copied/tampered private state cannot be accepted by another checkout. $payload = array('version' => 1, 'gateway_checkout_id' => (string)$gateway_checkout_id, 'context' => $context); $encrypted = c_ws_plugin__s2member_utils_encryption::encrypt(serialize($payload)); if(!$encrypted) return FALSE; } $state['private_context'] = $encrypted; $state['updated_at'] = time(); if(!update_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id, $state, FALSE)) { $persisted_state = self::get($gateway_checkout_id); if($persisted_state !== $state) return FALSE; } return TRUE; } /** * Gets private encrypted recovery context for a Gateway Checkout. * * @package s2Member\Gateway_Checkouts * @since 260831.0723 * * @param string $gateway_checkout_id Gateway Checkout ID. * * @return array|bool Private recovery context, an empty array when none exists, else FALSE on invalid/corrupt state. */ public static function private_context_get($gateway_checkout_id = '') { $state = self::get($gateway_checkout_id); if(!$state) return FALSE; if(empty($state['private_context'])) return array(); if(!is_string($state['private_context'])) return FALSE; $payload = c_ws_plugin__s2member_utils_arrays::maybe_unserialize(c_ws_plugin__s2member_utils_encryption::decrypt($state['private_context'])); if(!is_array($payload) || empty($payload['version']) || (int)$payload['version'] !== 1 || empty($payload['gateway_checkout_id']) || !hash_equals((string)$gateway_checkout_id, (string)$payload['gateway_checkout_id']) || !isset($payload['context']) || !is_array($payload['context']) || !self::private_context_is_safe($payload['context'])) return FALSE; return $payload['context']; } /** * Clears private encrypted recovery context for a Gateway Checkout. * * @package s2Member\Gateway_Checkouts * @since 260831.0723 * * @param string $gateway_checkout_id Gateway Checkout ID. * * @return bool TRUE if cleared; else FALSE. */ public static function private_context_delete($gateway_checkout_id = '') { return self::private_context_set($gateway_checkout_id, array()); } /** * Acquires an atomic processing lock for a Gateway Checkout. * * @package s2Member\Gateway_Checkouts * @since 260830.0408 * * @param string $gateway_checkout_id Gateway Checkout ID. * @param integer $timeout Optional stale-lock timeout in seconds. * * @return string|bool Lock token if acquired; else FALSE. */ public static function processing_lock($gateway_checkout_id = '', $timeout = 300) { global $wpdb; if(!self::valid_id($gateway_checkout_id) || !self::get($gateway_checkout_id)) return FALSE; $option_name = 's2m_gateway_checkout_lock_'.$gateway_checkout_id; $timeout = max(30, abs((int)$timeout)); $token = self::generate_id(); $lock = array('token' => $token, 'time' => time()); if(add_option($option_name, $lock, '', 'no')) return $token; $existing = get_option($option_name, FALSE); if(!is_array($existing) || empty($existing['time']) || time() - (int)$existing['time'] >= $timeout) { //260830.1504 Delete only the stale lock version we inspected; another request may replace it between get_option() and this delete. $deleted = $wpdb->delete($wpdb->options, array('option_name' => $option_name, 'option_value' => maybe_serialize($existing)), array('%s', '%s')); if($deleted) { wp_cache_delete($option_name, 'options'); if(add_option($option_name, $lock, '', 'no')) return $token; } } return FALSE; } /** * Releases a Gateway Checkout processing lock owned by the supplied token. * * @package s2Member\Gateway_Checkouts * @since 260830.0408 * * @param string $gateway_checkout_id Gateway Checkout ID. * @param string $token Lock token returned by processing_lock(). * * @return bool TRUE if released; else FALSE. */ public static function processing_unlock($gateway_checkout_id = '', $token = '') { global $wpdb; if(!self::valid_id($gateway_checkout_id) || !self::valid_id($token)) return FALSE; $option_name = 's2m_gateway_checkout_lock_'.$gateway_checkout_id; $existing = get_option($option_name, FALSE); if(!is_array($existing) || empty($existing['token']) || !hash_equals((string)$existing['token'], (string)$token)) return FALSE; //260830.1504 Release only the lock version owned by this token; a stale owner must never delete a newer replacement lock. $deleted = $wpdb->delete($wpdb->options, array('option_name' => $option_name, 'option_value' => maybe_serialize($existing)), array('%s', '%s')); if($deleted) wp_cache_delete($option_name, 'options'); return (bool)$deleted; } /** * Deletes durable Gateway Checkout state. * * @package s2Member\Gateway_Checkouts * @since 260829.2325 * * @param string $gateway_checkout_id Gateway Checkout ID. * * @return bool TRUE if state was deleted; else FALSE. */ public static function delete($gateway_checkout_id = '') { if(!self::valid_id($gateway_checkout_id)) return FALSE; delete_option('s2m_gateway_checkout_lock_'.$gateway_checkout_id); return delete_option('ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id); } /** * Creates a signed browser token for a Gateway Checkout identity. * * @package s2Member\Gateway_Checkouts * @since 260829.2325 * * @param string $gateway_checkout_id Gateway Checkout ID. * @param integer $expires_at Optional absolute expiration time for a provisional identity. * * @return string Signed browser token, else an empty string. */ public static function browser_token($gateway_checkout_id = '', $expires_at = 0) { if(!self::valid_id($gateway_checkout_id)) return ''; if(!$expires_at) { $state = self::get($gateway_checkout_id); if(!$state) return ''; $expires_at = (int)$state['expires_at']; } if((int)$expires_at <= time()) return ''; $payload = (string)$gateway_checkout_id.'|'.(int)$expires_at; $key = hash_hmac('sha256', 's2member:gateway-checkout:browser-token', c_ws_plugin__s2member_utils_encryption::key()); $signature = hash_hmac('sha256', $payload, $key); return (int)$expires_at.'.'.$signature; } /** * Verifies a signed Gateway Checkout browser token. * * @package s2Member\Gateway_Checkouts * @since 260829.2325 * * @param string $gateway_checkout_id Gateway Checkout ID. * @param string $browser_token Signed browser token. * * @return bool TRUE if valid; else FALSE. */ public static function browser_token_verify($gateway_checkout_id = '', $browser_token = '') { if(!self::valid_id($gateway_checkout_id) || !is_string($browser_token) || !preg_match('/^([0-9]+)\.([a-f0-9]{64})$/i', $browser_token, $matches)) return FALSE; $expires_at = (int)$matches[1]; if($expires_at <= time()) return FALSE; $payload = (string)$gateway_checkout_id.'|'.$expires_at; $key = hash_hmac('sha256', 's2member:gateway-checkout:browser-token', c_ws_plugin__s2member_utils_encryption::key()); $expected = hash_hmac('sha256', $payload, $key); if(!hash_equals($expected, strtolower($matches[2]))) return FALSE; $state = self::get($gateway_checkout_id); //260830.0059 A provisional browser identity has no state yet; once state exists, its expiration must remain bound to the signed token. return !$state || $expires_at === (int)$state['expires_at']; } /** * Gets the expiration time encoded in a syntactically valid browser token. * * @package s2Member\Gateway_Checkouts * @since 260830.0059 * * @param string $browser_token Signed browser token. * * @return integer Absolute expiration time, else 0. */ protected static function browser_token_expires_at($browser_token = '') { return is_string($browser_token) && preg_match('/^([0-9]+)\.[a-f0-9]{64}$/i', $browser_token, $matches) ? (int)$matches[1] : 0; } /** * Removes a bounded batch of expired Gateway Checkout states. * * @package s2Member\Gateway_Checkouts * @since 260829.2325 * * @param integer $limit Maximum candidate options to inspect. * * @return integer Number of expired/malformed state options removed. */ public static function cleanup_expired($limit = 50) { global $wpdb; $limit = min(500, max(1, abs((int)$limit))); $option_prefix = 'ws_plugin__s2member_gateway_checkout_'; $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)); $removed = 0; foreach((array)$option_names as $option_name) { $gateway_checkout_id = substr((string)$option_name, strlen($option_prefix)); if(!self::valid_id($gateway_checkout_id)) continue; $state = get_option($option_name, FALSE); if(!is_array($state) || empty($state['expires_at']) || (int)$state['expires_at'] <= time()) { if(self::delete($gateway_checkout_id)) $removed++; } } return $removed; } /** * Gets the configured Gateway Checkout state TTL. * * @package s2Member\Gateway_Checkouts * @since 260829.2325 * * @param integer $ttl Optional explicit TTL in seconds. * * @return integer TTL in seconds. */ public static function ttl($ttl = 0) { if((int)$ttl > 0) return abs((int)$ttl); //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. return max(HOUR_IN_SECONDS, abs((int)apply_filters('ws_plugin__s2member_gateway_checkout_ttl', 7 * DAY_IN_SECONDS))); } /** * Validates private recovery context before encryption/persistence. * * @package s2Member\Gateway_Checkouts * @since 260831.0723 * * @param array $context Private recovery context. * * @return bool TRUE if safe to persist; else FALSE. */ protected static function private_context_is_safe($context = array()) { foreach((array)$context as $key => $value) { $normalized_key = strtolower(trim(preg_replace('/[^a-z0-9]+/i', '_', (string)$key), '_')); //260901.0722 Never persist actual login credentials, while allowing unrelated site-defined profile fields such as `password_hint` in otherwise valid private recovery context. $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'); if(in_array($normalized_key, $credential_keys, TRUE)) return FALSE; if(is_array($value)) { if(!self::private_context_is_safe($value)) return FALSE; } else if(is_object($value) || is_resource($value)) return FALSE; } return TRUE; } /** * Creates durable Gateway Checkout state with a specific signed identity. * * @package s2Member\Gateway_Checkouts * @since 260830.0059 * * @param string $gateway_checkout_id Gateway Checkout ID. * @param string $gateway Gateway identifier. * @param string $operation Gateway operation identifier. * @param string $purchase_fingerprint Purchase fingerprint, if already known. * @param integer $user_id WordPress user ID, if already known. * @param integer $ttl State TTL in seconds. * @param integer $expires_at Optional absolute expiration time; used by signed provisional browser identities. * * @return array|bool Gateway Checkout state, else FALSE. */ protected static function create_with_id($gateway_checkout_id = '', $gateway = '', $operation = '', $purchase_fingerprint = '', $user_id = 0, $ttl = 0, $expires_at = 0) { $gateway = sanitize_key((string)$gateway); $operation = sanitize_key((string)$operation); $user_id = abs((int)$user_id); $expires_at = abs((int)$expires_at); $ttl = $expires_at ? 0 : self::ttl($ttl); if(!self::valid_id($gateway_checkout_id) || !$gateway || !$operation || (!$ttl && $expires_at <= time())) return FALSE; $option_name = 'ws_plugin__s2member_gateway_checkout_'.$gateway_checkout_id; $now = time(); $expires_at = $expires_at ?: $now + $ttl; $state = array( 'version' => 1, 'id' => (string)$gateway_checkout_id, 'gateway' => $gateway, 'operation' => $operation, 'purchase_fingerprint' => (string)$purchase_fingerprint, 'user_id' => $user_id, 'gateway_ids' => array(), 'gateway_status' => '', 'context' => array(), //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. 'private_context' => '', 'fulfillment_status' => 'pending', 'created_at' => $now, 'updated_at' => $now, 'expires_at' => $expires_at, ); if(!add_option($option_name, $state, '', 'no')) return FALSE; //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. if(wp_rand(1, 50) === 1) self::cleanup_expired(100); return $state; } } }