(int) $order_id, 'jti' => wp_generate_password( 32, false, false ), 'exp' => time() + ( absint( $ttl_minutes ) * MINUTE_IN_SECONDS ), ); $body = self::encode( wp_json_encode( $payload ) ); return $body . '.' . self::encode( Hash::hmac( $body ) ); } /** * @return array|false Payload array on success, false otherwise. */ public static function verify( $ticket ) { $ticket = (string) $ticket; if ( '' === $ticket || false === strpos( $ticket, '.' ) ) { return false; } list( $body, $signature ) = array_pad( explode( '.', $ticket, 2 ), 2, '' ); if ( ! Hash::equals( self::encode( Hash::hmac( $body ) ), $signature ) ) { return false; } $payload = json_decode( self::decode( $body ), true ); if ( ! is_array( $payload ) || ! isset( $payload['oid'], $payload['exp'] ) ) { return false; } if ( (int) $payload['oid'] < 1 ) { return false; } if ( time() > (int) $payload['exp'] ) { return false; } return $payload; } /** Storage key for a ticket's single-use marker. */ private static function key( array $payload ) { return self::KEY_PREFIX . substr( Hash::hmac( (string) $payload['jti'] ), 0, 32 ); } /** * Whether this ticket was already used. Checked before the request is * validated, so a replay is refused without spending anything. */ public static function spent( array $payload ) { if ( empty( $payload['jti'] ) ) { return true; } return (bool) get_transient( self::key( $payload ) ); } /** * Tickets are single use: a valid ticket must not be replayable for the * remainder of its lifetime. Called once the request is about to be stored, * so a validation message does not cost the customer their session. */ public static function consume( array $payload ) { if ( empty( $payload['jti'] ) ) { return false; } $key = self::key( $payload ); if ( get_transient( $key ) ) { return false; } $ttl = max( 60, (int) $payload['exp'] - time() ); set_transient( $key, 1, $ttl ); // The reservation has served its purpose once the permanent marker exists. delete_option( $key ); return true; } /** * Claims a ticket for the caller, atomically. Returns true only for the first * caller; a concurrent request holding the same ticket gets false. * * The claim is an `add_option` with autoload off, which fails when the key * already exists. */ public static function reserve( array $payload ) { if ( empty( $payload['jti'] ) ) { return false; } $key = self::key( $payload ); if ( get_transient( $key ) ) { return false; } if ( ! add_option( $key, time(), '', 'no' ) ) { return false; } return true; } /** Prefix every claim and spent-marker key shares. */ const KEY_PREFIX = 'careback_jti_'; /** * Removes claims left behind by a request that never finished. * * A claim is an option with no expiry — that is what makes it atomic — and * it is normally removed a moment later, either by consume() when the * request is stored or by release() when the customer is sent back to fix * the form. A request that dies in between, a PHP timeout or a fatal, leaves * the row behind for good. One orphan is nothing; a site that has been * running for two years with a flaky host accumulates thousands, and every * one of them is a row in the options table. * * Anything older than a day is safe to drop: the longest ticket the plugin * issues is measured in minutes, so a claim that old belongs to no session * anyone is still in. * * @param int $older_than Seconds a claim may live before it counts as stale. * @return int Claims removed. */ public static function sweep_claims( $older_than = DAY_IN_SECONDS ) { global $wpdb; $cutoff = time() - max( HOUR_IN_SECONDS, (int) $older_than ); // esc_like() escapes the underscores too, which is the point: an // unescaped `careback_jti_` would also match option names like `carebackXjtiY`. $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- no WordPress API lists options by name prefix; read live. $wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( self::KEY_PREFIX ) . '%' ), ARRAY_A ); $removed = 0; foreach ( (array) $rows as $row ) { // The value is the time the claim was made. A row that does not hold // one is not ours to judge, so it is left alone. $claimed = isset( $row['option_value'] ) ? (int) $row['option_value'] : 0; if ( $claimed > 0 && $claimed < $cutoff ) { delete_option( $row['option_name'] ); $removed++; } } return $removed; } /** Releases a claim so the customer can correct the form and resubmit. */ public static function release( array $payload ) { if ( empty( $payload['jti'] ) ) { return; } delete_option( self::key( $payload ) ); } private static function encode( $value ) { return rtrim( strtr( base64_encode( $value ), '+/', '-_' ), '=' ); } private static function decode( $value ) { return base64_decode( strtr( $value, '-_', '+/' ) ); } }