From a4becdc228b4906bf2c6660068500760b898dde9 Mon Sep 17 00:00:00 2001 From: Geoff Taylor Date: Wed, 19 Jun 2024 17:43:34 -0400 Subject: [PATCH] feat: QL Session Handler functionality expanded to support cookies on non-GraphQL requests --- codeception.dist.yml | 4 + composer.lock | 12 +- includes/admin/class-general.php | 18 +++ includes/class-woocommerce-filters.php | 17 ++- includes/utils/class-ql-session-handler.php | 121 +++++++++----------- tests/wpunit/QLSessionHandlerTest.php | 98 +++++++++++++++- 6 files changed, 192 insertions(+), 78 deletions(-) diff --git a/codeception.dist.yml b/codeception.dist.yml index 62a0f351..283cbaf9 100644 --- a/codeception.dist.yml +++ b/codeception.dist.yml @@ -66,6 +66,10 @@ modules: uploads: '/wp-content/uploads' WPLoader: wpRootFolder: '%WP_CORE_DIR%' + dbHost: '%DB_HOST%' + dbName: '%DB_NAME%' + dbUser: '%DB_USER%' + dbPassword: '%DB_PASSWORD%' dbUrl: 'mysql://%DB_USER%:%DB_PASSWORD%@%DB_HOST%:%DB_PORT%/%DB_NAME%' tablePrefix: '%WP_TABLE_PREFIX%' domain: '%WORDPRESS_DOMAIN%' diff --git a/composer.lock b/composer.lock index be089afe..e3c9e863 100644 --- a/composer.lock +++ b/composer.lock @@ -1128,16 +1128,16 @@ }, { "name": "symfony/polyfill-php73", - "version": "v1.29.0", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "21bd091060673a1177ae842c0ef8fe30893114d2" + "reference": "ec444d3f3f6505bb28d11afa41e75faadebc10a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/21bd091060673a1177ae842c0ef8fe30893114d2", - "reference": "21bd091060673a1177ae842c0ef8fe30893114d2", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/ec444d3f3f6505bb28d11afa41e75faadebc10a1", + "reference": "ec444d3f3f6505bb28d11afa41e75faadebc10a1", "shasum": "" }, "require": { @@ -1184,7 +1184,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.29.0" + "source": "https://github.com/symfony/polyfill-php73/tree/v1.30.0" }, "funding": [ { @@ -1200,7 +1200,7 @@ "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2024-05-31T15:07:36+00:00" }, { "name": "szepeviktor/phpstan-wordpress", diff --git a/includes/admin/class-general.php b/includes/admin/class-general.php index ded29c5b..eb27e307 100644 --- a/includes/admin/class-general.php +++ b/includes/admin/class-general.php @@ -77,6 +77,24 @@ public static function get_fields() { 'value' => defined( 'NO_QL_SESSION_HANDLER' ) ? 'on' : woographql_setting( 'disable_ql_session_handler', 'off' ), 'disabled' => defined( 'NO_QL_SESSION_HANDLER' ), ], + [ + 'name' => 'enable_ql_session_handler_on_ajax', + 'label' => __( 'Enable QL Session Handler on WC AJAX requests.', 'wp-graphql-woocommerce' ), + 'desc' => __( 'Enabling this will enable JSON Web Tokens usage on WC AJAX requests.', 'wp-graphql-woocommerce' ) + . ( defined( 'NO_QL_SESSION_HANDLER' ) ? __( ' This setting is disabled. The "NO_QL_SESSION_HANDLER" flag has been triggered with code', 'wp-graphql-woocommerce' ) : '' ), + 'type' => 'checkbox', + 'value' => defined( 'NO_QL_SESSION_HANDLER' ) ? 'off' : woographql_setting( 'enable_ql_session_handler_on_ajax', 'off' ), + 'disabled' => defined( 'NO_QL_SESSION_HANDLER' ), + ], + [ + 'name' => 'enable_ql_session_handler_on_rest', + 'label' => __( 'Enable QL Session Handler on WP REST requests.', 'wp-graphql-woocommerce' ), + 'desc' => __( 'Enabling this will enable JSON Web Tokens usage on WP REST requests.', 'wp-graphql-woocommerce' ) + . ( defined( 'NO_QL_SESSION_HANDLER' ) ? __( ' This setting is disabled. The "NO_QL_SESSION_HANDLER" flag has been triggered with code', 'wp-graphql-woocommerce' ) : '' ), + 'type' => 'checkbox', + 'value' => defined( 'NO_QL_SESSION_HANDLER' ) ? 'off' : woographql_setting( 'enable_ql_session_handler_on_rest', 'off' ), + 'disabled' => defined( 'NO_QL_SESSION_HANDLER' ), + ], [ 'name' => 'enable_unsupported_product_type', 'label' => __( 'Enable Unsupported types', 'wp-graphql-woocommerce' ), diff --git a/includes/class-woocommerce-filters.php b/includes/class-woocommerce-filters.php index 5e6ea366..dad0925b 100644 --- a/includes/class-woocommerce-filters.php +++ b/includes/class-woocommerce-filters.php @@ -78,6 +78,21 @@ public static function get_authorizing_url_nonce_param_name( $field ) { return woographql_setting( "{$field}_nonce_param", null ); } + public static function should_load_session_handler() { + switch( true ) { + case \WPGraphQL\Router::is_graphql_http_request(): + //phpcs:disable + case 'on' === woographql_setting( 'enable_ql_session_handler_on_ajax', 'off' ) + && ( ! empty( $_GET['wc-ajax'] ) || defined( 'WC_DOING_AJAX' ) ): + //phpcs:enable + case 'on' === woographql_setting( 'enable_ql_session_handler_on_rest', 'off' ) + && ( defined( 'REST_REQUEST' ) && REST_REQUEST ): + return true; + default: + return false; + } + } + /** * WooCommerce Session Handler callback * @@ -85,7 +100,7 @@ public static function get_authorizing_url_nonce_param_name( $field ) { * @return string */ public static function woocommerce_session_handler( $session_class ) { - if ( \WPGraphQL\Router::is_graphql_http_request() ) { + if ( self::should_load_session_handler() ) { $session_class = '\WPGraphQL\WooCommerce\Utils\QL_Session_Handler'; } elseif ( WooGraphQL::auth_router_is_enabled() ) { require_once get_includes_directory() . 'utils/class-protected-router.php'; diff --git a/includes/utils/class-ql-session-handler.php b/includes/utils/class-ql-session-handler.php index 761e214c..79157d07 100644 --- a/includes/utils/class-ql-session-handler.php +++ b/includes/utils/class-ql-session-handler.php @@ -11,6 +11,7 @@ use WC_Session_Handler; use WPGraphQL\WooCommerce\Vendor\Firebase\JWT\JWT; use WPGraphQL\WooCommerce\Vendor\Firebase\JWT\Key; +use WPGraphQL\Router as Router; /** * Class - QL_Session_Handler @@ -52,8 +53,8 @@ class QL_Session_Handler extends WC_Session_Handler { * Constructor for the session class. */ public function __construct() { + parent::__construct(); $this->_token = apply_filters( 'graphql_woocommerce_cart_session_http_header', 'woocommerce-session' ); - $this->_table = $GLOBALS['wpdb']->prefix . 'woocommerce_sessions'; } /** @@ -100,18 +101,17 @@ public function init() { $this->init_session_token(); Session_Transaction_Manager::get( $this ); - /** - * Necessary since Session_Transaction_Manager applies to the reference. - * - * @var self $this - */ - add_action( 'woocommerce_set_cart_cookies', [ $this, 'set_customer_session_token' ], 10 ); - add_action( 'woographql_update_session', [ $this, 'set_customer_session_token' ], 10 ); - add_action( 'shutdown', [ $this, 'save_data' ] ); - add_action( 'wp_logout', [ $this, 'destroy_session' ] ); - - if ( ! is_user_logged_in() ) { - add_filter( 'nonce_user_logged_out', [ $this, 'maybe_update_nonce_user_logged_out' ], 10, 2 ); + if ( Router::is_graphql_http_request() ) { + add_action( 'woocommerce_set_cart_cookies', [ $this, 'set_customer_session_token' ], 10 ); + add_action( 'woographql_update_session', [ $this, 'set_customer_session_token' ], 10 ); + add_action( 'shutdown', [ $this, 'save_data' ] ); + } else { + add_action( 'woocommerce_set_cart_cookies', [ $this, 'set_customer_session_cookie' ], 10 ); + add_action( 'shutdown', [ $this, 'save_data' ], 20 ); + add_action( 'wp_logout', [ $this, 'destroy_session' ] ); + if ( ! is_user_logged_in() ) { + add_filter( 'nonce_user_logged_out', [ $this, 'maybe_update_nonce_user_logged_out' ], 10, 2 ); + } } } @@ -123,6 +123,10 @@ public function init() { * @return void */ public function init_session_token() { + + /** + * @var object{ iat: int, exp: int, data: object{ customer_id: string } }|false|\WP_Error $token + */ $token = $this->get_session_token(); // Process existing session if not expired or invalid. @@ -147,7 +151,9 @@ public function init_session_token() { // @phpstan-ignore-next-line $this->save_data( $guest_session_id ); - $this->set_customer_session_token( true ); + Router::is_graphql_http_request() + ? $this->set_customer_session_token( true ) + : $this->set_customer_session_cookie( true ); } // Update session expiration on each action. @@ -155,19 +161,22 @@ public function init_session_token() { if ( $token->exp < $this->_session_expiration ) { $this->update_session_timestamp( (string) $this->_customer_id, $this->_session_expiration ); } - } else { + } else if ( is_wp_error( $token ) ) { + add_filter( + 'graphql_woocommerce_session_token_errors', + static function ( $errors ) use ( $token ) { + $errors = $token->get_error_message(); + return $errors; + } + ); + } - // If token invalid throw warning. - if ( is_wp_error( $token ) ) { - add_filter( - 'graphql_woocommerce_session_token_errors', - static function ( $errors ) use ( $token ) { - $errors = $token->get_error_message(); - return $errors; - } - ); - } + $start_new_session = ! $token || is_wp_error( $token ); + if ( ! $start_new_session ) { + return; + } + if ( Router::is_graphql_http_request() ) { // Start new session. $this->set_session_expiration(); @@ -175,7 +184,10 @@ static function ( $errors ) use ( $token ) { $this->_customer_id = is_user_logged_in() ? get_current_user_id() : $this->generate_customer_id(); $this->_data = $this->get_session_data(); $this->set_customer_session_token( true ); - }//end if + + } else { + return $this->init_session_cookie(); + } } /** @@ -259,13 +271,22 @@ public function get_session_header() { return apply_filters( 'graphql_woocommerce_cart_session_header', $session_header ); } + /** + * Determine if a JWT is being sent in the page response. + * + * @return bool + */ + private function sending_token() { + return $this->_has_token || $this->_issuing_new_token; + } + /** * Creates JSON Web Token for customer session. * * @return false|string */ public function build_token() { - if ( empty( $this->_session_issued ) ) { + if ( empty( $this->_session_issued ) || ! $this->sending_token() ) { return false; } @@ -368,8 +389,9 @@ function ( $headers ) { * @return bool */ public function has_session() { + // @codingStandardsIgnoreLine. - return $this->_issuing_new_token || $this->_has_token || is_user_logged_in(); + return $this->_issuing_new_token || $this->_has_token || parent::has_session(); } /** @@ -378,35 +400,9 @@ public function has_session() { * @return void */ public function set_session_expiration() { - $this->_session_issued = time(); - // 14 Days. - $this->_session_expiration = apply_filters( - 'graphql_woocommerce_cart_session_expire', - // Seconds * Minutes * Hours * Days. - $this->_session_issued + ( 60 * 60 * 24 * 14 ) - ); - // 13 Days. - $this->_session_expiring = $this->_session_expiration - ( 60 * 60 * 24 ); - } - - /** - * Forget all session data without destroying it. - * - * @return void - */ - public function forget_session() { - if ( isset( $this->_token_to_be_sent ) ) { - unset( $this->_token_to_be_sent ); - } - wc_empty_cart(); - $this->_data = []; - $this->_dirty = false; - - // Start new session. - $this->set_session_expiration(); - - // Get Customer ID. - $this->_customer_id = is_user_logged_in() ? get_current_user_id() : $this->generate_customer_id(); + $this->_session_issued = time(); + $this->_session_expiring = apply_filters( 'wc_session_expiring', $this->_session_issued + ( 60 * 60 * 47 ) ); // 47 Hours. + $this->_session_expiration = apply_filters( 'wc_session_expiration', $this->_session_issued + ( 60 * 60 * 48 ) ); // 48 Hours. } /** @@ -444,17 +440,6 @@ public function reload_data() { } } - /** - * Noop for \WC_Session_Handler method. - * - * Prevents potential crticial errors when calling this method. - * - * @param bool $set Should the session cookie be set. - * - * @return void - */ - public function set_customer_session_cookie( $set ) {} - /** * Returns "client_session_id". "client_session_id_expiration" is used * to keep "client_session_id" as fresh as possible. diff --git a/tests/wpunit/QLSessionHandlerTest.php b/tests/wpunit/QLSessionHandlerTest.php index b9afbe51..44e1937b 100644 --- a/tests/wpunit/QLSessionHandlerTest.php +++ b/tests/wpunit/QLSessionHandlerTest.php @@ -11,9 +11,14 @@ define( 'GRAPHQL_WOOCOMMERCE_SECRET_KEY', 'graphql-woo-cart-session' ); } +/** + * @runTestsInSeparateProcesses + * @preserveGlobalState disabled + */ class QLSessionHandlerTest extends \Tests\WPGraphQL\WooCommerce\TestCase\WooGraphQLTestCase { public function tearDown(): void { unset( $_SERVER ); + WC()->session->destroy_session(); // after parent::tearDown(); @@ -27,7 +32,10 @@ public function test_initializes() { $this->assertInstanceOf( QL_Session_Handler::class, $session ); } - public function test_init_session_token() { + public function test_init_on_graphql_request() { + // Simulate GraphQL HTTP Request. + add_filter( 'graphql_is_graphql_http_request', '__return_true' ); + // Create session handler. $session = new QL_Session_Handler(); @@ -35,7 +43,7 @@ public function test_init_session_token() { $this->assertFalse( $session->has_session(), 'Shouldn\'t have a session yet' ); // Initialize session. - $session->init_session_token(); + $session->init(); // Assert session has started. $this->assertTrue( $session->has_session(), 'Should have session.' ); @@ -51,16 +59,91 @@ public function test_init_session_token() { usleep( 1000000 ); // Initialize session token for next request. - $session->init_session_token(); + remove_action( 'woocommerce_set_cart_cookies', [ $session, 'set_customer_session_token' ] ); + remove_action( 'woographql_update_session', [ $session, 'set_customer_session_token' ] ); + remove_action( 'shutdown', [ $session, 'save_data' ] ); + + // Create new session handler. + $session = new QL_Session_Handler(); + $session->init(); $new_token = $session->build_token(); $decoded_new_token = JWT::decode( $new_token, new Key( GRAPHQL_WOOCOMMERCE_SECRET_KEY, 'HS256' ) ); // Assert new token is different than old token. $this->assertNotEquals( $old_token, $new_token, 'New token should not match token from last request.' ); $this->assertGreaterThan( $decoded_old_token->exp, $decoded_new_token->exp ); + + // Assert customer ID match + $this->assertEquals( $decoded_old_token->data->customer_id, $decoded_new_token->data->customer_id ); + } + + public function test_init_on_non_graphql_request() { + $product_id = $this->factory()->product->createSimple(); + + // Create session handler. + $session = new QL_Session_Handler(); + + // Assert session hasn't started. + $this->assertFalse( $session->has_session(), 'Shouldn\'t have a session yet' ); + + // Initialize session. + $session->init(); + + // Add product to cart and start the session. + $this->factory()->cart->add( $product_id ); + + // Assert session has started. + $this->assertTrue( $session->has_session(), 'Should have session.' ); + + // Assert no tokens are being issued. + $this->assertFalse( $session->build_token(), 'Should not have a token.' ); + } + + public function test_init_on_non_graphql_request_with_session_token() { + // Simulate GraphQL HTTP Request. + add_filter( 'graphql_is_graphql_http_request', '__return_true' ); + + // Create session handler. + $session = new QL_Session_Handler(); + + // Assert session hasn't started. + $this->assertFalse( $session->has_session(), 'Shouldn\'t have a session yet' ); + + // Initialize session. + $session->init(); + + // Assert session has started. + $this->assertTrue( $session->has_session(), 'Should have session.' ); + + // Get token for future request. + $token_to_session = $session->build_token(); + + // Remove GraphQL HTTP Request filter to simulate normal request. + remove_filter( 'graphql_is_graphql_http_request', '__return_true' ); + + // Sent token to HTTP header to simulate a new request. + $_SERVER['HTTP_WOOCOMMERCE_SESSION'] = 'Session ' . $token_to_session; + + // Create session handler. + $session = new QL_Session_Handler(); + + // Assert session hasn't started. + $this->assertFalse( $session->has_session(), 'Shouldn\'t have a session yet' ); + + // Initialize session. + $session->init(); + + // Assert session has started. + $this->assertTrue( $session->has_session(), 'Should have session.' ); + + // Assert tokens are being issued. + $this->assertNotFalse( $session->build_token() ); } public function test_get_session_token() { + // Simulate GraphQL HTTP Request. + add_filter( 'graphql_is_graphql_http_request', '__return_true' ); + // Create session handler. $session = new QL_Session_Handler(); @@ -96,6 +179,9 @@ public function test_get_session_header() { } public function test_build_token() { + // Simulate GraphQL HTTP Request. + add_filter( 'graphql_is_graphql_http_request', '__return_true' ); + // Create session handler. $session = new QL_Session_Handler(); @@ -116,6 +202,9 @@ public function test_build_token() { } public function test_set_customer_session_token() { + // Simulate GraphQL HTTP Request. + add_filter( 'graphql_is_graphql_http_request', '__return_true' ); + // Create session handler. $session = new QL_Session_Handler(); @@ -131,6 +220,9 @@ public function test_set_customer_session_token() { } public function test_forget_session() { + // Simulate GraphQL HTTP Request. + add_filter( 'graphql_is_graphql_http_request', '__return_true' ); + // Create session handler. $session = new QL_Session_Handler(); $session->init_session_token();