• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wp-graphql / wp-graphql-woocommerce / 10288284022

07 Aug 2024 04:39PM UTC coverage: 84.506% (-0.08%) from 84.583%
10288284022

push

github

web-flow
feat: QL Session Handler refactored to handle non-GraphQL requests (#870)

* feat: QL Session Handler functionality expanded to support cookies on non-GraphQL requests

* chore: Linter and PHPStan compliance met

* devops: QLSessionHandlerTest patched for suite testing

* chore: Linter and PHPStan compliance met

* fix: More cart session save triggered implemented

* fix: More cart session save triggered implemented

* chore: Linter compliance met

* chore: Linter compliance met

* feat: forgetSession mutation added

* feat: forgetSession mutation added

84 of 124 new or added lines in 18 files covered. (67.74%)

1 existing line in 1 file now uncovered.

12484 of 14773 relevant lines covered (84.51%)

72.58 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.66
/includes/utils/class-ql-session-handler.php
1
<?php
2
/**
3
 * Handles data for the current customers session.
4
 *
5
 * @package WPGraphQL\WooCommerce\Utils
6
 * @since 0.1.2
7
 */
8

9
namespace WPGraphQL\WooCommerce\Utils;
10

11
use WC_Session_Handler;
12
use WPGraphQL\Router;
13
use WPGraphQL\WooCommerce\Vendor\Firebase\JWT\JWT;
14
use WPGraphQL\WooCommerce\Vendor\Firebase\JWT\Key;
15

16
/**
17
 * Class - QL_Session_Handler
18
 *
19
 * @property int $_session_expiring
20
 * @property int $_session_expiration
21
 * @property int|string $_customer_id
22
 */
23
class QL_Session_Handler extends WC_Session_Handler {
24
        /**
25
         * Stores the name of the HTTP header used to pass the session token.
26
         *
27
         * @var string $_token
28
         */
29
        protected $_token; // @codingStandardsIgnoreLine
30

31
        /**
32
         * Stores Timestamp of when the session token was issued.
33
         *
34
         * @var float $_session_issued
35
         */
36
        protected $_session_issued; // @codingStandardsIgnoreLine
37

38
        /**
39
         * True when the token exists.
40
         *
41
         * @var bool $_has_token
42
         */
43
        protected $_has_token = false; // @codingStandardsIgnoreLine
44

45
        /**
46
         * True when a new session token has been issued.
47
         *
48
         * @var bool $_issuing_new_token
49
         */
50
        protected $_issuing_new_token = false; // @codingStandardsIgnoreLine
51

52
        /**
53
         * True when a new session cookie has been issued.
54
         *
55
         * @var bool $_issuing_new_cookie
56
         */
57
        protected $_issuing_new_cookie = false; // @codingStandardsIgnoreLine
58

59
        /**
60
         * Constructor for the session class.
61
         */
62
        public function __construct() {
63
                parent::__construct();
11✔
64

65
                $this->_token = apply_filters( 'graphql_woocommerce_cart_session_http_header', 'woocommerce-session' );
11✔
66
        }
67

68
        /**
69
         * Returns formatted $_SERVER index from provided string.
70
         *
71
         * @param string $header String to be formatted.
72
         *
73
         * @return string
74
         */
75
        private function get_server_key( $header = null ) {
76
                /**
77
                 * Server key.
78
                 *
79
                 * @var string $server_key
80
                 */
81
                $server_key = preg_replace( '#[^A-z0-9]#', '_', ! empty( $header ) ? $header : $this->_token );
56✔
82
                return null !== $server_key
56✔
83
                        ? 'HTTP_' . strtoupper( $server_key )
56✔
84
                        : '';
56✔
85
        }
86

87
        /**
88
         * This returns the secret key, using the defined constant if defined, and passing it through a filter to
89
         * allow for the config to be able to be set via another method other than a defined constant, such as an
90
         * admin UI that allows the key to be updated/changed/revoked at any time without touching server files
91
         *
92
         * @return mixed|null|string
93
         */
94
        private function get_secret_key() {
95
                // Use the defined secret key, if it exists.
96

97
                $secret_key = defined( 'GRAPHQL_WOOCOMMERCE_SECRET_KEY' ) && ! empty( GRAPHQL_WOOCOMMERCE_SECRET_KEY )
6✔
98
                        ? GRAPHQL_WOOCOMMERCE_SECRET_KEY :
6✔
99
                        'graphql-woo-cart-session';
6✔
100
                return apply_filters( 'graphql_woocommerce_secret_key', $secret_key );
6✔
101
        }
102

103
        /**
104
         * Init hooks and session data.
105
         *
106
         * @return void
107
         */
108
        public function init() {
109
                $this->init_session_token();
51✔
110
                Session_Transaction_Manager::get( $this );
51✔
111

112
                /**
113
                 * Necessary since Session_Transaction_Manager applies to the reference.
114
                 *
115
                 * @var self $this
116
                 */
117
                if ( Router::is_graphql_http_request() ) {
51✔
118
                        add_action( 'woocommerce_set_cart_cookies', [ $this, 'set_customer_session_token' ], 10 );
2✔
119
                        add_action( 'woographql_update_session', [ $this, 'set_customer_session_token' ], 10 );
2✔
120
                        add_action( 'shutdown', [ $this, 'save_data' ] );
2✔
121
                } else {
122
                        add_action( 'woocommerce_set_cart_cookies', [ $this, 'set_customer_session_cookie' ], 10 );
50✔
123
                        add_action( 'shutdown', [ $this, 'save_data' ], 20 );
50✔
124
                        add_action( 'wp_logout', [ $this, 'destroy_session' ] );
50✔
125
                        if ( ! is_user_logged_in() ) {
50✔
126
                                add_filter( 'nonce_user_logged_out', [ $this, 'maybe_update_nonce_user_logged_out' ], 10, 2 );
3✔
127
                        }
128
                }
129
        }
130

131
        /**
132
         * Mark the session as dirty.
133
         *
134
         * To trigger a save of the session data.
135
         *
136
         * @return void
137
         */
138
        public function mark_dirty() {
139
                $this->_dirty = true;
1✔
140
        }
141

142
        /**
143
         * Setup token and customer ID.
144
         *
145
         * @throws \GraphQL\Error\UserError Invalid token.
146
         *
147
         * @return void
148
         */
149
        public function init_session_token() {
150

151
                /**
152
                 * @var object{ iat: int, exp: int, data: object{ customer_id: string } }|false|\WP_Error $token
153
                 */
154
                $token = $this->get_session_token();
56✔
155

156
                // Process existing session if not expired or invalid.
157
                if ( $token && is_object( $token ) && ! is_wp_error( $token ) ) {
56✔
158
                        $this->_customer_id        = $token->data->customer_id;
2✔
159
                        $this->_session_issued     = $token->iat;
2✔
160
                        $this->_session_expiration = $token->exp;
2✔
161
                        $this->_session_expiring   = $token->exp - ( 3600 );
2✔
162
                        $this->_has_token          = true;
2✔
163
                        $this->_data               = $this->get_session_data();
2✔
164

165
                        // If the user logs in, update session.
166
                        if ( is_user_logged_in() && strval( get_current_user_id() ) !== $this->_customer_id ) {
2✔
167
                                $guest_session_id   = $this->_customer_id;
×
168
                                $this->_customer_id = strval( get_current_user_id() );
×
169
                                $this->_dirty       = true;
×
170

171
                                // If session empty check for previous data associated with customer and assign that to the session.
172
                                if ( empty( $this->_data ) ) {
×
173
                                        $this->_data = $this->get_session_data();
×
174
                                }
175

176
                                // @phpstan-ignore-next-line
177
                                $this->save_data( $guest_session_id );
×
NEW
178
                                Router::is_graphql_http_request()
×
NEW
179
                                        ? $this->set_customer_session_token( true )
×
NEW
180
                                        : $this->set_customer_session_cookie( true );
×
181
                        }
182

183
                        // Update session expiration on each action.
184
                        $this->set_session_expiration();
2✔
185
                        if ( $token->exp < $this->_session_expiration ) {
2✔
186
                                $this->update_session_timestamp( (string) $this->_customer_id, $this->_session_expiration );
2✔
187
                        }
188
                } elseif ( is_wp_error( $token ) ) {
56✔
NEW
189
                        add_filter(
×
NEW
190
                                'graphql_woocommerce_session_token_errors',
×
NEW
191
                                static function ( $errors ) use ( $token ) {
×
NEW
192
                                        $errors = $token->get_error_message();
×
NEW
193
                                        return $errors;
×
NEW
194
                                }
×
NEW
195
                        );
×
196
                }
197

198
                $start_new_session = ! $token || is_wp_error( $token );
56✔
199
                if ( ! $start_new_session ) {
56✔
200
                        return;
2✔
201
                }
202

203
                // Distribute new session token on GraphQL requests, otherwise distribute a new session cookie.
204
                if ( Router::is_graphql_http_request() ) {
56✔
205
                        // Start new session.
206
                        $this->set_session_expiration();
6✔
207

208
                        // Get Customer ID.
209
                        $this->_customer_id = is_user_logged_in() ? get_current_user_id() : $this->generate_customer_id();
6✔
210
                        $this->_data        = $this->get_session_data();
6✔
211
                        $this->set_customer_session_token( true );
6✔
212
                } else {
213
                        $this->init_session_cookie();
50✔
214
                }
215
        }
216

217
        /**
218
         * Retrieve and decrypt the session data from session, if set. Otherwise return false.
219
         *
220
         * Session cookies without a customer ID are invalid.
221
         *
222
         * @throws \Exception  Invalid token.
223
         * @return false|\WP_Error|object{ iat: int, exp: int, data: object{ customer_id: string } }
224
         */
225
        public function get_session_token() {
226
                // Get the Auth header.
227
                $session_header = $this->get_session_header();
56✔
228

229
                if ( empty( $session_header ) ) {
56✔
230
                        return false;
56✔
231
                }
232

233
                // Get the token from the header.
234
                $token_string = sscanf( $session_header, 'Session %s' );
3✔
235
                if ( empty( $token_string ) ) {
3✔
236
                        return false;
×
237
                }
238

239
                list( $token ) = $token_string;
3✔
240

241
                /**
242
                 * Try to decode the token
243
                 */
244
                try {
245
                        JWT::$leeway = 60;
3✔
246

247
                        $secret = $this->get_secret_key();
3✔
248
                        $key    = new Key( $secret, 'HS256' );
3✔
249
                        /**
250
                         * Decode the token
251
                         *
252
                         * @var null|object{ iat: int, exp: int, data: object{ customer_id: string }, iss: string } $token
253
                         */
254
                        $token = ! empty( $token ) ? JWT::decode( $token, $key ) : null;
3✔
255

256
                        // Check if token was successful decoded.
257
                        if ( ! $token ) {
3✔
258
                                throw new \Exception( __( 'Failed to decode session token', 'wp-graphql-woocommerce' ) );
×
259
                        }
260

261
                        // The Token is decoded now validate the iss.
262
                        if ( empty( $token->iss ) || get_bloginfo( 'url' ) !== $token->iss ) {
3✔
263
                                throw new \Exception( __( 'The iss do not match with this server', 'wp-graphql-woocommerce' ) );
×
264
                        }
265

266
                        // Validate the customer id in the token.
267
                        if ( empty( $token->data ) || empty( $token->data->customer_id ) ) {
3✔
268
                                throw new \Exception( __( 'Customer ID not found in the token', 'wp-graphql-woocommerce' ) );
3✔
269
                        }
270
                } catch ( \Throwable $error ) {
×
271
                        return new \WP_Error( 'invalid_token', $error->getMessage() );
×
272
                }//end try
273

274
                return $token;
3✔
275
        }
276

277
        /**
278
         * Get the value of the cart session header from the $_SERVER super global
279
         *
280
         * @return mixed|string
281
         */
282
        public function get_session_header() {
283
                $session_header_key = $this->get_server_key();
56✔
284

285
                // Looking for the cart session header.
286
                $session_header = isset( $_SERVER[ $session_header_key ] )
56✔
287
                        ? $_SERVER[ $session_header_key ] //@codingStandardsIgnoreLine
4✔
288
                        : false;
56✔
289

290
                /**
291
                 * Return the cart session header, passed through a filter
292
                 *
293
                 * @param string $session_header  The header used to identify a user's cart session token.
294
                 */
295
                return apply_filters( 'graphql_woocommerce_cart_session_header', $session_header );
56✔
296
        }
297

298
        /**
299
         * Determine if a JWT is being sent in the page response.
300
         *
301
         * @return bool
302
         */
303
        public function sending_token() {
304
                return $this->_has_token || $this->_issuing_new_token;
8✔
305
        }
306

307
        /**
308
         * Determine if a HTTP cookie is being sent in the page response.
309
         *
310
         * @return bool
311
         */
312
        public function sending_cookie() {
NEW
313
                return $this->_has_cookie || $this->_issuing_new_cookie;
×
314
        }
315

316
        /**
317
         * Creates JSON Web Token for customer session.
318
         *
319
         * @return false|string
320
         */
321
        public function build_token() {
322
                if ( empty( $this->_session_issued ) || ! $this->sending_token() ) {
8✔
323
                        return false;
3✔
324
                }
325

326
                /**
327
                 * Determine the "not before" value for use in the token
328
                 *
329
                 * @param float      $issued        The timestamp of token was issued.
330
                 * @param int|string $customer_id   Customer ID.
331
                 * @param array      $session_data  Cart session data.
332
                 */
333
                $not_before = apply_filters(
6✔
334
                        'graphql_woo_cart_session_not_before',
6✔
335
                        $this->_session_issued,
6✔
336
                        $this->_customer_id,
6✔
337
                        $this->_data
6✔
338
                );
6✔
339

340
                // Configure the token array, which will be encoded.
341
                $token = [
6✔
342
                        'iss'  => get_bloginfo( 'url' ),
6✔
343
                        'iat'  => $this->_session_issued,
6✔
344
                        'nbf'  => $not_before,
6✔
345
                        'exp'  => $this->_session_expiration,
6✔
346
                        'data' => [
6✔
347
                                'customer_id' => $this->_customer_id,
6✔
348
                        ],
6✔
349
                ];
6✔
350

351
                /**
352
                 * Filter the token, allowing for individual systems to configure the token as needed
353
                 *
354
                 * @param array      $token         The token array that will be encoded
355
                 * @param int|string $customer_id   ID of customer associated with token.
356
                 * @param array      $session_data  Session data associated with token.
357
                 */
358
                $token = apply_filters(
6✔
359
                        'graphql_woocommerce_cart_session_before_token_sign',
6✔
360
                        $token,
6✔
361
                        $this->_customer_id,
6✔
362
                        $this->_data
6✔
363
                );
6✔
364

365
                // Encode the token.
366
                JWT::$leeway = 60;
6✔
367
                $token       = JWT::encode( $token, $this->get_secret_key(), 'HS256' );
6✔
368

369
                /**
370
                 * Filter the token before returning it, allowing for individual systems to override what's returned.
371
                 *
372
                 * For example, if the user should not be granted a token for whatever reason, a filter could have the token return null.
373
                 *
374
                 * @param string     $token         The signed JWT token that will be returned
375
                 * @param int|string $customer_id   ID of customer associated with token.
376
                 * @param array      $session_data  Session data associated with token.
377
                 */
378
                $token = apply_filters(
6✔
379
                        'graphql_woocommerce_cart_session_signed_token',
6✔
380
                        $token,
6✔
381
                        $this->_customer_id,
6✔
382
                        $this->_data
6✔
383
                );
6✔
384

385
                return $token;
6✔
386
        }
387

388
        /**
389
         * Sets the session header on-demand (usually after adding an item to the cart).
390
         *
391
         * Warning: Headers will only be set if this is called before the headers are sent.
392
         *
393
         * @param bool $set Should the session cookie be set.
394
         *
395
         * @return void
396
         */
397
        public function set_customer_session_token( $set ) {
398
                if ( ! empty( $this->_session_issued ) && $set ) {
6✔
399
                        /**
400
                         * Set callback session token for use in the HTTP response header and customer/user "sessionToken" field.
401
                         */
402
                        add_filter(
6✔
403
                                'graphql_response_headers_to_send',
6✔
404
                                function ( $headers ) {
6✔
405
                                        $token = $this->build_token();
1✔
406
                                        if ( $token ) {
1✔
407
                                                $headers[ $this->_token ] = $token;
1✔
408
                                        }
409

410
                                        return $headers;
1✔
411
                                },
6✔
412
                                10
6✔
413
                        );
6✔
414

415
                        $this->_issuing_new_token = true;
6✔
416
                }
417
        }
418

419
        /**
420
         * {@inheritDoc}
421
         *
422
         * @return void
423
         */
424
        public function set_customer_session_cookie( $set ) {
NEW
425
                parent::set_customer_session_cookie( $set );
×
426

NEW
427
                if ( $set ) {
×
NEW
428
                        $this->_issuing_new_cookie = true;
×
429
                }
430
        }
431

432
        /**
433
         * Return true if the current user has an active session, i.e. a cookie to retrieve values.
434
         *
435
         * @return bool
436
         */
437
        public function has_session() {
438

439
                // @codingStandardsIgnoreLine.
440
                return $this->_issuing_new_token || $this->_has_token || parent::has_session();
57✔
441
        }
442

443
        /**
444
         * Set session expiration.
445
         *
446
         * @return void
447
         */
448
        public function set_session_expiration() {
449
                $this->_session_issued = time();
57✔
450
                // 47 hours.
451
                $this->_session_expiring = apply_filters( 'wc_session_expiring', $this->_session_issued + ( 60 * 60 * 47 ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
57✔
452
                // 48 hours.
453
                $this->_session_expiration = apply_filters( 'wc_session_expiration', $this->_session_issued + ( 60 * 60 * 48 ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
57✔
454
                $this->_session_expiration = apply_filters_deprecated(
57✔
455
                        'graphql_woocommerce_cart_session_expire',
57✔
456
                        [ $this->_session_expiration ],
57✔
457
                        'TBD',
57✔
458
                        'wc_session_expiration'
57✔
459
                );
57✔
460
        }
461

462
        /**
463
         * Save any changes to database after a session mutations has been run.
464
         *
465
         * @return void
466
         */
467
        public function save_if_dirty() {
468
                // Update if user recently authenticated.
469
                if ( is_user_logged_in() && get_current_user_id() !== $this->_customer_id ) {
×
470
                        $this->_customer_id = get_current_user_id();
×
471
                        $this->_dirty       = true;
×
472
                }
473

474
                // Bail if no changes.
475
                if ( ! $this->_dirty ) {
×
476
                        return;
×
477
                }
478

479
                $this->save_data();
×
480
        }
481

482
        /**
483
         * For refreshing session data mid-request when changes occur in concurrent requests.
484
         *
485
         * @return void
486
         */
487
        public function reload_data() {
488
                \WC_Cache_Helper::invalidate_cache_group( WC_SESSION_CACHE_GROUP );
×
489

490
                // Get session data.
491
                $data = $this->get_session( (string) $this->_customer_id );
×
492
                if ( is_array( $data ) ) {
×
493
                        $this->_data = $data;
×
494
                }
495
        }
496

497
        /**
498
         * Returns "client_session_id". "client_session_id_expiration" is used
499
         * to keep "client_session_id" as fresh as possible.
500
         *
501
         * For the most strict level of security it's highly recommend these values
502
         * be set client-side using the `updateSession` mutation.
503
         * "client_session_id" in particular should be salted with some
504
         * kind of client identifier like the end-user "IP" or "user-agent"
505
         * then hashed parodying the tokens generated by
506
         * WP's WP_Session_Tokens class.
507
         *
508
         * @return string
509
         */
510
        public function get_client_session_id() {
511
                // Get client session ID.
512
                $client_session_id            = $this->get( 'client_session_id', false );
2✔
513
                $client_session_id_expiration = absint( $this->get( 'client_session_id_expiration', 0 ) );
2✔
514

515
                // If client session ID valid return it.
516
                if ( false !== $client_session_id && time() < $client_session_id_expiration ) {
2✔
517
                        // @phpstan-ignore-next-line
518
                        return $client_session_id;
2✔
519
                }
520

521
                // Generate a new client session ID.
522
                $client_session_id            = uniqid();
2✔
523
                $client_session_id_expiration = time() + 3600;
2✔
524
                $this->set( 'client_session_id', $client_session_id );
2✔
525
                $this->set( 'client_session_id_expiration', $client_session_id_expiration );
2✔
526
                $this->save_data();
2✔
527

528
                // Return new client session ID.
529
                return $client_session_id;
2✔
530
        }
531
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc