PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / Services / Auth.php

Auth.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.18, at includes/Services/Auth.php

993 lines 28.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Auth.
4 *
5 * @package WCPOS\WooCommercePOS
6 */
7
8 namespace WCPOS\WooCommercePOS\Services;
9
10 use Exception;
11 use WCPOS\Vendor\Firebase\JWT\JWT;
12 use WCPOS\Vendor\Firebase\JWT\Key;
13 use WCPOS\WooCommercePOS\Services\Settings\Access_Section;
14 use WP_Error;
15 use WP_User;
16 use const DAY_IN_SECONDS;
17 use const HOUR_IN_SECONDS;
18
19 /**
20 * Auth Service class.
21 */
22 class Auth {
23 /**
24 * Maximum retained idle sessions.
25 *
26 * @deprecated Use Session_Registry::MAX_SESSIONS_PER_USER.
27 */
28 public const MAX_SESSIONS_PER_USER = Session_Registry::MAX_SESSIONS_PER_USER;
29
30 /**
31 * Minimum idle time before eviction.
32 *
33 * @deprecated Use Session_Registry::SESSION_EVICTION_IDLE_SECONDS.
34 */
35 public const SESSION_EVICTION_IDLE_SECONDS = Session_Registry::SESSION_EVICTION_IDLE_SECONDS;
36
37 /**
38 * Session row byte ceiling.
39 *
40 * @deprecated Use Session_Registry::MAX_SESSIONS_ROW_BYTES.
41 */
42 public const MAX_SESSIONS_ROW_BYTES = Session_Registry::MAX_SESSIONS_ROW_BYTES;
43
44 /**
45 * The single instance of the class.
46 *
47 * @var null|Auth
48 */
49 private static $instance = null;
50
51 /**
52 * Session storage.
53 *
54 * @var Session_Registry
55 */
56 private $sessions;
57
58 /**
59 * Constructor is private to prevent direct instantiation.
60 * Or Auth::instance() instead.
61 */
62 public function __construct() {
63 $this->sessions = new Session_Registry();
64 }
65
66 /**
67 * Get the session registry.
68 *
69 * @return Session_Registry
70 */
71 public function sessions(): Session_Registry {
72 return $this->sessions;
73 }
74
75 /**
76 * Gets the singleton instance.
77 *
78 * @return Auth
79 */
80 public static function instance(): self {
81 if ( null === self::$instance ) {
82 self::$instance = new self();
83 }
84
85 return self::$instance;
86 }
87
88 /**
89 * Extract a WCPOS token from an authorization value.
90 *
91 * @param mixed $auth_value Authorization value.
92 *
93 * @return null|string
94 */
95 public function extract_token( $auth_value ): ?string {
96 if ( ! \is_string( $auth_value ) || '' === $auth_value ) {
97 return null;
98 }
99
100 // Match the old sscanf( 'Bearer %s' ) semantics exactly: any run of
101 // whitespace after the scheme, token = the next non-whitespace run.
102 if ( 1 === preg_match( '/^Bearer\s+(\S+)/', $auth_value, $matches ) ) {
103 return $matches[1];
104 }
105
106 return 1 === preg_match( '/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/', $auth_value ) ? $auth_value : null;
107 }
108
109 /**
110 * Authenticate the current request from its WCPOS token.
111 *
112 * @return false|int|WP_Error User ID, validation error, or false when no WCPOS token is present.
113 */
114 public function authenticate_request() {
115 $auth_header = $this->get_auth_header();
116 $token = $this->extract_token( $auth_header );
117 if ( null === $token ) {
118 return false;
119 }
120
121 $decoded_token = $this->validate_token( $token );
122 if ( is_wp_error( $decoded_token ) ) {
123 return $decoded_token;
124 }
125
126 return absint( $decoded_token->data->user->id );
127 }
128
129 /**
130 * Get authorization header/param value.
131 *
132 * Checks multiple sources for the authorization token:
133 * 1. HTTP_AUTHORIZATION server variable (standard)
134 * 2. REDIRECT_HTTP_AUTHORIZATION (Apache CGI workaround)
135 * 3. authorization query parameter (for servers that strip auth headers)
136 *
137 * @return false|string The authorization value or false if not found.
138 */
139 public function get_auth_header() {
140 // Check HTTP_AUTHORIZATION (not empty - htaccess SetEnvIf can set empty value).
141 if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
142 return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) );
143 }
144
145 // Check REDIRECT_HTTP_AUTHORIZATION (Apache CGI).
146 if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
147 return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
148 }
149
150 // Check authorization query param.
151 if ( ! empty( $_GET['authorization'] ) ) {
152 return sanitize_text_field( wp_unslash( $_GET['authorization'] ) );
153 }
154
155 return false;
156 }
157
158 /**
159 * Generate a secret key if it doesn't exist, or return the existing one.
160 *
161 * @return string
162 */
163 public function get_secret_key(): string {
164 $secret_key = get_option( 'woocommerce_pos_secret_key' );
165 if ( false === $secret_key || empty( $secret_key ) ) {
166 $secret_key = wp_generate_password( 64, true, true );
167 update_option( 'woocommerce_pos_secret_key', $secret_key );
168 }
169
170 return $secret_key;
171 }
172
173 /**
174 * Get refresh token secret key (separate from access token key for security).
175 *
176 * @return string
177 */
178 public function get_refresh_secret_key(): string {
179 $secret_key = get_option( 'woocommerce_pos_refresh_secret_key' );
180 if ( false === $secret_key || empty( $secret_key ) ) {
181 $secret_key = wp_generate_password( 64, true, true );
182 update_option( 'woocommerce_pos_refresh_secret_key', $secret_key );
183 }
184
185 return $secret_key;
186 }
187
188 /**
189 * Validate the provided JWT token.
190 *
191 * @param string $token The JWT token.
192 * @param string $token_type The token type: 'access' or 'refresh'.
193 *
194 * @return object|WP_Error
195 */
196 public function validate_token( $token = '', $token_type = 'access' ) {
197 try {
198 $secret_key = 'refresh' === $token_type ? $this->get_refresh_secret_key() : $this->get_secret_key();
199 $decoded_token = JWT::decode( $token, new Key( $secret_key, 'HS256' ) ); // @phpstan-ignore-line
200
201 // The Token is decoded now validate the iss.
202 if ( get_bloginfo( 'url' ) != $decoded_token->iss ) {
203 // The iss do not match, return error.
204 return new WP_Error(
205 'woocommmerce_pos_auth_bad_iss',
206 'The iss do not match with this server',
207 array( 'status' => 403 )
208 );
209 }
210
211 // Validate token type.
212 if ( ! isset( $decoded_token->type ) || $decoded_token->type !== $token_type ) {
213 return new WP_Error(
214 'woocommmerce_pos_auth_invalid_token_type',
215 'Invalid token type',
216 array( 'status' => 403 )
217 );
218 }
219
220 // So far so good, validate the user id in the token.
221 if ( ! isset( $decoded_token->data->user->id ) ) {
222 // No user id in the token, abort!!
223 return new WP_Error(
224 'woocommmerce_pos_auth_bad_request',
225 'User ID not found in the token',
226 array(
227 'status' => 403,
228 )
229 );
230 }
231
232 // Check if access token is blacklisted (for instant revocation)
233 // We check both the access token's own JTI and its parent refresh_jti.
234 if ( 'access' === $token_type ) {
235 // Check if this specific access token is blacklisted.
236 if ( isset( $decoded_token->jti ) && $this->is_token_blacklisted( $decoded_token->jti ) ) {
237 return new WP_Error(
238 'woocommerce_pos_auth_token_revoked',
239 'Access token has been revoked',
240 array( 'status' => 403 )
241 );
242 }
243
244 // Check if the parent session (refresh token) is blacklisted
245 // This catches ALL access tokens for a revoked session.
246 if ( isset( $decoded_token->refresh_jti ) && $this->is_token_blacklisted( $decoded_token->refresh_jti ) ) {
247 return new WP_Error(
248 'woocommerce_pos_auth_session_revoked',
249 'Session has been revoked',
250 array( 'status' => 403 )
251 );
252 }
253
254 // The session is live: record that, so eviction can tell a device that is
255 // working right now from one that has not been seen in a week.
256 if ( isset( $decoded_token->refresh_jti ) ) {
257 $this->sessions->touch(
258 absint( $decoded_token->data->user->id ),
259 (string) $decoded_token->refresh_jti
260 );
261 }
262 }
263
264 // Everything looks good return the decoded token.
265 return $decoded_token;
266 } catch ( Exception $e ) {
267 // Something is wrong trying to decode the token, send back the error.
268 return new WP_Error(
269 'woocommmerce_pos_auth_invalid_token',
270 $e->getMessage(),
271 array(
272 'status' => 403,
273 )
274 );
275 }
276 }
277
278 /**
279 * Generate an access token for the provided user (short-lived).
280 *
281 * @param WP_User $user The user object.
282 * @param string $refresh_jti Optional refresh token JTI to link access token to session.
283 *
284 * @return string|WP_Error
285 */
286 public function generate_access_token( WP_User $user, string $refresh_jti = '' ) {
287 $token_data = $this->generate_access_token_data( $user, $refresh_jti );
288
289 if ( is_wp_error( $token_data ) ) {
290 return $token_data;
291 }
292
293 return $token_data['token'];
294 }
295
296 /**
297 * Generate an access token and return the token metadata used by callers.
298 *
299 * @param WP_User $user The user object.
300 * @param string $refresh_jti Optional refresh token JTI to link access token to session.
301 *
302 * @return array|WP_Error
303 */
304 private function generate_access_token_data( WP_User $user, string $refresh_jti = '' ) {
305 // First thing, check the secret key if not exist return a error.
306 if ( ! $this->get_secret_key() ) {
307 return new WP_Error(
308 'woocommerce_pos_jwt_auth_bad_config',
309 __( 'JWT is not configured properly, please contact the admin', 'woocommerce-pos' ),
310 array(
311 'status' => 403,
312 )
313 );
314 }
315
316 /** Valid credentials, the user exists create the according Token */
317 $issued_at = time();
318 $expire = $this->get_access_token_expire( $issued_at );
319
320 // Generate unique JTI for access token.
321 $jti = wp_generate_uuid4();
322
323 $token = array(
324 'iss' => get_bloginfo( 'url' ),
325 'iat' => $issued_at,
326 'exp' => $expire,
327 'jti' => $jti,
328 'type' => 'access',
329 'data' => array(
330 'user' => array(
331 'id' => $user->data->ID,
332 ),
333 ),
334 );
335
336 // Link to refresh token if provided.
337 if ( ! empty( $refresh_jti ) ) {
338 $token['refresh_jti'] = $refresh_jti;
339 }
340
341 /*
342 * Let the user modify the access token data before the sign.
343 *
344 * @param {array} $token
345 * @param {WP_User} $user
346 *
347 * @returns {array} Token
348 *
349 * @since 1.8.0
350 *
351 * @hook woocommerce_pos_jwt_access_token_before_sign
352 */
353 $payload = apply_filters( 'woocommerce_pos_jwt_access_token_before_sign', $token, $user );
354 $token = JWT::encode( $payload, $this->get_secret_key(), 'HS256' );
355
356 $expires_at = $this->get_payload_claim( $payload, 'exp' );
357 $access_jti = $this->get_payload_claim( $payload, 'jti' );
358 $linked_refresh_jti = $this->get_payload_claim( $payload, 'refresh_jti' );
359
360 $expires_at = null === $expires_at ? $expire : (int) $expires_at;
361 $access_jti = null === $access_jti ? $jti : (string) $access_jti;
362
363 if ( null !== $linked_refresh_jti ) {
364 $linked_refresh_jti = (string) $linked_refresh_jti;
365 $this->sessions->record_access_expiry( $user->ID, $linked_refresh_jti, $expires_at );
366 }
367
368 return array(
369 'token' => $token,
370 'expires_at' => $expires_at,
371 'jti' => $access_jti,
372 'refresh_jti' => $linked_refresh_jti,
373 );
374 }
375
376 /**
377 * Generate a refresh token for the provided user (long-lived).
378 *
379 * @param WP_User $user The user object.
380 *
381 * @return string|WP_Error
382 */
383 public function generate_refresh_token( WP_User $user ) {
384 // First thing, check the secret key if not exist return a error.
385 if ( ! $this->get_refresh_secret_key() ) {
386 return new WP_Error(
387 'woocommerce_pos_jwt_auth_bad_config',
388 __( 'JWT is not configured properly, please contact the admin', 'woocommerce-pos' ),
389 array(
390 'status' => 403,
391 )
392 );
393 }
394
395 /** Valid credentials, the user exists create the according Token */
396 $issued_at = time();
397 $expire = $this->get_refresh_token_expire( $issued_at );
398
399 // Generate unique JTI (JWT ID) for refresh token tracking.
400 $jti = wp_generate_uuid4();
401
402 $token = array(
403 'iss' => get_bloginfo( 'url' ),
404 'iat' => $issued_at,
405 'exp' => $expire,
406 'jti' => $jti,
407 'type' => 'refresh',
408 'data' => array(
409 'user' => array(
410 'id' => $user->data->ID,
411 ),
412 ),
413 );
414
415 /**
416 * Let the user modify the refresh token data before the sign.
417 *
418 * @param array $token
419 * @param WP_User $user
420 *
421 * @returns array Token
422 *
423 * @since 1.8.0
424 *
425 * @hook woocommerce_pos_jwt_refresh_token_before_sign
426 */
427 $token = JWT::encode( apply_filters( 'woocommerce_pos_jwt_refresh_token_before_sign', $token, $user ), $this->get_refresh_secret_key(), 'HS256' );
428
429 // Store refresh token JTI for potential revocation.
430 $evicted = $this->sessions->record( $user->ID, $jti, $expire, Session_Context::from_request() );
431 $issued_at = time();
432 foreach ( $evicted as $evicted_jti => $token_data ) {
433 /*
434 * Blacklist ONLY a session that can still hold a live access token. An eviction
435 * is not a revoke: clearing a bloated row can drop thousands of long-dead
436 * sessions at once, and a transient for each would guard nothing — an expired
437 * access token is already rejected on its own `exp` claim, and the refresh token
438 * dies with the meta entry (`is_live()` requires the entry). This
439 * also bounds each transient this path writes to one access-token lifetime,
440 * rather than the refresh-token expiry `get_access_token_blacklist_ttl()` falls
441 * back to for a session with no recorded access-token expiry.
442 */
443 $horizon = $this->access_token_horizon( $token_data );
444 if ( $horizon > $issued_at ) {
445 $this->blacklist_token( $evicted_jti, $horizon - $issued_at );
446 }
447 }
448
449 return $token;
450 }
451
452 /**
453 * Generate both access and refresh tokens.
454 *
455 * @param WP_User $user The user object.
456 *
457 * @return array|WP_Error
458 */
459 public function generate_token_pair( WP_User $user ) {
460 // Generate refresh token first to get its JTI.
461 $refresh_token = $this->generate_refresh_token( $user );
462 if ( is_wp_error( $refresh_token ) ) {
463 return $refresh_token;
464 }
465
466 // Decode to get the JTI.
467 $decoded_refresh = $this->validate_token( $refresh_token, 'refresh' );
468 if ( is_wp_error( $decoded_refresh ) ) {
469 return $decoded_refresh;
470 }
471
472 // Generate access token with link to refresh token.
473 $access_token_data = $this->generate_access_token_data( $user, $decoded_refresh->jti ?? '' );
474 if ( is_wp_error( $access_token_data ) ) {
475 return $access_token_data;
476 }
477
478 return array(
479 'access_token' => $access_token_data['token'],
480 'refresh_token' => $refresh_token,
481 'token_type' => 'Bearer',
482 'expires_at' => (int) $access_token_data['expires_at'],
483 );
484 }
485
486 /**
487 * Legacy method for backward compatibility.
488 *
489 * @deprecated Use generate_access_token() instead
490 *
491 * @param WP_User $user The user object.
492 *
493 * @return string|WP_Error
494 */
495 public function generate_token( WP_User $user ) {
496 return $this->generate_access_token( $user );
497 }
498
499 /**
500 * Get user's data (minimal set for security).
501 *
502 * @param WP_User $user The user object.
503 * @param bool $is_web_frontend Whether this is the web frontend context.
504 * When true, manages web session cookie to prevent
505 * session proliferation on page refresh.
506 *
507 * @return array
508 */
509 public function get_user_data( WP_User $user, bool $is_web_frontend = false ): array {
510 // For web frontend, revoke previous session to prevent proliferation on page refresh.
511 if ( $is_web_frontend ) {
512 $this->cleanup_previous_web_session( $user->ID );
513 }
514
515 $tokens = $this->generate_token_pair( $user );
516 if ( is_wp_error( $tokens ) ) {
517 return array();
518 }
519
520 // For web frontend, store the new session JTI in a cookie for cleanup on next page load.
521 if ( $is_web_frontend ) {
522 $this->set_web_session_cookie( $tokens['refresh_token'] );
523 }
524
525 return array(
526 'uuid' => Cashier::instance()->get_cashier_uuid( $user ),
527 'id' => $user->ID,
528 'username' => $user->user_login,
529 'email' => $user->user_email,
530 'first_name' => $user->user_firstname,
531 'last_name' => $user->user_lastname,
532 'nice_name' => $user->user_nicename,
533 'display_name' => $user->display_name,
534 'roles' => array_values( $user->roles ),
535 // The helper reports effective grants, including role-editor denies.
536 'capabilities' => Access_Section::effective_capabilities( $user ),
537 'avatar_url' => get_avatar_url( $user->ID ),
538 // Token data.
539 'access_token' => $tokens['access_token'],
540 'refresh_token' => $tokens['refresh_token'],
541 'token_type' => $tokens['token_type'],
542 'expires_at' => $tokens['expires_at'],
543 );
544 }
545
546 /**
547 * Get minimal user data for redirect (security-focused).
548 *
549 * @param WP_User $user The user object.
550 *
551 * @return array
552 */
553 public function get_redirect_data( WP_User $user ): array {
554 $tokens = $this->generate_token_pair( $user );
555 if ( is_wp_error( $tokens ) ) {
556 return array();
557 }
558
559 // Only return essential data for redirect URL.
560 return array(
561 'access_token' => $tokens['access_token'],
562 'refresh_token' => $tokens['refresh_token'],
563 'token_type' => $tokens['token_type'],
564 'expires_at' => $tokens['expires_at'],
565 // Get basic user data for display, other data will be fetched from the server.
566 'uuid' => Cashier::instance()->get_cashier_uuid( $user ),
567 'id' => $user->ID,
568 'display_name' => $user->display_name,
569 );
570 }
571
572 /**
573 * Refresh an access token using a valid refresh token.
574 *
575 * @param string $refresh_token The refresh token.
576 *
577 * @return array|WP_Error
578 */
579 public function refresh_access_token( string $refresh_token ) {
580 $decoded = $this->validate_token( $refresh_token, 'refresh' );
581 if ( is_wp_error( $decoded ) ) {
582 return $decoded;
583 }
584
585 /*
586 * Before the first row read on this path. A refresh loads the whole session row —
587 * `is_live()` below, then `refresh_activity()` — so it needs
588 * the same protection a login has against a row too large to read (#1776).
589 * Validating an ACCESS token needs no such guard: it no longer touches the row.
590 */
591 $this->sessions->guard_row( absint( $decoded->data->user->id ) );
592
593 // Check if refresh token is still valid (not revoked).
594 if ( ! $this->sessions->is_live( $decoded->data->user->id, $decoded->jti ?? '' ) ) {
595 return new WP_Error(
596 'woocommerce_pos_auth_refresh_token_revoked',
597 'Refresh token has been revoked',
598 array( 'status' => 403 )
599 );
600 }
601
602 $user = get_user_by( 'id', $decoded->data->user->id );
603 if ( ! $user ) {
604 return new WP_Error(
605 'woocommerce_pos_auth_user_not_found',
606 'User not found',
607 array( 'status' => 404 )
608 );
609 }
610
611 // Update last_active timestamp for this session.
612 $this->update_session_activity( $decoded->data->user->id, $decoded->jti ?? '' );
613
614 // Generate new access token with link to refresh token (refresh token stays the same).
615 $new_access_token_data = $this->generate_access_token_data( $user, $decoded->jti ?? '' );
616 if ( is_wp_error( $new_access_token_data ) ) {
617 return $new_access_token_data;
618 }
619
620 return array(
621 'access_token' => $new_access_token_data['token'],
622 'token_type' => 'Bearer',
623 'expires_at' => (int) $new_access_token_data['expires_at'],
624 );
625 }
626
627 /**
628 * Revoke JWT Token by JTI.
629 *
630 * @param int $user_id The user ID.
631 * @param string $jti The token JTI.
632 *
633 * @return bool
634 */
635 public function revoke_refresh_token( int $user_id, string $jti ): bool {
636 return $this->sessions->revoke( $user_id, $jti );
637 }
638
639 /**
640 * Revoke all refresh tokens for a user.
641 *
642 * @param int $user_id The user ID.
643 *
644 * @return bool
645 */
646 /**
647 * Revoke all refresh tokens for a user with blacklisting.
648 *
649 * @param int $user_id The user ID.
650 *
651 * @return bool
652 */
653 public function revoke_all_refresh_tokens( int $user_id ): bool {
654 $refresh_tokens = $this->sessions->entries( $user_id );
655
656 // Blacklist all sessions for instant access token invalidation. The expiry
657 // policy is only consulted when there is something to blacklist.
658 if ( array() !== $refresh_tokens ) {
659 $issued_at = time();
660 $access_expire = $this->get_access_token_expire( $issued_at );
661
662 foreach ( $refresh_tokens as $jti => $token_data ) {
663 $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
664 $this->blacklist_token( $jti, $ttl );
665 }
666 }
667
668 return $this->sessions->revoke_all( $user_id );
669 }
670
671 /**
672 * Get all active sessions for a user.
673 *
674 * @param int $user_id The user ID.
675 *
676 * @return array
677 */
678 public function get_user_sessions( int $user_id ): array {
679 return $this->sessions->list( $user_id );
680 }
681
682 /**
683 * Revoke a specific session by JTI (alias for revoke_refresh_token for clarity).
684 *
685 * @param int $user_id The user ID.
686 * @param string $jti The token JTI.
687 *
688 * @return bool
689 */
690 public function revoke_session( int $user_id, string $jti ): bool {
691 return $this->revoke_refresh_token( $user_id, $jti );
692 }
693
694 /**
695 * Revoke all sessions except the current one.
696 *
697 * @param int $user_id The user ID.
698 * @param string $current_jti The current token JTI.
699 *
700 * @return bool
701 */
702 /**
703 * Revoke all sessions except the current one, with blacklisting.
704 *
705 * @param int $user_id The user ID.
706 * @param string $current_jti The current token JTI.
707 *
708 * @return bool
709 */
710 public function revoke_all_sessions_except( int $user_id, string $current_jti ): bool {
711 $refresh_tokens = $this->sessions->entries( $user_id );
712 if ( array() === $refresh_tokens ) {
713 // No row (or nothing in it): nothing to blacklist, nothing to rewrite.
714 return false;
715 }
716
717 // Blacklist all sessions except current for instant access token invalidation.
718 $issued_at = time();
719 $access_expire = $this->get_access_token_expire( $issued_at );
720
721 foreach ( $refresh_tokens as $jti => $token_data ) {
722 if ( $jti !== $current_jti ) {
723 $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
724 $this->blacklist_token( $jti, $ttl );
725 }
726 }
727
728 return $this->sessions->keep_only( $user_id, $current_jti );
729 }
730
731 /**
732 * Update last_active timestamp for a session.
733 *
734 * @param int $user_id The user ID.
735 * @param string $jti The token JTI.
736 *
737 * @return bool
738 */
739 public function update_session_activity( int $user_id, string $jti ): bool {
740 return $this->sessions->refresh_activity( $user_id, $jti );
741 }
742
743 /**
744 * Check if the current user can manage sessions for the target user.
745 *
746 * @param int $target_user_id The target user ID.
747 *
748 * @return bool
749 */
750 public function can_manage_user_sessions( int $target_user_id ): bool {
751 $current_user_id = get_current_user_id();
752
753 // User can manage their own sessions.
754 if ( $current_user_id === $target_user_id ) {
755 return true;
756 }
757
758 // Administrators can manage anyone's sessions.
759 if ( current_user_can( 'manage_options' ) ) {
760 return true;
761 }
762
763 // Shop managers can manage anyone's sessions.
764 if ( current_user_can( 'manage_woocommerce' ) ) {
765 return true;
766 }
767
768 return false;
769 }
770
771 /**
772 * Blacklist a token JTI (for instant revocation).
773 *
774 * Can be used for access token JTIs or refresh token JTIs (session).
775 * When a refresh_jti is blacklisted, all access tokens linked to it
776 * become invalid.
777 *
778 * @param string $jti Token JTI to blacklist.
779 * @param int $ttl Time to live in seconds.
780 *
781 * @return bool
782 */
783 public function blacklist_token( string $jti, int $ttl ): bool {
784 if ( empty( $jti ) ) {
785 return false;
786 }
787
788 // Use transient with TTL matching token expiration.
789 return set_transient( "wcpos_blacklist_{$jti}", true, $ttl );
790 }
791
792 /**
793 * Revoke session and blacklist it for instant access token invalidation.
794 *
795 * By blacklisting the refresh_jti, ALL access tokens linked to this session
796 * become immediately invalid (they contain refresh_jti in their payload).
797 *
798 * @param int $user_id The user ID.
799 * @param string $refresh_jti Refresh token JTI (session identifier).
800 *
801 * @return bool
802 */
803 public function revoke_session_with_blacklist( int $user_id, string $refresh_jti ): bool {
804 $session_data = $this->sessions->entry( $user_id, $refresh_jti );
805 $ttl = $this->get_access_token_blacklist_ttl( $session_data );
806
807 // Revoke the refresh token (session) from user meta.
808 $revoked = $this->revoke_session( $user_id, $refresh_jti );
809
810 if ( $revoked ) {
811 // Blacklist the session JTI - this invalidates ALL access tokens for this session
812 // TTL covers the current policy and any access token expiry recorded for the session.
813 $this->blacklist_token( $refresh_jti, $ttl );
814 }
815
816 return $revoked;
817 }
818
819 /**
820 * The last moment an access token minted against a session can still validate.
821 *
822 * @param array $token_data Stored session record.
823 *
824 * @return int Unix timestamp; 0 when the session carries no usable timestamp at all.
825 */
826 private function access_token_horizon( array $token_data ): int {
827 if ( isset( $token_data['access_expires'] ) ) {
828 return (int) $token_data['access_expires'];
829 }
830
831 // Rows written before `access_expires` was recorded. The newest access token such a
832 // session can hold was minted no later than its last recorded activity, so one
833 // access-token lifetime past that moment is the outside limit.
834 $last_seen = (int) ( $token_data['last_active'] ?? $token_data['created'] ?? 0 );
835
836 return $last_seen > 0 ? $this->get_access_token_expire( $last_seen ) : 0;
837 }
838
839 /**
840 * Filters the JWT access token expire time.
841 * Default: 30 minutes for access tokens.
842 *
843 * @param int $issued_at Token issued timestamp.
844 *
845 * @return int Expire time.
846 *
847 * @since 1.8.0
848 *
849 * @hook woocommerce_pos_jwt_access_token_expire
850 */
851 private function get_access_token_expire( int $issued_at ): int {
852 return (int) apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
853 }
854
855 /**
856 * Filters the JWT refresh token expire time.
857 * Default: 30 days for refresh tokens.
858 *
859 * @param int $issued_at Token issued timestamp.
860 *
861 * @return int Expire time.
862 *
863 * @since 1.8.0
864 *
865 * @hook woocommerce_pos_jwt_refresh_token_expire
866 */
867 private function get_refresh_token_expire( int $issued_at ): int {
868 return (int) apply_filters( 'woocommerce_pos_jwt_refresh_token_expire', $issued_at + ( DAY_IN_SECONDS * 30 ), $issued_at );
869 }
870
871 /**
872 * Read a top-level claim from a JWT payload array/object.
873 *
874 * @param mixed $payload The filtered JWT payload.
875 * @param string $claim The claim name.
876 *
877 * @return mixed|null
878 */
879 private function get_payload_claim( $payload, string $claim ) {
880 if ( \is_array( $payload ) && array_key_exists( $claim, $payload ) ) {
881 return $payload[ $claim ];
882 }
883
884 if ( \is_object( $payload ) && isset( $payload->{$claim} ) ) {
885 return $payload->{$claim};
886 }
887
888 return null;
889 }
890
891 /**
892 * Calculate blacklist TTL for a session.
893 *
894 * @param array $session_data Session metadata.
895 * @param null|int $issued_at Current timestamp.
896 * @param null|int $access_expire Current access token expiry policy value.
897 *
898 * @return int
899 */
900 private function get_access_token_blacklist_ttl(
901 array $session_data = array(),
902 ?int $issued_at = null,
903 ?int $access_expire = null
904 ): int {
905 $issued_at = null === $issued_at ? time() : $issued_at;
906 $access_expire = null === $access_expire ? $this->get_access_token_expire( $issued_at ) : $access_expire;
907
908 if ( isset( $session_data['access_expires'] ) ) {
909 $access_expire = max( $access_expire, (int) $session_data['access_expires'] );
910 } elseif ( isset( $session_data['expires'] ) ) {
911 $access_expire = max( $access_expire, (int) $session_data['expires'] );
912 }
913
914 return max( 0, $access_expire - $issued_at );
915 }
916
917 /**
918 * Check if a token JTI is blacklisted.
919 *
920 * Works for both access token JTIs and refresh token JTIs (sessions).
921 *
922 * @param string $jti Token JTI to check.
923 *
924 * @return bool
925 */
926 private function is_token_blacklisted( string $jti ): bool {
927 if ( empty( $jti ) ) {
928 return false;
929 }
930
931 // Check transient.
932 return false !== get_transient( "wcpos_blacklist_{$jti}" );
933 }
934
935 /**
936 * Clean up previous web session to prevent session proliferation.
937 *
938 * The web application generates new tokens on every page load. This method
939 * revokes the previous session (stored in a cookie) so only one web session
940 * exists per browser at a time.
941 *
942 * @param int $user_id The user ID.
943 */
944 private function cleanup_previous_web_session( int $user_id ): void {
945 $cookie_name = 'wcpos_web_session_jti';
946
947 if ( ! isset( $_COOKIE[ $cookie_name ] ) ) {
948 return;
949 }
950
951 $previous_jti = sanitize_text_field( wp_unslash( $_COOKIE[ $cookie_name ] ) );
952
953 if ( empty( $previous_jti ) ) {
954 return;
955 }
956
957 // Revoke the previous session (silently - don't care if it fails).
958 $this->revoke_session( $user_id, $previous_jti );
959 }
960
961 /**
962 * Set a cookie to track the current web session JTI.
963 *
964 * @param string $refresh_token The refresh token to extract JTI from.
965 */
966 private function set_web_session_cookie( string $refresh_token ): void {
967 $decoded = $this->validate_token( $refresh_token, 'refresh' );
968
969 if ( is_wp_error( $decoded ) || empty( $decoded->jti ) ) {
970 return;
971 }
972
973 $cookie_name = 'wcpos_web_session_jti';
974 $jti = $decoded->jti;
975 $expires = $decoded->exp ?? ( time() + DAY_IN_SECONDS * 30 );
976
977 // Set cookie with same expiry as refresh token
978 // Use httponly for security, but not secure flag as POS may run on localhost.
979 setcookie(
980 $cookie_name,
981 $jti,
982 array(
983 'expires' => $expires,
984 'path' => \defined( 'COOKIEPATH' ) ? COOKIEPATH : '/', // @phpstan-ignore-line
985 'domain' => \defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '', // @phpstan-ignore-line
986 'secure' => is_ssl(),
987 'httponly' => true,
988 'samesite' => 'Lax',
989 )
990 );
991 }
992 }
993