Store API - Cart JWT tokens/session handling (https://github.com/woocommerce/woocommerce-blocks/pull/5953)
* Re-apply token support * Updated nonce headers * Updated package-lock.json * test commit to debug failing git hooks * Revert "test commit to debug failing git hooks" This reverts commit e64086b0a7aede154705be09c7b3433b08bc1e34. * JsonWebToken utility class for generating and validating HS256 JWT tokens. Removed third-party JWT library. * Add ext-hash to composer (required by hash_hmac()) * Removed unnecessary method param. * Tests for retrieving cart contents via Cart-Token * Removed token tests ( we can't properly test cart token functionality until we refactor the way it intercepts calls to replace the session object ) * Abstracted payload from JsonWebToken class. We can now use it to encode custom payloads and reuse them wherever we want. * Fixed missing check for token expiration in the payload. * MD lint error and config fix * Update composer.lock * Fixed bug using the wrong nonce header. * Refactor to properly save session data based on cart token. * Refactored DB queries to properly use prepared statement * Removed underscore prefix for class attributes * Fixed spaces instead of tabs indenting composer.json. Cleaned up .editorconfig * Cleaned up borked .md comments. * Comment for WP_SETUP_CONFIG check. * Reverted SQL prepared statement for including table names. * Used hash_equals() for signature comparison. Renamed some wrongly named properties. * Updated composer.lock * Reverted some accidentally removed lines on some documentation files. * Reverted accidentally removed line on docs/internal-developers/testing/releases/404.md * Changed param type from mixed to Co-authored-by: Paulo Arromba <17236129+wavvves@users.noreply.github.com> Co-authored-by: Seghir Nadir <nadir.seghir@gmail.com>
This commit is contained in:
parent
33910f316f
commit
6f93c5cf1b
|
@ -18,10 +18,10 @@ trim_trailing_whitespace = true
|
|||
[*.txt]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{json,yml}]
|
||||
[*.yml]
|
||||
trim_trailing_whitespace = false
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.json]
|
||||
indent_style = tab
|
||||
[*.md]
|
||||
indent_style = space
|
||||
|
|
|
@ -12,6 +12,8 @@
|
|||
"prefer-stable": true,
|
||||
"minimum-stability": "dev",
|
||||
"require": {
|
||||
"ext-hash": "*",
|
||||
"ext-json": "*",
|
||||
"composer/installers": "^1.7.0",
|
||||
"automattic/jetpack-autoloader": "^2.9.1"
|
||||
},
|
||||
|
|
|
@ -4,20 +4,20 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "938acec166de3572a0884e7e81a9ba52",
|
||||
"content-hash": "d441b0288f8e32e9445bc3887462211c",
|
||||
"packages": [
|
||||
{
|
||||
"name": "automattic/jetpack-autoloader",
|
||||
"version": "v2.11.7",
|
||||
"version": "v2.11.9",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Automattic/jetpack-autoloader.git",
|
||||
"reference": "65170ab358aa5a8efd9de96666a46b74dc74513d"
|
||||
"reference": "966247ebaf42f4c9076144f0eee0e3ea90fdd4c9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Automattic/jetpack-autoloader/zipball/65170ab358aa5a8efd9de96666a46b74dc74513d",
|
||||
"reference": "65170ab358aa5a8efd9de96666a46b74dc74513d",
|
||||
"url": "https://api.github.com/repos/Automattic/jetpack-autoloader/zipball/966247ebaf42f4c9076144f0eee0e3ea90fdd4c9",
|
||||
"reference": "966247ebaf42f4c9076144f0eee0e3ea90fdd4c9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
@ -53,9 +53,9 @@
|
|||
],
|
||||
"description": "Creates a custom autoloader for a plugin or theme.",
|
||||
"support": {
|
||||
"source": "https://github.com/Automattic/jetpack-autoloader/tree/v2.11.7"
|
||||
"source": "https://github.com/Automattic/jetpack-autoloader/tree/v2.11.9"
|
||||
},
|
||||
"time": "2022-07-26T13:41:25+00:00"
|
||||
"time": "2022-09-27T17:31:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/installers",
|
||||
|
@ -1837,16 +1837,16 @@
|
|||
},
|
||||
{
|
||||
"name": "sebastian/comparator",
|
||||
"version": "4.0.6",
|
||||
"version": "4.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/comparator.git",
|
||||
"reference": "55f4261989e546dc112258c7a75935a81a7ce382"
|
||||
"reference": "fa0f136dd2334583309d32b62544682ee972b51a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55f4261989e546dc112258c7a75935a81a7ce382",
|
||||
"reference": "55f4261989e546dc112258c7a75935a81a7ce382",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a",
|
||||
"reference": "fa0f136dd2334583309d32b62544682ee972b51a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
@ -1899,7 +1899,7 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/comparator/issues",
|
||||
"source": "https://github.com/sebastianbergmann/comparator/tree/4.0.6"
|
||||
"source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
@ -1907,7 +1907,7 @@
|
|||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2020-10-26T15:49:45+00:00"
|
||||
"time": "2022-09-14T12:41:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/diff",
|
||||
|
@ -2040,16 +2040,16 @@
|
|||
},
|
||||
{
|
||||
"name": "sebastian/exporter",
|
||||
"version": "4.0.4",
|
||||
"version": "4.0.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/exporter.git",
|
||||
"reference": "65e8b7db476c5dd267e65eea9cab77584d3cfff9"
|
||||
"reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/65e8b7db476c5dd267e65eea9cab77584d3cfff9",
|
||||
"reference": "65e8b7db476c5dd267e65eea9cab77584d3cfff9",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d",
|
||||
"reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
@ -2105,7 +2105,7 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/exporter/issues",
|
||||
"source": "https://github.com/sebastianbergmann/exporter/tree/4.0.4"
|
||||
"source": "https://github.com/sebastianbergmann/exporter/tree/4.0.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
@ -2113,7 +2113,7 @@
|
|||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2021-11-11T14:18:36+00:00"
|
||||
"time": "2022-09-14T06:03:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/global-state",
|
||||
|
@ -2514,16 +2514,16 @@
|
|||
},
|
||||
{
|
||||
"name": "squizlabs/php_codesniffer",
|
||||
"version": "3.6.1",
|
||||
"version": "3.7.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
|
||||
"reference": "f268ca40d54617c6e06757f83f699775c9b3ff2e"
|
||||
"reference": "1359e176e9307e906dc3d890bcc9603ff6d90619"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/f268ca40d54617c6e06757f83f699775c9b3ff2e",
|
||||
"reference": "f268ca40d54617c6e06757f83f699775c9b3ff2e",
|
||||
"url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/1359e176e9307e906dc3d890bcc9603ff6d90619",
|
||||
"reference": "1359e176e9307e906dc3d890bcc9603ff6d90619",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
@ -2566,7 +2566,7 @@
|
|||
"source": "https://github.com/squizlabs/PHP_CodeSniffer",
|
||||
"wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki"
|
||||
},
|
||||
"time": "2021-10-11T04:00:11+00:00"
|
||||
"time": "2022-06-18T07:21:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "theseer/tokenizer",
|
||||
|
@ -2828,7 +2828,10 @@
|
|||
"stability-flags": [],
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": [],
|
||||
"platform": {
|
||||
"ext-hash": "*",
|
||||
"ext-json": "*"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"platform-overrides": {
|
||||
"php": "7.4.24"
|
||||
|
|
|
@ -1,14 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
|
||||
|
||||
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
|
||||
use Automattic\WooCommerce\StoreApi\SchemaController;
|
||||
use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema;
|
||||
use Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema;
|
||||
use Automattic\WooCommerce\StoreApi\Schemas\V1\CartItemSchema;
|
||||
use Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema;
|
||||
use Automattic\WooCommerce\StoreApi\SessionHandler;
|
||||
use Automattic\WooCommerce\StoreApi\Utilities\CartController;
|
||||
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
|
||||
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
|
||||
use Automattic\WooCommerce\StoreApi\Utilities\OrderController;
|
||||
|
||||
/**
|
||||
* Abstract Cart Route
|
||||
*/
|
||||
|
@ -16,7 +20,7 @@ abstract class AbstractCartRoute extends AbstractRoute {
|
|||
use DraftOrderTrait;
|
||||
|
||||
/**
|
||||
* The routes schema.
|
||||
* The route's schema.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
|
@ -29,6 +33,13 @@ abstract class AbstractCartRoute extends AbstractRoute {
|
|||
*/
|
||||
protected $cart_schema;
|
||||
|
||||
/**
|
||||
* Schema class for the cart item.
|
||||
*
|
||||
* @var CartItemSchema
|
||||
*/
|
||||
protected $cart_item_schema;
|
||||
|
||||
/**
|
||||
* Cart controller class instance.
|
||||
*
|
||||
|
@ -50,29 +61,30 @@ abstract class AbstractCartRoute extends AbstractRoute {
|
|||
* @param AbstractSchema $schema Schema class for this route.
|
||||
*/
|
||||
public function __construct( SchemaController $schema_controller, AbstractSchema $schema ) {
|
||||
$this->schema_controller = $schema_controller;
|
||||
$this->schema = $schema;
|
||||
$this->cart_schema = $this->schema_controller->get( CartSchema::IDENTIFIER );
|
||||
$this->cart_item_schema = $this->schema_controller->get( CartItemSchema::IDENTIFIER );
|
||||
$this->cart_controller = new CartController();
|
||||
$this->order_controller = new OrderController();
|
||||
parent::__construct( $schema_controller, $schema );
|
||||
|
||||
$this->cart_schema = $this->schema_controller->get( CartSchema::IDENTIFIER );
|
||||
$this->cart_item_schema = $this->schema_controller->get( CartItemSchema::IDENTIFIER );
|
||||
$this->cart_controller = new CartController();
|
||||
$this->order_controller = new OrderController();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the route response based on the type of request.
|
||||
*
|
||||
* @param \WP_REST_Request $request Request object.
|
||||
* @return \WP_Error|\WP_REST_Response
|
||||
*
|
||||
* @return \WP_REST_Response
|
||||
*/
|
||||
public function get_response( \WP_REST_Request $request ) {
|
||||
$this->cart_controller->load_cart();
|
||||
$this->load_cart_session( $request );
|
||||
$this->calculate_totals();
|
||||
|
||||
if ( $this->requires_nonce( $request ) ) {
|
||||
$nonce_check = $this->check_nonce( $request );
|
||||
|
||||
if ( is_wp_error( $nonce_check ) ) {
|
||||
return $this->add_nonce_headers( $this->error_to_response( $nonce_check ) );
|
||||
return $this->add_response_headers( $this->error_to_response( $nonce_check ) );
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -81,7 +93,7 @@ abstract class AbstractCartRoute extends AbstractRoute {
|
|||
} catch ( RouteException $error ) {
|
||||
$response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() );
|
||||
} catch ( \Exception $error ) {
|
||||
$response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage(), 500 );
|
||||
$response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage() );
|
||||
}
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
|
@ -90,21 +102,23 @@ abstract class AbstractCartRoute extends AbstractRoute {
|
|||
$this->cart_updated( $request );
|
||||
}
|
||||
|
||||
return $this->add_nonce_headers( $response );
|
||||
return $this->add_response_headers( $response );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add nonce headers to a response object.
|
||||
*
|
||||
* @param \WP_REST_Response $response The response object.
|
||||
*
|
||||
* @return \WP_REST_Response
|
||||
*/
|
||||
protected function add_nonce_headers( \WP_REST_Response $response ) {
|
||||
protected function add_response_headers( \WP_REST_Response $response ) {
|
||||
$nonce = wp_create_nonce( 'wc_store_api' );
|
||||
|
||||
$response->header( 'Nonce', $nonce );
|
||||
$response->header( 'Nonce-Timestamp', time() );
|
||||
$response->header( 'User-ID', get_current_user_id() );
|
||||
$response->header( 'Cart-Token', $this->get_cart_token() );
|
||||
|
||||
// The following headers are deprecated and should be removed in a future version.
|
||||
$response->header( 'X-WC-Store-API-Nonce', $nonce );
|
||||
|
@ -112,10 +126,69 @@ abstract class AbstractCartRoute extends AbstractRoute {
|
|||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the cart session before handling responses.
|
||||
*
|
||||
* @param \WP_REST_Request $request Request object.
|
||||
*/
|
||||
protected function load_cart_session( \WP_REST_Request $request ) {
|
||||
$cart_token = $request->get_header( 'Cart-Token' );
|
||||
|
||||
if ( $cart_token && JsonWebToken::validate( $cart_token, $this->get_cart_token_secret() ) ) {
|
||||
// Overrides the core session class.
|
||||
add_filter(
|
||||
'woocommerce_session_handler',
|
||||
function () {
|
||||
return SessionHandler::class;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
$this->cart_controller->load_cart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cart token for the response headers.
|
||||
*
|
||||
* Current namespace is used as the token Issuer.
|
||||
* *
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_cart_token() {
|
||||
return JsonWebToken::create(
|
||||
[
|
||||
'user_id' => wc()->session->get_customer_id(),
|
||||
'exp' => $this->get_cart_token_expiration(),
|
||||
'iss' => $this->namespace,
|
||||
],
|
||||
$this->get_cart_token_secret()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the secret for the cart token using wp_salt.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_cart_token_secret() {
|
||||
return '@' . wp_salt();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the expiration of the cart token. Defaults to 48h.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
protected function get_cart_token_expiration() {
|
||||
return time() + intval( apply_filters( 'wc_session_expiration', 60 * 60 * 48 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a nonce is required for the route.
|
||||
*
|
||||
* @param \WP_REST_Request $request Request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function requires_nonce( \WP_REST_Request $request ) {
|
||||
|
@ -173,6 +246,7 @@ abstract class AbstractCartRoute extends AbstractRoute {
|
|||
* to match the logged in cookie in your browser.
|
||||
*
|
||||
* @param \WP_REST_Request $request Request object.
|
||||
*
|
||||
* @return \WP_Error|boolean
|
||||
*/
|
||||
protected function check_nonce( \WP_REST_Request $request ) {
|
||||
|
@ -194,6 +268,7 @@ abstract class AbstractCartRoute extends AbstractRoute {
|
|||
* This can be used to disable the nonce check when testing API endpoints via a REST API client.
|
||||
*
|
||||
* @param boolean $disable_nonce_check If true, nonce checks will be disabled.
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
if ( apply_filters( 'woocommerce_store_api_disable_nonce_check', false ) ) {
|
||||
|
@ -217,7 +292,8 @@ abstract class AbstractCartRoute extends AbstractRoute {
|
|||
* @param string $error_code String based error code.
|
||||
* @param string $error_message User facing error message.
|
||||
* @param int $http_status_code HTTP status. Defaults to 500.
|
||||
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
|
||||
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
|
||||
*
|
||||
* @return \WP_Error WP Error object.
|
||||
*/
|
||||
protected function get_route_error_response( $error_code, $error_message, $http_status_code = 500, $additional_data = [] ) {
|
||||
|
@ -238,6 +314,7 @@ abstract class AbstractCartRoute extends AbstractRoute {
|
|||
)
|
||||
);
|
||||
}
|
||||
|
||||
return new \WP_Error( $error_code, $error_message, [ 'status' => $http_status_code ] );
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
namespace Automattic\WooCommerce\StoreApi;
|
||||
|
||||
use Automattic\Jetpack\Constants;
|
||||
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
|
||||
use WC_Session;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* SessionHandler class
|
||||
*/
|
||||
final class SessionHandler extends WC_Session {
|
||||
/**
|
||||
* Token from HTTP headers.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $token;
|
||||
|
||||
/**
|
||||
* Table name for session data.
|
||||
*
|
||||
* @var string Custom session table name
|
||||
*/
|
||||
protected $table;
|
||||
|
||||
/**
|
||||
* Expiration timestamp.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $session_expiration;
|
||||
|
||||
/**
|
||||
* Constructor for the session class.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->token = wc_clean( wp_unslash( $_SERVER['HTTP_CART_TOKEN'] ?? '' ) );
|
||||
$this->table = $GLOBALS['wpdb']->prefix . 'woocommerce_sessions';
|
||||
}
|
||||
|
||||
/**
|
||||
* Init hooks and session data.
|
||||
*/
|
||||
public function init() {
|
||||
$this->init_session_from_token();
|
||||
add_action( 'shutdown', array( $this, 'save_data' ), 20 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the token header to load the correct session.
|
||||
*/
|
||||
protected function init_session_from_token() {
|
||||
$payload = JsonWebToken::get_parts( $this->token )->payload;
|
||||
|
||||
$this->_customer_id = $payload->user_id;
|
||||
$this->session_expiration = $payload->exp;
|
||||
$this->_data = (array) $this->get_session( $this->_customer_id, array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the session.
|
||||
*
|
||||
* @param string $customer_id Customer ID.
|
||||
* @param mixed $default Default session value.
|
||||
*
|
||||
* @return string|array|bool
|
||||
*/
|
||||
public function get_session( $customer_id, $default = false ) {
|
||||
global $wpdb;
|
||||
|
||||
// This mimics behaviour from default WC_Session_Handler class. There will be no sessions retrieved while WP setup is due.
|
||||
if ( Constants::is_defined( 'WP_SETUP_CONFIG' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$value = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT session_value FROM $this->table WHERE session_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$customer_id
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_null( $value ) ) {
|
||||
$value = $default;
|
||||
}
|
||||
|
||||
return maybe_unserialize( $value );
|
||||
}
|
||||
|
||||
/**
|
||||
* Save data and delete user session.
|
||||
*/
|
||||
public function save_data() {
|
||||
// Dirty if something changed - prevents saving nothing new.
|
||||
if ( $this->_dirty ) {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO $this->table (`session_key`, `session_value`, `session_expiry`) VALUES (%s, %s, %d) ON DUPLICATE KEY UPDATE `session_value` = VALUES(`session_value`), `session_expiry` = VALUES(`session_expiry`)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$this->_customer_id,
|
||||
maybe_serialize( $this->_data ),
|
||||
$this->session_expiration
|
||||
)
|
||||
);
|
||||
|
||||
$this->_dirty = false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,196 @@
|
|||
<?php
|
||||
|
||||
namespace Automattic\WooCommerce\StoreApi\Utilities;
|
||||
|
||||
/**
|
||||
* JsonWebToken class.
|
||||
*
|
||||
* Simple Json Web Token generator & verifier static utility class, currently supporting only HS256 signatures.
|
||||
*/
|
||||
final class JsonWebToken {
|
||||
|
||||
/**
|
||||
* JWT header type.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private static $type = 'JWT';
|
||||
|
||||
/**
|
||||
* JWT algorithm to generate signature.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private static $algorithm = 'HS256';
|
||||
|
||||
/**
|
||||
* Generates a token from provided data and secret.
|
||||
*
|
||||
* @param array $payload Payload data.
|
||||
* @param string $secret The secret used to generate the signature.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function create( array $payload, string $secret ) {
|
||||
$header = self::to_base_64_url( self::generate_header() );
|
||||
$payload = self::to_base_64_url( self::generate_payload( $payload ) );
|
||||
$signature = self::to_base_64_url( self::generate_signature( $header . '.' . $payload, $secret ) );
|
||||
|
||||
return $header . '.' . $payload . '.' . $signature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a provided token against the provided secret.
|
||||
* Checks for format, valid header for our class, expiration claim validity and signature.
|
||||
* https://datatracker.ietf.org/doc/html/rfc7519#section-7.2
|
||||
*
|
||||
* @param string $token Full token string.
|
||||
* @param string $secret The secret used to generate the signature.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function validate( string $token, string $secret ) {
|
||||
/**
|
||||
* Confirm the structure of a JSON Web Token, it has three parts separated
|
||||
* by dots and complies with Base64URL standards.
|
||||
*/
|
||||
if ( preg_match( '/^[a-zA-Z\d\-_=]+\.[a-zA-Z\d\-_=]+\.[a-zA-Z\d\-_=]+$/', $token ) !== 1 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$parts = self::get_parts( $token );
|
||||
|
||||
/**
|
||||
* Check if header declares a supported JWT by this class.
|
||||
*/
|
||||
if (
|
||||
! is_object( $parts->header ) ||
|
||||
! property_exists( $parts->header, 'typ' ) ||
|
||||
! property_exists( $parts->header, 'alg' ) ||
|
||||
self::$type !== $parts->header->typ ||
|
||||
self::$algorithm !== $parts->header->alg
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired.
|
||||
*/
|
||||
if ( ! property_exists( $parts->payload, 'exp' ) || time() > (int) $parts->payload->exp ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the token is based on our secret.
|
||||
*/
|
||||
$encoded_regenerated_signature = self::to_base_64_url(
|
||||
self::generate_signature( $parts->header_encoded . '.' . $parts->payload_encoded, $secret )
|
||||
);
|
||||
|
||||
return hash_equals( $encoded_regenerated_signature, $parts->signature_encoded );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the decoded/encoded header, payload and signature from a token string.
|
||||
*
|
||||
* @param string $token Full token string.
|
||||
*
|
||||
* @return object
|
||||
*/
|
||||
public static function get_parts( string $token ) {
|
||||
$parts = explode( '.', $token );
|
||||
|
||||
return (object) array(
|
||||
'header' => json_decode( self::from_base_64_url( $parts[0] ) ),
|
||||
'header_encoded' => $parts[0],
|
||||
'payload' => json_decode( self::from_base_64_url( $parts[1] ) ),
|
||||
'payload_encoded' => $parts[1],
|
||||
'signature' => self::from_base_64_url( $parts[2] ),
|
||||
'signature_encoded' => $parts[2],
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the json formatted header for our HS256 JWT token.
|
||||
*
|
||||
* @return string|bool
|
||||
*/
|
||||
private static function generate_header() {
|
||||
return wp_json_encode(
|
||||
array(
|
||||
'alg' => self::$algorithm,
|
||||
'typ' => self::$type,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a sha256 signature for the provided string using the provided secret.
|
||||
*
|
||||
* @param string $string Header + Payload token substring.
|
||||
* @param string $secret The secret used to generate the signature.
|
||||
*
|
||||
* @return false|string
|
||||
*/
|
||||
private static function generate_signature( string $string, string $secret ) {
|
||||
return hash_hmac(
|
||||
'sha256',
|
||||
$string,
|
||||
$secret,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the payload in json formatted string.
|
||||
*
|
||||
* @param array $payload Payload data.
|
||||
*
|
||||
* @return string|bool
|
||||
*/
|
||||
private static function generate_payload( array $payload ) {
|
||||
return wp_json_encode( array_merge( $payload, [ 'iat' => time() ] ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a string to url safe base64.
|
||||
*
|
||||
* @param string $string The string to be encoded.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function to_base_64_url( string $string ) {
|
||||
return str_replace(
|
||||
array( '+', '/', '=' ),
|
||||
array( '-', '_', '' ),
|
||||
base64_encode( $string ) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a string encoded using url safe base64, supporting auto padding.
|
||||
*
|
||||
* @param string $string the string to be decoded.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function from_base_64_url( string $string ) {
|
||||
/**
|
||||
* Add padding to base64 strings which require it. Some base64 URL strings
|
||||
* which are decoded will have missing padding which is represented by the
|
||||
* equals sign.
|
||||
*/
|
||||
if ( strlen( $string ) % 4 !== 0 ) {
|
||||
return self::from_base_64_url( $string . '=' );
|
||||
}
|
||||
|
||||
return base64_decode( // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
|
||||
str_replace(
|
||||
array( '-', '_' ),
|
||||
array( '+', '/' ),
|
||||
$string
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -7,6 +7,9 @@ namespace Automattic\WooCommerce\Blocks\Tests\StoreApi\Routes;
|
|||
|
||||
use Automattic\WooCommerce\Blocks\Tests\Helpers\FixtureData;
|
||||
use Automattic\WooCommerce\Blocks\Tests\Helpers\ValidateSchema;
|
||||
use Automattic\WooCommerce\StoreApi\SessionHandler;
|
||||
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
|
||||
use Spy_REST_Server;
|
||||
|
||||
/**
|
||||
* Cart Controller Tests.
|
||||
|
@ -87,7 +90,7 @@ class Cart extends ControllerTestCase {
|
|||
'needs_payment' => true,
|
||||
'needs_shipping' => true,
|
||||
'items_weight' => '30',
|
||||
'items' => function( $value ) {
|
||||
'items' => function ( $value ) {
|
||||
return count( $value ) === 2;
|
||||
},
|
||||
'cross_sells' => array(
|
||||
|
@ -155,7 +158,7 @@ class Cart extends ControllerTestCase {
|
|||
200,
|
||||
array(
|
||||
'items_count' => 1,
|
||||
'items' => function( $value ) {
|
||||
'items' => function ( $value ) {
|
||||
return count( $value ) === 1;
|
||||
},
|
||||
'items_weight' => '10',
|
||||
|
@ -221,7 +224,13 @@ class Cart extends ControllerTestCase {
|
|||
$action_callback = \Mockery::mock( 'ActionCallback' );
|
||||
$action_callback->shouldReceive( 'do_customer_callback' )->once();
|
||||
|
||||
add_action( 'woocommerce_store_api_cart_update_customer_from_request', array( $action_callback, 'do_customer_callback' ) );
|
||||
add_action(
|
||||
'woocommerce_store_api_cart_update_customer_from_request',
|
||||
array(
|
||||
$action_callback,
|
||||
'do_customer_callback',
|
||||
)
|
||||
);
|
||||
|
||||
$this->assertAPIResponse(
|
||||
$request,
|
||||
|
@ -242,7 +251,13 @@ class Cart extends ControllerTestCase {
|
|||
)
|
||||
);
|
||||
|
||||
remove_action( 'woocommerce_store_api_cart_update_customer_from_request', array( $action_callback, 'do_customer_callback' ) );
|
||||
remove_action(
|
||||
'woocommerce_store_api_cart_update_customer_from_request',
|
||||
array(
|
||||
$action_callback,
|
||||
'do_customer_callback',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -509,4 +524,24 @@ class Cart extends ControllerTestCase {
|
|||
$diff = $validate->get_diff_from_object( $response->get_data() );
|
||||
$this->assertEmpty( $diff );
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests for Cart-Token header presence and validity.
|
||||
*/
|
||||
public function test_cart_token_header() {
|
||||
|
||||
/** @var Spy_REST_Server $server */
|
||||
$server = rest_get_server();
|
||||
|
||||
$server->serve_request( '/wc/store/cart' );
|
||||
|
||||
$this->assertArrayHasKey( 'Cart-Token', $server->sent_headers );
|
||||
|
||||
$this->assertTrue(
|
||||
JsonWebToken::validate(
|
||||
$server->sent_headers['Cart-Token'],
|
||||
'@' . wp_salt()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -95,9 +95,9 @@ abstract class ControllerTestCase extends \WP_Test_REST_TestCase {
|
|||
/**
|
||||
* Custom assertion of an API response to confirm data and response code is expected.
|
||||
*
|
||||
* @param string $endpoint_or_request Route endpoint to get.
|
||||
* @param int $expected_response_code Expected response code.
|
||||
* @param array $expected_response_data Expected response data.
|
||||
* @param string|\WP_Rest_Request $endpoint_or_request Route endpoint to get.
|
||||
* @param int $expected_response_code Expected response code.
|
||||
* @param array $expected_response_data Expected response data.
|
||||
*/
|
||||
public function assertApiResponse( $endpoint_or_request, $expected_response_code, $expected_response_data = null ) {
|
||||
$response = is_a( $endpoint_or_request, '\WP_Rest_Request' ) ? rest_get_server()->dispatch( $endpoint_or_request ) : $this->getApiResponse( $endpoint_or_request );
|
||||
|
|
Loading…
Reference in New Issue