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

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