PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.2
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.2
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.2, at includes/Services/Auth.php

1,201 lines 36.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 * The single instance of the class.
25 *
26 * @var null|Auth
27 */
28 private static $instance = null;
29
30 /**
31 * Constructor is private to prevent direct instantiation.
32 * Or Auth::instance() instead.
33 */
34 public function __construct() {
35 }
36
37 /**
38 * Gets the singleton instance.
39 *
40 * @return Auth
41 */
42 public static function instance(): self {
43 if ( null === self::$instance ) {
44 self::$instance = new self();
45 }
46
47 return self::$instance;
48 }
49
50 /**
51 * Extract a WCPOS token from an authorization value.
52 *
53 * @param mixed $auth_value Authorization value.
54 *
55 * @return null|string
56 */
57 public function extract_token( $auth_value ): ?string {
58 if ( ! \is_string( $auth_value ) || '' === $auth_value ) {
59 return null;
60 }
61
62 // Match the old sscanf( 'Bearer %s' ) semantics exactly: any run of
63 // whitespace after the scheme, token = the next non-whitespace run.
64 if ( 1 === preg_match( '/^Bearer\s+(\S+)/', $auth_value, $matches ) ) {
65 return $matches[1];
66 }
67
68 return 1 === preg_match( '/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/', $auth_value ) ? $auth_value : null;
69 }
70
71 /**
72 * Authenticate the current request from its WCPOS token.
73 *
74 * @return false|int|WP_Error User ID, validation error, or false when no WCPOS token is present.
75 */
76 public function authenticate_request() {
77 $auth_header = $this->get_auth_header();
78 $token = $this->extract_token( $auth_header );
79 if ( null === $token ) {
80 return false;
81 }
82
83 $decoded_token = $this->validate_token( $token );
84 if ( is_wp_error( $decoded_token ) ) {
85 return $decoded_token;
86 }
87
88 return absint( $decoded_token->data->user->id );
89 }
90
91 /**
92 * Get authorization header/param value.
93 *
94 * Checks multiple sources for the authorization token:
95 * 1. HTTP_AUTHORIZATION server variable (standard)
96 * 2. REDIRECT_HTTP_AUTHORIZATION (Apache CGI workaround)
97 * 3. authorization query parameter (for servers that strip auth headers)
98 *
99 * @return false|string The authorization value or false if not found.
100 */
101 public function get_auth_header() {
102 // Check HTTP_AUTHORIZATION (not empty - htaccess SetEnvIf can set empty value).
103 if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
104 return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) );
105 }
106
107 // Check REDIRECT_HTTP_AUTHORIZATION (Apache CGI).
108 if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
109 return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
110 }
111
112 // Check authorization query param.
113 if ( ! empty( $_GET['authorization'] ) ) {
114 return sanitize_text_field( wp_unslash( $_GET['authorization'] ) );
115 }
116
117 return false;
118 }
119
120 /**
121 * Generate a secret key if it doesn't exist, or return the existing one.
122 *
123 * @return string
124 */
125 public function get_secret_key(): string {
126 $secret_key = get_option( 'woocommerce_pos_secret_key' );
127 if ( false === $secret_key || empty( $secret_key ) ) {
128 $secret_key = wp_generate_password( 64, true, true );
129 update_option( 'woocommerce_pos_secret_key', $secret_key );
130 }
131
132 return $secret_key;
133 }
134
135 /**
136 * Get refresh token secret key (separate from access token key for security).
137 *
138 * @return string
139 */
140 public function get_refresh_secret_key(): string {
141 $secret_key = get_option( 'woocommerce_pos_refresh_secret_key' );
142 if ( false === $secret_key || empty( $secret_key ) ) {
143 $secret_key = wp_generate_password( 64, true, true );
144 update_option( 'woocommerce_pos_refresh_secret_key', $secret_key );
145 }
146
147 return $secret_key;
148 }
149
150 /**
151 * Validate the provided JWT token.
152 *
153 * @param string $token The JWT token.
154 * @param string $token_type The token type: 'access' or 'refresh'.
155 *
156 * @return object|WP_Error
157 */
158 public function validate_token( $token = '', $token_type = 'access' ) {
159 try {
160 $secret_key = 'refresh' === $token_type ? $this->get_refresh_secret_key() : $this->get_secret_key();
161 $decoded_token = JWT::decode( $token, new Key( $secret_key, 'HS256' ) ); // @phpstan-ignore-line
162
163 // The Token is decoded now validate the iss.
164 if ( get_bloginfo( 'url' ) != $decoded_token->iss ) {
165 // The iss do not match, return error.
166 return new WP_Error(
167 'woocommmerce_pos_auth_bad_iss',
168 'The iss do not match with this server',
169 array( 'status' => 403 )
170 );
171 }
172
173 // Validate token type.
174 if ( ! isset( $decoded_token->type ) || $decoded_token->type !== $token_type ) {
175 return new WP_Error(
176 'woocommmerce_pos_auth_invalid_token_type',
177 'Invalid token type',
178 array( 'status' => 403 )
179 );
180 }
181
182 // So far so good, validate the user id in the token.
183 if ( ! isset( $decoded_token->data->user->id ) ) {
184 // No user id in the token, abort!!
185 return new WP_Error(
186 'woocommmerce_pos_auth_bad_request',
187 'User ID not found in the token',
188 array(
189 'status' => 403,
190 )
191 );
192 }
193
194 // Check if access token is blacklisted (for instant revocation)
195 // We check both the access token's own JTI and its parent refresh_jti.
196 if ( 'access' === $token_type ) {
197 // Check if this specific access token is blacklisted.
198 if ( isset( $decoded_token->jti ) && $this->is_token_blacklisted( $decoded_token->jti ) ) {
199 return new WP_Error(
200 'woocommerce_pos_auth_token_revoked',
201 'Access token has been revoked',
202 array( 'status' => 403 )
203 );
204 }
205
206 // Check if the parent session (refresh token) is blacklisted
207 // This catches ALL access tokens for a revoked session.
208 if ( isset( $decoded_token->refresh_jti ) && $this->is_token_blacklisted( $decoded_token->refresh_jti ) ) {
209 return new WP_Error(
210 'woocommerce_pos_auth_session_revoked',
211 'Session has been revoked',
212 array( 'status' => 403 )
213 );
214 }
215 }
216
217 // Everything looks good return the decoded token.
218 return $decoded_token;
219 } catch ( Exception $e ) {
220 // Something is wrong trying to decode the token, send back the error.
221 return new WP_Error(
222 'woocommmerce_pos_auth_invalid_token',
223 $e->getMessage(),
224 array(
225 'status' => 403,
226 )
227 );
228 }
229 }
230
231 /**
232 * Generate an access token for the provided user (short-lived).
233 *
234 * @param WP_User $user The user object.
235 * @param string $refresh_jti Optional refresh token JTI to link access token to session.
236 *
237 * @return string|WP_Error
238 */
239 public function generate_access_token( WP_User $user, string $refresh_jti = '' ) {
240 $token_data = $this->generate_access_token_data( $user, $refresh_jti );
241
242 if ( is_wp_error( $token_data ) ) {
243 return $token_data;
244 }
245
246 return $token_data['token'];
247 }
248
249 /**
250 * Generate an access token and return the token metadata used by callers.
251 *
252 * @param WP_User $user The user object.
253 * @param string $refresh_jti Optional refresh token JTI to link access token to session.
254 *
255 * @return array|WP_Error
256 */
257 private function generate_access_token_data( WP_User $user, string $refresh_jti = '' ) {
258 // First thing, check the secret key if not exist return a error.
259 if ( ! $this->get_secret_key() ) {
260 return new WP_Error(
261 'woocommerce_pos_jwt_auth_bad_config',
262 __( 'JWT is not configured properly, please contact the admin', 'woocommerce-pos' ),
263 array(
264 'status' => 403,
265 )
266 );
267 }
268
269 /** Valid credentials, the user exists create the according Token */
270 $issued_at = time();
271 $expire = $this->get_access_token_expire( $issued_at );
272
273 // Generate unique JTI for access token.
274 $jti = wp_generate_uuid4();
275
276 $token = array(
277 'iss' => get_bloginfo( 'url' ),
278 'iat' => $issued_at,
279 'exp' => $expire,
280 'jti' => $jti,
281 'type' => 'access',
282 'data' => array(
283 'user' => array(
284 'id' => $user->data->ID,
285 ),
286 ),
287 );
288
289 // Link to refresh token if provided.
290 if ( ! empty( $refresh_jti ) ) {
291 $token['refresh_jti'] = $refresh_jti;
292 }
293
294 /*
295 * Let the user modify the access token data before the sign.
296 *
297 * @param {array} $token
298 * @param {WP_User} $user
299 *
300 * @returns {array} Token
301 *
302 * @since 1.8.0
303 *
304 * @hook woocommerce_pos_jwt_access_token_before_sign
305 */
306 $payload = apply_filters( 'woocommerce_pos_jwt_access_token_before_sign', $token, $user );
307 $token = JWT::encode( $payload, $this->get_secret_key(), 'HS256' );
308
309 $expires_at = $this->get_payload_claim( $payload, 'exp' );
310 $access_jti = $this->get_payload_claim( $payload, 'jti' );
311 $linked_refresh_jti = $this->get_payload_claim( $payload, 'refresh_jti' );
312
313 $expires_at = null === $expires_at ? $expire : (int) $expires_at;
314 $access_jti = null === $access_jti ? $jti : (string) $access_jti;
315
316 if ( null !== $linked_refresh_jti ) {
317 $linked_refresh_jti = (string) $linked_refresh_jti;
318 $this->store_access_token_expiry( $user->ID, $linked_refresh_jti, $expires_at );
319 }
320
321 return array(
322 'token' => $token,
323 'expires_at' => $expires_at,
324 'jti' => $access_jti,
325 'refresh_jti' => $linked_refresh_jti,
326 );
327 }
328
329 /**
330 * Generate a refresh token for the provided user (long-lived).
331 *
332 * @param WP_User $user The user object.
333 *
334 * @return string|WP_Error
335 */
336 public function generate_refresh_token( WP_User $user ) {
337 // First thing, check the secret key if not exist return a error.
338 if ( ! $this->get_refresh_secret_key() ) {
339 return new WP_Error(
340 'woocommerce_pos_jwt_auth_bad_config',
341 __( 'JWT is not configured properly, please contact the admin', 'woocommerce-pos' ),
342 array(
343 'status' => 403,
344 )
345 );
346 }
347
348 /** Valid credentials, the user exists create the according Token */
349 $issued_at = time();
350 $expire = $this->get_refresh_token_expire( $issued_at );
351
352 // Generate unique JTI (JWT ID) for refresh token tracking.
353 $jti = wp_generate_uuid4();
354
355 $token = array(
356 'iss' => get_bloginfo( 'url' ),
357 'iat' => $issued_at,
358 'exp' => $expire,
359 'jti' => $jti,
360 'type' => 'refresh',
361 'data' => array(
362 'user' => array(
363 'id' => $user->data->ID,
364 ),
365 ),
366 );
367
368 /**
369 * Let the user modify the refresh token data before the sign.
370 *
371 * @param array $token
372 * @param WP_User $user
373 *
374 * @returns array Token
375 *
376 * @since 1.8.0
377 *
378 * @hook woocommerce_pos_jwt_refresh_token_before_sign
379 */
380 $token = JWT::encode( apply_filters( 'woocommerce_pos_jwt_refresh_token_before_sign', $token, $user ), $this->get_refresh_secret_key(), 'HS256' );
381
382 // Store refresh token JTI for potential revocation.
383 $this->store_refresh_token_jti( $user->ID, $jti, $expire );
384
385 return $token;
386 }
387
388 /**
389 * Generate both access and refresh tokens.
390 *
391 * @param WP_User $user The user object.
392 *
393 * @return array|WP_Error
394 */
395 public function generate_token_pair( WP_User $user ) {
396 // Generate refresh token first to get its JTI.
397 $refresh_token = $this->generate_refresh_token( $user );
398 if ( is_wp_error( $refresh_token ) ) {
399 return $refresh_token;
400 }
401
402 // Decode to get the JTI.
403 $decoded_refresh = $this->validate_token( $refresh_token, 'refresh' );
404 if ( is_wp_error( $decoded_refresh ) ) {
405 return $decoded_refresh;
406 }
407
408 // Generate access token with link to refresh token.
409 $access_token_data = $this->generate_access_token_data( $user, $decoded_refresh->jti ?? '' );
410 if ( is_wp_error( $access_token_data ) ) {
411 return $access_token_data;
412 }
413
414 return array(
415 'access_token' => $access_token_data['token'],
416 'refresh_token' => $refresh_token,
417 'token_type' => 'Bearer',
418 'expires_at' => (int) $access_token_data['expires_at'],
419 );
420 }
421
422 /**
423 * Legacy method for backward compatibility.
424 *
425 * @deprecated Use generate_access_token() instead
426 *
427 * @param WP_User $user The user object.
428 *
429 * @return string|WP_Error
430 */
431 public function generate_token( WP_User $user ) {
432 return $this->generate_access_token( $user );
433 }
434
435 /**
436 * Get user's data (minimal set for security).
437 *
438 * @param WP_User $user The user object.
439 * @param bool $is_web_frontend Whether this is the web frontend context.
440 * When true, manages web session cookie to prevent
441 * session proliferation on page refresh.
442 *
443 * @return array
444 */
445 public function get_user_data( WP_User $user, bool $is_web_frontend = false ): array {
446 // For web frontend, revoke previous session to prevent proliferation on page refresh.
447 if ( $is_web_frontend ) {
448 $this->cleanup_previous_web_session( $user->ID );
449 }
450
451 $tokens = $this->generate_token_pair( $user );
452 if ( is_wp_error( $tokens ) ) {
453 return array();
454 }
455
456 // For web frontend, store the new session JTI in a cookie for cleanup on next page load.
457 if ( $is_web_frontend ) {
458 $this->set_web_session_cookie( $tokens['refresh_token'] );
459 }
460
461 return array(
462 'uuid' => Cashier::instance()->get_cashier_uuid( $user ),
463 'id' => $user->ID,
464 'username' => $user->user_login,
465 'email' => $user->user_email,
466 'first_name' => $user->user_firstname,
467 'last_name' => $user->user_lastname,
468 'nice_name' => $user->user_nicename,
469 'display_name' => $user->display_name,
470 'roles' => array_values( $user->roles ),
471 // Raw grants (role + user), the same vocabulary the POS Access settings
472 // screen reads and writes. user_can() is wrong here: the singular meta
473 // caps (edit_product, delete_product) cannot be checked without a post.
474 'capabilities' => array_values(
475 array_filter( Access_Section::capability_names(), fn( $cap ) => ! empty( $user->allcaps[ $cap ] ) )
476 ),
477 'avatar_url' => get_avatar_url( $user->ID ),
478 // Token data.
479 'access_token' => $tokens['access_token'],
480 'refresh_token' => $tokens['refresh_token'],
481 'token_type' => $tokens['token_type'],
482 'expires_at' => $tokens['expires_at'],
483 );
484 }
485
486 /**
487 * Get minimal user data for redirect (security-focused).
488 *
489 * @param WP_User $user The user object.
490 *
491 * @return array
492 */
493 public function get_redirect_data( WP_User $user ): array {
494 $tokens = $this->generate_token_pair( $user );
495 if ( is_wp_error( $tokens ) ) {
496 return array();
497 }
498
499 // Only return essential data for redirect URL.
500 return array(
501 'access_token' => $tokens['access_token'],
502 'refresh_token' => $tokens['refresh_token'],
503 'token_type' => $tokens['token_type'],
504 'expires_at' => $tokens['expires_at'],
505 // Get basic user data for display, other data will be fetched from the server.
506 'uuid' => Cashier::instance()->get_cashier_uuid( $user ),
507 'id' => $user->ID,
508 'display_name' => $user->display_name,
509 );
510 }
511
512 /**
513 * Refresh an access token using a valid refresh token.
514 *
515 * @param string $refresh_token The refresh token.
516 *
517 * @return array|WP_Error
518 */
519 public function refresh_access_token( string $refresh_token ) {
520 $decoded = $this->validate_token( $refresh_token, 'refresh' );
521 if ( is_wp_error( $decoded ) ) {
522 return $decoded;
523 }
524
525 // Check if refresh token is still valid (not revoked).
526 if ( ! $this->is_refresh_token_valid( $decoded->data->user->id, $decoded->jti ?? '' ) ) {
527 return new WP_Error(
528 'woocommerce_pos_auth_refresh_token_revoked',
529 'Refresh token has been revoked',
530 array( 'status' => 403 )
531 );
532 }
533
534 $user = get_user_by( 'id', $decoded->data->user->id );
535 if ( ! $user ) {
536 return new WP_Error(
537 'woocommerce_pos_auth_user_not_found',
538 'User not found',
539 array( 'status' => 404 )
540 );
541 }
542
543 // Update last_active timestamp for this session.
544 $this->update_session_activity( $decoded->data->user->id, $decoded->jti ?? '' );
545
546 // Generate new access token with link to refresh token (refresh token stays the same).
547 $new_access_token_data = $this->generate_access_token_data( $user, $decoded->jti ?? '' );
548 if ( is_wp_error( $new_access_token_data ) ) {
549 return $new_access_token_data;
550 }
551
552 return array(
553 'access_token' => $new_access_token_data['token'],
554 'token_type' => 'Bearer',
555 'expires_at' => (int) $new_access_token_data['expires_at'],
556 );
557 }
558
559 /**
560 * Revoke JWT Token by JTI.
561 *
562 * @param int $user_id The user ID.
563 * @param string $jti The token JTI.
564 *
565 * @return bool
566 */
567 public function revoke_refresh_token( int $user_id, string $jti ): bool {
568 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
569 if ( ! \is_array( $refresh_tokens ) ) {
570 return false;
571 }
572
573 if ( isset( $refresh_tokens[ $jti ] ) ) {
574 unset( $refresh_tokens[ $jti ] );
575 update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
576
577 return true;
578 }
579
580 return false;
581 }
582
583 /**
584 * Revoke all refresh tokens for a user.
585 *
586 * @param int $user_id The user ID.
587 *
588 * @return bool
589 */
590 /**
591 * Revoke all refresh tokens for a user with blacklisting.
592 *
593 * @param int $user_id The user ID.
594 *
595 * @return bool
596 */
597 public function revoke_all_refresh_tokens( int $user_id ): bool {
598 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
599
600 // Blacklist all sessions for instant access token invalidation.
601 if ( \is_array( $refresh_tokens ) ) {
602 $issued_at = time();
603 $access_expire = $this->get_access_token_expire( $issued_at );
604
605 foreach ( $refresh_tokens as $jti => $token_data ) {
606 $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
607 $this->blacklist_token( $jti, $ttl );
608 }
609 }
610
611 return delete_user_meta( $user_id, '_woocommerce_pos_refresh_tokens' );
612 }
613
614 /**
615 * Get all active sessions for a user.
616 *
617 * @param int $user_id The user ID.
618 *
619 * @return array
620 */
621 public function get_user_sessions( int $user_id ): array {
622 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
623 if ( ! \is_array( $refresh_tokens ) ) {
624 return array();
625 }
626
627 $sessions = array();
628 $current_time = time();
629
630 foreach ( $refresh_tokens as $jti => $token_data ) {
631 // Skip expired sessions.
632 if ( $token_data['expires'] <= $current_time ) {
633 continue;
634 }
635
636 $sessions[] = array(
637 'jti' => $jti,
638 'created' => $token_data['created'] ?? $current_time,
639 'last_active' => $token_data['last_active'] ?? $token_data['created'] ?? $current_time,
640 'expires' => $token_data['expires'],
641 'ip_address' => $token_data['ip_address'] ?? '',
642 'user_agent' => $token_data['user_agent'] ?? '',
643 'device_info' => $token_data['device_info'] ?? array(),
644 );
645 }
646
647 // Sort by last_active descending (most recent first).
648 usort(
649 $sessions,
650 function ( $a, $b ) {
651 return $b['last_active'] - $a['last_active'];
652 }
653 );
654
655 return $sessions;
656 }
657
658 /**
659 * Revoke a specific session by JTI (alias for revoke_refresh_token for clarity).
660 *
661 * @param int $user_id The user ID.
662 * @param string $jti The token JTI.
663 *
664 * @return bool
665 */
666 public function revoke_session( int $user_id, string $jti ): bool {
667 return $this->revoke_refresh_token( $user_id, $jti );
668 }
669
670 /**
671 * Revoke all sessions except the current one.
672 *
673 * @param int $user_id The user ID.
674 * @param string $current_jti The current token JTI.
675 *
676 * @return bool
677 */
678 /**
679 * Revoke all sessions except the current one, with blacklisting.
680 *
681 * @param int $user_id The user ID.
682 * @param string $current_jti The current token JTI.
683 *
684 * @return bool
685 */
686 public function revoke_all_sessions_except( int $user_id, string $current_jti ): bool {
687 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
688 if ( ! \is_array( $refresh_tokens ) ) {
689 return false;
690 }
691
692 // Blacklist all sessions except current for instant access token invalidation.
693 $issued_at = time();
694 $access_expire = $this->get_access_token_expire( $issued_at );
695
696 foreach ( $refresh_tokens as $jti => $token_data ) {
697 if ( $jti !== $current_jti ) {
698 $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
699 $this->blacklist_token( $jti, $ttl );
700 }
701 }
702
703 // Keep only the current session in user meta.
704 $refresh_tokens = array_filter(
705 $refresh_tokens,
706 function ( $_token, $jti ) use ( $current_jti ) {
707 return $jti === $current_jti;
708 },
709 ARRAY_FILTER_USE_BOTH
710 );
711
712 return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
713 }
714
715 /**
716 * Update last_active timestamp for a session.
717 *
718 * @param int $user_id The user ID.
719 * @param string $jti The token JTI.
720 *
721 * @return bool
722 */
723 public function update_session_activity( int $user_id, string $jti ): bool {
724 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
725 if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $jti ] ) ) {
726 return false;
727 }
728
729 $refresh_tokens[ $jti ]['last_active'] = time();
730
731 return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
732 }
733
734 /**
735 * Check if the current user can manage sessions for the target user.
736 *
737 * @param int $target_user_id The target user ID.
738 *
739 * @return bool
740 */
741 public function can_manage_user_sessions( int $target_user_id ): bool {
742 $current_user_id = get_current_user_id();
743
744 // User can manage their own sessions.
745 if ( $current_user_id === $target_user_id ) {
746 return true;
747 }
748
749 // Administrators can manage anyone's sessions.
750 if ( current_user_can( 'manage_options' ) ) {
751 return true;
752 }
753
754 // Shop managers can manage anyone's sessions.
755 if ( current_user_can( 'manage_woocommerce' ) ) {
756 return true;
757 }
758
759 return false;
760 }
761
762 /**
763 * Blacklist a token JTI (for instant revocation).
764 *
765 * Can be used for access token JTIs or refresh token JTIs (session).
766 * When a refresh_jti is blacklisted, all access tokens linked to it
767 * become invalid.
768 *
769 * @param string $jti Token JTI to blacklist.
770 * @param int $ttl Time to live in seconds.
771 *
772 * @return bool
773 */
774 public function blacklist_token( string $jti, int $ttl ): bool {
775 if ( empty( $jti ) ) {
776 return false;
777 }
778
779 // Use transient with TTL matching token expiration.
780 return set_transient( "wcpos_blacklist_{$jti}", true, $ttl );
781 }
782
783 /**
784 * Revoke session and blacklist it for instant access token invalidation.
785 *
786 * By blacklisting the refresh_jti, ALL access tokens linked to this session
787 * become immediately invalid (they contain refresh_jti in their payload).
788 *
789 * @param int $user_id The user ID.
790 * @param string $refresh_jti Refresh token JTI (session identifier).
791 *
792 * @return bool
793 */
794 public function revoke_session_with_blacklist( int $user_id, string $refresh_jti ): bool {
795 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
796 $session_data = \is_array( $refresh_tokens ) && isset( $refresh_tokens[ $refresh_jti ] ) ? $refresh_tokens[ $refresh_jti ] : array();
797 $ttl = $this->get_access_token_blacklist_ttl( $session_data );
798
799 // Revoke the refresh token (session) from user meta.
800 $revoked = $this->revoke_session( $user_id, $refresh_jti );
801
802 if ( $revoked ) {
803 // Blacklist the session JTI - this invalidates ALL access tokens for this session
804 // TTL covers the current policy and any access token expiry recorded for the session.
805 $this->blacklist_token( $refresh_jti, $ttl );
806 }
807
808 return $revoked;
809 }
810
811 /**
812 * Store refresh token JTI for tracking/revocation.
813 *
814 * @param int $user_id The user ID.
815 * @param string $jti The token JTI.
816 * @param int $expires The expiration timestamp.
817 * @param null|Session_Context $context Request state the session is recorded
818 * against. Defaults to the current request.
819 */
820 private function store_refresh_token_jti( int $user_id, string $jti, int $expires, ?Session_Context $context = null ): void {
821 $context = null === $context ? Session_Context::from_request() : $context;
822
823 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
824 if ( ! \is_array( $refresh_tokens ) ) {
825 $refresh_tokens = array();
826 }
827
828 // Clean up expired tokens.
829 $refresh_tokens = array_filter(
830 $refresh_tokens,
831 function ( $token ) {
832 return $token['expires'] > time();
833 }
834 );
835
836 // Capture session metadata.
837 $current_time = time();
838 $ip_address = $context->get_ip();
839 $user_agent = $context->get_user_agent();
840 $device_info = $this->parse_user_agent( $user_agent );
841
842 // Check for explicit platform declaration from native apps (passed as a param in the auth request).
843 $platform = $context->get_platform();
844 $version = $context->get_version();
845 $build = $context->get_build();
846
847 // Override app_type if platform was explicitly provided by the client.
848 if ( \in_array( $platform, array( 'ios', 'android', 'electron', 'web' ), true ) ) {
849 $device_info['app_type'] = 'web' === $platform ? 'web' : $platform . '_app';
850
851 // Set appropriate device type based on platform.
852 if ( 'ios' === $platform || 'android' === $platform ) {
853 $device_info['device_type'] = 'tablet'; // Default to tablet for mobile apps.
854 } elseif ( 'electron' === $platform ) {
855 $device_info['device_type'] = 'desktop';
856 }
857
858 // Use version from param if provided.
859 if ( ! empty( $version ) ) {
860 $device_info['browser_version'] = $version;
861 }
862
863 // Store build number if provided.
864 if ( ! empty( $build ) ) {
865 $device_info['build'] = $build;
866 }
867
868 // Set browser to WooCommerce POS for native apps.
869 if ( 'web' !== $platform ) {
870 $device_info['browser'] = 'WooCommerce POS';
871 }
872 }
873
874 // Add new token with metadata.
875 $refresh_tokens[ $jti ] = array(
876 'expires' => $expires,
877 'created' => $current_time,
878 'last_active' => $current_time,
879 'ip_address' => $ip_address,
880 'user_agent' => $user_agent,
881 'device_info' => $device_info,
882 );
883
884 update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
885 }
886
887 /**
888 * Filters the JWT access token expire time.
889 * Default: 30 minutes for access tokens.
890 *
891 * @param int $issued_at Token issued timestamp.
892 *
893 * @return int Expire time.
894 *
895 * @since 1.8.0
896 *
897 * @hook woocommerce_pos_jwt_access_token_expire
898 */
899 private function get_access_token_expire( int $issued_at ): int {
900 return (int) apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
901 }
902
903 /**
904 * Filters the JWT refresh token expire time.
905 * Default: 30 days for refresh tokens.
906 *
907 * @param int $issued_at Token issued timestamp.
908 *
909 * @return int Expire time.
910 *
911 * @since 1.8.0
912 *
913 * @hook woocommerce_pos_jwt_refresh_token_expire
914 */
915 private function get_refresh_token_expire( int $issued_at ): int {
916 return (int) apply_filters( 'woocommerce_pos_jwt_refresh_token_expire', $issued_at + ( DAY_IN_SECONDS * 30 ), $issued_at );
917 }
918
919 /**
920 * Read a top-level claim from a JWT payload array/object.
921 *
922 * @param mixed $payload The filtered JWT payload.
923 * @param string $claim The claim name.
924 *
925 * @return mixed|null
926 */
927 private function get_payload_claim( $payload, string $claim ) {
928 if ( \is_array( $payload ) && array_key_exists( $claim, $payload ) ) {
929 return $payload[ $claim ];
930 }
931
932 if ( \is_object( $payload ) && isset( $payload->{$claim} ) ) {
933 return $payload->{$claim};
934 }
935
936 return null;
937 }
938
939 /**
940 * Record the latest access token expiry linked to a refresh-token session.
941 *
942 * @param int $user_id The user ID.
943 * @param string $refresh_jti Refresh token JTI.
944 * @param int $access_expires Access token expiry timestamp.
945 *
946 * @return bool
947 */
948 private function store_access_token_expiry( int $user_id, string $refresh_jti, int $access_expires ): bool {
949 if ( empty( $refresh_jti ) || $access_expires <= 0 ) {
950 return false;
951 }
952
953 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
954 if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $refresh_jti ] ) ) {
955 return false;
956 }
957
958 $current_access_expires = isset( $refresh_tokens[ $refresh_jti ]['access_expires'] ) ? (int) $refresh_tokens[ $refresh_jti ]['access_expires'] : 0;
959 if ( $access_expires <= $current_access_expires ) {
960 return true;
961 }
962
963 $refresh_tokens[ $refresh_jti ]['access_expires'] = $access_expires;
964
965 return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
966 }
967
968 /**
969 * Calculate blacklist TTL for a session.
970 *
971 * @param array $session_data Session metadata.
972 * @param null|int $issued_at Current timestamp.
973 * @param null|int $access_expire Current access token expiry policy value.
974 *
975 * @return int
976 */
977 private function get_access_token_blacklist_ttl(
978 array $session_data = array(),
979 ?int $issued_at = null,
980 ?int $access_expire = null
981 ): int {
982 $issued_at = null === $issued_at ? time() : $issued_at;
983 $access_expire = null === $access_expire ? $this->get_access_token_expire( $issued_at ) : $access_expire;
984
985 if ( isset( $session_data['access_expires'] ) ) {
986 $access_expire = max( $access_expire, (int) $session_data['access_expires'] );
987 } elseif ( isset( $session_data['expires'] ) ) {
988 $access_expire = max( $access_expire, (int) $session_data['expires'] );
989 }
990
991 return max( 0, $access_expire - $issued_at );
992 }
993
994 /**
995 * Check if refresh token is still valid (not revoked).
996 *
997 * @param int $user_id The user ID.
998 * @param string $jti The token JTI.
999 *
1000 * @return bool
1001 */
1002 private function is_refresh_token_valid( int $user_id, string $jti ): bool {
1003 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
1004 if ( ! \is_array( $refresh_tokens ) ) {
1005 return false;
1006 }
1007
1008 return isset( $refresh_tokens[ $jti ] ) && $refresh_tokens[ $jti ]['expires'] > time();
1009 }
1010
1011 /**
1012 * Parse user agent string to extract device information.
1013 *
1014 * @param string $user_agent The user agent string.
1015 *
1016 * @return array
1017 */
1018 private function parse_user_agent( string $user_agent ): array {
1019 $device_info = array(
1020 'device_type' => 'unknown',
1021 'browser' => 'unknown',
1022 'browser_version' => '',
1023 'os' => 'unknown',
1024 'app_type' => 'web', // web, ios_app, android_app, electron_app.
1025 );
1026
1027 if ( empty( $user_agent ) ) {
1028 return $device_info;
1029 }
1030
1031 // Detect WooCommerce POS apps first (custom identifiers)
1032 // Check for Electron app (including just "WooCommercePOS" in user agent with Electron).
1033 if ( preg_match( '/Electron/i', $user_agent ) && preg_match( '/WooCommercePOS|WCPOS/i', $user_agent ) ) {
1034 $device_info['app_type'] = 'electron_app';
1035 $device_info['browser'] = 'WooCommerce POS';
1036 $device_info['device_type'] = 'desktop';
1037 // Try to extract WooCommercePOS version.
1038 if ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1039 $device_info['browser_version'] = $matches[1];
1040 } elseif ( preg_match( '/WCPOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1041 $device_info['browser_version'] = $matches[1];
1042 }
1043 } elseif ( preg_match( '/WCPOS[-_]?iOS|WooCommercePOS[-_]?iOS/i', $user_agent ) ) {
1044 $device_info['app_type'] = 'ios_app';
1045 $device_info['browser'] = 'WooCommerce POS';
1046 // Default to tablet unless explicitly detected as phone.
1047 $device_info['device_type'] = preg_match( '/iphone|ipod/i', $user_agent ) ? 'mobile' : 'tablet';
1048 if ( preg_match( '/WCPOS[-_]?iOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1049 $device_info['browser_version'] = $matches[1];
1050 } elseif ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1051 $device_info['browser_version'] = $matches[1];
1052 }
1053 } elseif ( preg_match( '/WCPOS[-_]?Android|WooCommercePOS[-_]?Android/i', $user_agent ) ) {
1054 $device_info['app_type'] = 'android_app';
1055 $device_info['browser'] = 'WooCommerce POS';
1056 // Default to tablet unless explicitly detected as mobile.
1057 $device_info['device_type'] = preg_match( '/mobile/i', $user_agent ) && ! preg_match( '/tablet/i', $user_agent ) ? 'mobile' : 'tablet';
1058 if ( preg_match( '/WCPOS[-_]?Android[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1059 $device_info['browser_version'] = $matches[1];
1060 } elseif ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1061 $device_info['browser_version'] = $matches[1];
1062 }
1063 }
1064
1065 // Detect standard device type (if not already set by app detection).
1066 if ( 'web' === $device_info['app_type'] ) {
1067 if ( preg_match( '/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/i', $user_agent ) ) {
1068 $device_info['device_type'] = 'mobile';
1069 } elseif ( preg_match( '/tablet|ipad|playbook|silk/i', $user_agent ) ) {
1070 $device_info['device_type'] = 'tablet';
1071 } else {
1072 $device_info['device_type'] = 'desktop';
1073 }
1074 }
1075
1076 // Detect browser (skip if we already detected a WCPOS app).
1077 if ( 'WooCommerce POS' !== $device_info['browser'] ) {
1078 if ( preg_match( '/MSIE|Trident/i', $user_agent ) ) {
1079 $device_info['browser'] = 'Internet Explorer';
1080 if ( preg_match( '/MSIE ([0-9.]+)/', $user_agent, $matches ) ) {
1081 $device_info['browser_version'] = $matches[1];
1082 }
1083 } elseif ( preg_match( '/Edge\/([0-9.]+)/i', $user_agent, $matches ) ) {
1084 $device_info['browser'] = 'Edge';
1085 $device_info['browser_version'] = $matches[1];
1086 } elseif ( preg_match( '/Edg\/([0-9.]+)/i', $user_agent, $matches ) ) {
1087 $device_info['browser'] = 'Edge';
1088 $device_info['browser_version'] = $matches[1];
1089 } elseif ( preg_match( '/Firefox\/([0-9.]+)/i', $user_agent, $matches ) ) {
1090 $device_info['browser'] = 'Firefox';
1091 $device_info['browser_version'] = $matches[1];
1092 } elseif ( preg_match( '/Chrome\/([0-9.]+)/i', $user_agent, $matches ) ) {
1093 $device_info['browser'] = 'Chrome';
1094 $device_info['browser_version'] = $matches[1];
1095 } elseif ( preg_match( '/Safari\/([0-9.]+)/i', $user_agent, $matches ) ) {
1096 // Safari should be checked after Chrome because Chrome also contains Safari.
1097 if ( ! preg_match( '/Chrome/i', $user_agent ) ) {
1098 $device_info['browser'] = 'Safari';
1099 $device_info['browser_version'] = $matches[1];
1100 }
1101 } elseif ( preg_match( '/Opera\/([0-9.]+)/i', $user_agent, $matches ) ) {
1102 $device_info['browser'] = 'Opera';
1103 $device_info['browser_version'] = $matches[1];
1104 }
1105 }
1106
1107 // Detect OS.
1108 if ( preg_match( '/Windows NT ([0-9.]+)/i', $user_agent, $matches ) ) {
1109 $device_info['os'] = 'Windows';
1110 } elseif ( preg_match( '/Mac OS X ([0-9_]+)/i', $user_agent, $matches ) ) {
1111 $device_info['os'] = 'macOS';
1112 } elseif ( preg_match( '/Android ([0-9.]+)/i', $user_agent, $matches ) ) {
1113 $device_info['os'] = 'Android';
1114 } elseif ( preg_match( '/iPhone OS ([0-9_]+)/i', $user_agent, $matches ) ) {
1115 $device_info['os'] = 'iOS';
1116 } elseif ( preg_match( '/iPad.*OS ([0-9_]+)/i', $user_agent, $matches ) ) {
1117 $device_info['os'] = 'iPadOS';
1118 } elseif ( preg_match( '/Linux/i', $user_agent ) ) {
1119 $device_info['os'] = 'Linux';
1120 }
1121
1122 return $device_info;
1123 }
1124
1125 /**
1126 * Check if a token JTI is blacklisted.
1127 *
1128 * Works for both access token JTIs and refresh token JTIs (sessions).
1129 *
1130 * @param string $jti Token JTI to check.
1131 *
1132 * @return bool
1133 */
1134 private function is_token_blacklisted( string $jti ): bool {
1135 if ( empty( $jti ) ) {
1136 return false;
1137 }
1138
1139 // Check transient.
1140 return false !== get_transient( "wcpos_blacklist_{$jti}" );
1141 }
1142
1143 /**
1144 * Clean up previous web session to prevent session proliferation.
1145 *
1146 * The web application generates new tokens on every page load. This method
1147 * revokes the previous session (stored in a cookie) so only one web session
1148 * exists per browser at a time.
1149 *
1150 * @param int $user_id The user ID.
1151 */
1152 private function cleanup_previous_web_session( int $user_id ): void {
1153 $cookie_name = 'wcpos_web_session_jti';
1154
1155 if ( ! isset( $_COOKIE[ $cookie_name ] ) ) {
1156 return;
1157 }
1158
1159 $previous_jti = sanitize_text_field( wp_unslash( $_COOKIE[ $cookie_name ] ) );
1160
1161 if ( empty( $previous_jti ) ) {
1162 return;
1163 }
1164
1165 // Revoke the previous session (silently - don't care if it fails).
1166 $this->revoke_session( $user_id, $previous_jti );
1167 }
1168
1169 /**
1170 * Set a cookie to track the current web session JTI.
1171 *
1172 * @param string $refresh_token The refresh token to extract JTI from.
1173 */
1174 private function set_web_session_cookie( string $refresh_token ): void {
1175 $decoded = $this->validate_token( $refresh_token, 'refresh' );
1176
1177 if ( is_wp_error( $decoded ) || empty( $decoded->jti ) ) {
1178 return;
1179 }
1180
1181 $cookie_name = 'wcpos_web_session_jti';
1182 $jti = $decoded->jti;
1183 $expires = $decoded->exp ?? ( time() + DAY_IN_SECONDS * 30 );
1184
1185 // Set cookie with same expiry as refresh token
1186 // Use httponly for security, but not secure flag as POS may run on localhost.
1187 setcookie(
1188 $cookie_name,
1189 $jti,
1190 array(
1191 'expires' => $expires,
1192 'path' => \defined( 'COOKIEPATH' ) ? COOKIEPATH : '/', // @phpstan-ignore-line
1193 'domain' => \defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '', // @phpstan-ignore-line
1194 'secure' => is_ssl(),
1195 'httponly' => true,
1196 'samesite' => 'Lax',
1197 )
1198 );
1199 }
1200 }
1201