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
← All changes | includes/Services/Auth.php +65 -633 1.10.91.10.18 View file →
@@ -9,15 +9,13 @@
9 9
10 10 use Exception;
11 11 use WCPOS\Vendor\Firebase\JWT\JWT;
12 12 use WCPOS\Vendor\Firebase\JWT\Key;
13 -use WCPOS\WooCommercePOS\Logger;
14 13 use WCPOS\WooCommercePOS\Services\Settings\Access_Section;
15 14 use WP_Error;
16 15 use WP_User;
17 16 use const DAY_IN_SECONDS;
18 17 use const HOUR_IN_SECONDS;
19 -use const MINUTE_IN_SECONDS;
20 18
21 19 /**
22 20 * Auth Service class.
23 21 */
@@ -22,88 +20,57 @@
22 20 * Auth Service class.
23 21 */
24 22 class Auth {
25 23 /**
26 - * Maximum number of refresh-token sessions retained per user.
24 + * Maximum retained idle sessions.
27 25 *
28 - * Refresh tokens live for weeks and every entry carries a user agent plus parsed
29 - * device info, so without a cap the `_woocommerce_pos_refresh_tokens` row grows until
30 - * `get_user_meta()` can no longer unserialize it inside the PHP memory limit.
31 - *
32 - * This is a ceiling on ACCUMULATED CLUTTER, never a limit on how many devices may be
33 - * signed in at once: `evict_oldest_sessions()` only ever removes sessions that have
34 - * been idle for SESSION_EVICTION_IDLE_SECONDS, and lets the count exceed this number
35 - * rather than log a live device out. Two hundred covers a large merchant's real
36 - * devices with room to spare, and 200 entries serialize to roughly a hundred
37 - * kilobytes.
26 + * @deprecated Use Session_Registry::MAX_SESSIONS_PER_USER.
38 27 */
39 - public const MAX_SESSIONS_PER_USER = 200;
28 + public const MAX_SESSIONS_PER_USER = Session_Registry::MAX_SESSIONS_PER_USER;
40 29
41 30 /**
42 - * How long a session must have gone unseen before eviction may remove it.
31 + * Minimum idle time before eviction.
43 32 *
44 - * The cap alone is not a safe eviction rule. A client that authenticates
45 - * programmatically mints sessions far faster than a merchant does, so "the oldest of
46 - * N" can be a session created minutes ago and still in use — and evicting it
47 - * blacklists its access token, logging a working device out mid-request. That is
48 - * exactly what happened on the shared E2E cashier after #1798 shipped a 50-session
49 - * cap. A week of silence is a long time for a till: a device seen inside that window
50 - * is treated as live and is never a candidate, whatever the count.
33 + * @deprecated Use Session_Registry::SESSION_EVICTION_IDLE_SECONDS.
51 34 */
52 - public const SESSION_EVICTION_IDLE_SECONDS = 7 * DAY_IN_SECONDS;
35 + public const SESSION_EVICTION_IDLE_SECONDS = Session_Registry::SESSION_EVICTION_IDLE_SECONDS;
53 36
54 37 /**
55 - * How stale a session's `last_active` may get before an authenticated request rewrites it.
38 + * Session row byte ceiling.
56 39 *
57 - * `last_active` decides what eviction may touch, so it has to reflect USE, not just
58 - * token refreshes — before this, only `refresh_access_token()` moved it, and a device
59 - * happily working through a 30-minute access token looked idle the whole time. Every
60 - * authenticated request now refreshes it, throttled to one write per session per five
61 - * minutes so the POS's request volume does not turn into a write per call.
40 + * @deprecated Use Session_Registry::MAX_SESSIONS_ROW_BYTES.
62 41 */
63 - private const SESSION_ACTIVITY_REFRESH_SECONDS = 5 * MINUTE_IN_SECONDS;
42 + public const MAX_SESSIONS_ROW_BYTES = Session_Registry::MAX_SESSIONS_ROW_BYTES;
64 43
65 44 /**
66 - * Transient prefix for the per-session "last seen" record.
45 + * The single instance of the class.
67 46 *
68 - * Activity is recorded OUTSIDE the session row on purpose. Writing it into the row
69 - * meant every authenticated request did a read-modify-write of the whole
70 - * `_woocommerce_pos_refresh_tokens` array, which is neither atomic nor cheap: a
71 - * request overlapping a login, logout or revoke for the same user could write back a
72 - * stale copy and erase the concurrent change — losing a session that had just been
73 - * issued, so the new client worked until its access token expired and was then refused
74 - * a refresh. Four parallel E2E shards on one cashier do exactly that. A per-session key
75 - * cannot collide with another session's write, and reading it costs no row load at all.
47 + * @var null|Auth
76 48 */
77 - private const SESSION_SEEN_TRANSIENT_PREFIX = 'wcpos_session_seen_';
49 + private static $instance = null;
78 50
79 51 /**
80 - * Byte ceiling on the stored session row before it is discarded UNREAD.
52 + * Session storage.
81 53 *
82 - * This is a LAST RESORT for a row no longer safe to load, not a tidy-up threshold —
83 - * discarding it signs every one of that user's devices out at once. The bar is set
84 - * from measurement rather than caution: a 9,216,730-byte row (17,000 sessions) read
85 - * fine under the 128 MB limit that produced the #1776 fatal — `get_user_meta()` cost
86 - * ~26 MB to fetch and ~38 MB with the unserialize, and it was the WRITE-BACK, at ~42
87 - * MB more, that exhausted the request. Six megabytes therefore sits below anything
88 - * measured to be unreadable while still catching a row heading for that fatal. The
89 - * first release of this guard used one megabyte, which is comfortably readable and
90 - * threw away rows that eviction could simply have trimmed.
54 + * @var Session_Registry
91 55 */
92 - public const MAX_SESSIONS_ROW_BYTES = 6291456;
56 + private $sessions;
93 57
94 58 /**
95 - * The single instance of the class.
96 - *
97 - * @var null|Auth
59 + * Constructor is private to prevent direct instantiation.
60 + * Or Auth::instance() instead.
98 61 */
99 - private static $instance = null;
62 + public function __construct() {
63 + $this->sessions = new Session_Registry();
64 + }
100 65
101 66 /**
102 - * Constructor is private to prevent direct instantiation.
103 - * Or Auth::instance() instead.
67 + * Get the session registry.
68 + *
69 + * @return Session_Registry
104 70 */
105 - public function __construct() {
71 + public function sessions(): Session_Registry {
72 + return $this->sessions;
106 73 }
107 74
108 75 /**
109 76 * Gets the singleton instance.
@@ -286,9 +253,9 @@
286 253
287 254 // The session is live: record that, so eviction can tell a device that is
288 255 // working right now from one that has not been seen in a week.
289 256 if ( isset( $decoded_token->refresh_jti ) ) {
290 - $this->touch_session_activity(
257 + $this->sessions->touch(
291 258 absint( $decoded_token->data->user->id ),
292 259 (string) $decoded_token->refresh_jti
293 260 );
294 261 }
@@ -394,9 +361,9 @@
394 361 $access_jti = null === $access_jti ? $jti : (string) $access_jti;
395 362
396 363 if ( null !== $linked_refresh_jti ) {
397 364 $linked_refresh_jti = (string) $linked_refresh_jti;
398 - $this->store_access_token_expiry( $user->ID, $linked_refresh_jti, $expires_at );
365 + $this->sessions->record_access_expiry( $user->ID, $linked_refresh_jti, $expires_at );
399 366 }
400 367
401 368 return array(
402 369 'token' => $token,
@@ -459,9 +426,26 @@
459 426 */
460 427 $token = JWT::encode( apply_filters( 'woocommerce_pos_jwt_refresh_token_before_sign', $token, $user ), $this->get_refresh_secret_key(), 'HS256' );
461 428
462 429 // Store refresh token JTI for potential revocation.
463 - $this->store_refresh_token_jti( $user->ID, $jti, $expire );
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 + }
464 448
465 449 return $token;
466 450 }
467 451
@@ -547,14 +531,10 @@
547 531 'last_name' => $user->user_lastname,
548 532 'nice_name' => $user->user_nicename,
549 533 'display_name' => $user->display_name,
550 534 'roles' => array_values( $user->roles ),
551 - // Raw grants (role + user), the same vocabulary the POS Access settings
552 - // screen reads and writes. user_can() is wrong here: the singular meta
553 - // caps (edit_product, delete_product) cannot be checked without a post.
554 - 'capabilities' => array_values(
555 - array_filter( Access_Section::capability_names(), fn( $cap ) => ! empty( $user->allcaps[ $cap ] ) )
556 - ),
535 + // The helper reports effective grants, including role-editor denies.
536 + 'capabilities' => Access_Section::effective_capabilities( $user ),
557 537 'avatar_url' => get_avatar_url( $user->ID ),
558 538 // Token data.
559 539 'access_token' => $tokens['access_token'],
560 540 'refresh_token' => $tokens['refresh_token'],
@@ -603,16 +583,16 @@
603 583 }
604 584
605 585 /*
606 586 * Before the first row read on this path. A refresh loads the whole session row —
607 - * `is_refresh_token_valid()` below, then `update_session_activity()` — so it needs
587 + * `is_live()` below, then `refresh_activity()` — so it needs
608 588 * the same protection a login has against a row too large to read (#1776).
609 589 * Validating an ACCESS token needs no such guard: it no longer touches the row.
610 590 */
611 - $this->discard_oversized_session_row( absint( $decoded->data->user->id ) );
591 + $this->sessions->guard_row( absint( $decoded->data->user->id ) );
612 592
613 593 // Check if refresh token is still valid (not revoked).
614 - if ( ! $this->is_refresh_token_valid( $decoded->data->user->id, $decoded->jti ?? '' ) ) {
594 + if ( ! $this->sessions->is_live( $decoded->data->user->id, $decoded->jti ?? '' ) ) {
615 595 return new WP_Error(
616 596 'woocommerce_pos_auth_refresh_token_revoked',
617 597 'Refresh token has been revoked',
618 598 array( 'status' => 403 )
@@ -652,22 +632,9 @@
652 632 *
653 633 * @return bool
654 634 */
655 635 public function revoke_refresh_token( int $user_id, string $jti ): bool {
656 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
657 - if ( ! \is_array( $refresh_tokens ) ) {
658 - return false;
659 - }
660 -
661 - if ( isset( $refresh_tokens[ $jti ] ) ) {
662 - unset( $refresh_tokens[ $jti ] );
663 - update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
664 - $this->forget_session_activity( $jti );
665 -
666 - return true;
667 - }
668 -
669 - return false;
636 + return $this->sessions->revoke( $user_id, $jti );
670 637 }
671 638
672 639 /**
673 640 * Revoke all refresh tokens for a user.
@@ -683,12 +650,13 @@
683 650 *
684 651 * @return bool
685 652 */
686 653 public function revoke_all_refresh_tokens( int $user_id ): bool {
687 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
654 + $refresh_tokens = $this->sessions->entries( $user_id );
688 655
689 - // Blacklist all sessions for instant access token invalidation.
690 - if ( \is_array( $refresh_tokens ) ) {
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 ) {
691 659 $issued_at = time();
692 660 $access_expire = $this->get_access_token_expire( $issued_at );
693 661
694 662 foreach ( $refresh_tokens as $jti => $token_data ) {
@@ -693,13 +661,12 @@
693 661
694 662 foreach ( $refresh_tokens as $jti => $token_data ) {
695 663 $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
696 664 $this->blacklist_token( $jti, $ttl );
697 - $this->forget_session_activity( (string) $jti );
698 665 }
699 666 }
700 667
701 - return delete_user_meta( $user_id, '_woocommerce_pos_refresh_tokens' );
668 + return $this->sessions->revoke_all( $user_id );
702 669 }
703 670
704 671 /**
705 672 * Get all active sessions for a user.
@@ -708,42 +675,9 @@
708 675 *
709 676 * @return array
710 677 */
711 678 public function get_user_sessions( int $user_id ): array {
712 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
713 - if ( ! \is_array( $refresh_tokens ) ) {
714 - return array();
715 - }
716 -
717 - $sessions = array();
718 - $current_time = time();
719 -
720 - foreach ( $refresh_tokens as $jti => $token_data ) {
721 - // Skip expired sessions.
722 - if ( $token_data['expires'] <= $current_time ) {
723 - continue;
724 - }
725 -
726 - $sessions[] = array(
727 - 'jti' => $jti,
728 - 'created' => $token_data['created'] ?? $current_time,
729 - 'last_active' => $token_data['last_active'] ?? $token_data['created'] ?? $current_time,
730 - 'expires' => $token_data['expires'],
731 - 'ip_address' => $token_data['ip_address'] ?? '',
732 - 'user_agent' => $token_data['user_agent'] ?? '',
733 - 'device_info' => $token_data['device_info'] ?? array(),
734 - );
735 - }
736 -
737 - // Sort by last_active descending (most recent first).
738 - usort(
739 - $sessions,
740 - function ( $a, $b ) {
741 - return $b['last_active'] - $a['last_active'];
742 - }
743 - );
744 -
745 - return $sessions;
679 + return $this->sessions->list( $user_id );
746 680 }
747 681
748 682 /**
749 683 * Revoke a specific session by JTI (alias for revoke_refresh_token for clarity).
@@ -773,10 +707,11 @@
773 707 *
774 708 * @return bool
775 709 */
776 710 public function revoke_all_sessions_except( int $user_id, string $current_jti ): bool {
777 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
778 - if ( ! \is_array( $refresh_tokens ) ) {
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.
779 714 return false;
780 715 }
781 716
782 717 // Blacklist all sessions except current for instant access token invalidation.
@@ -786,22 +721,12 @@
786 721 foreach ( $refresh_tokens as $jti => $token_data ) {
787 722 if ( $jti !== $current_jti ) {
788 723 $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
789 724 $this->blacklist_token( $jti, $ttl );
790 - $this->forget_session_activity( (string) $jti );
791 725 }
792 726 }
793 727
794 - // Keep only the current session in user meta.
795 - $refresh_tokens = array_filter(
796 - $refresh_tokens,
797 - function ( $_token, $jti ) use ( $current_jti ) {
798 - return $jti === $current_jti;
799 - },
800 - ARRAY_FILTER_USE_BOTH
801 - );
802 -
803 - return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
728 + return $this->sessions->keep_only( $user_id, $current_jti );
804 729 }
805 730
806 731 /**
807 732 * Update last_active timestamp for a session.
@@ -811,62 +736,12 @@
811 736 *
812 737 * @return bool
813 738 */
814 739 public function update_session_activity( int $user_id, string $jti ): bool {
815 - // Public surface: any caller reaching the row goes through the size guard first.
816 - $this->discard_oversized_session_row( $user_id );
817 -
818 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
819 - if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $jti ] ) ) {
820 - return false;
821 - }
822 -
823 - $refresh_tokens[ $jti ]['last_active'] = time();
824 -
825 - return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
740 + return $this->sessions->refresh_activity( $user_id, $jti );
826 741 }
827 742
828 743 /**
829 - * Refresh a session's `last_active`, at most once every few minutes.
830 - *
831 - * Called from token validation, so it runs on EVERY authenticated request. The
832 - * throttle is what makes that affordable: the value only has to be accurate to within
833 - * minutes for a rule that asks whether a session has been unseen for a week, and the
834 - * read is already in the user's meta cache by this point.
835 - *
836 - * @param int $user_id The user ID.
837 - * @param string $jti Refresh token JTI (session identifier).
838 - */
839 - private function touch_session_activity( int $user_id, string $jti ): void {
840 - if ( 0 === $user_id || '' === $jti ) {
841 - return;
842 - }
843 -
844 - $key = self::SESSION_SEEN_TRANSIENT_PREFIX . $jti;
845 - $seen = get_transient( $key );
846 -
847 - // The throttle reads the transient, never the session row: this runs on every
848 - // authenticated request, and the row is the one thing this path must not touch.
849 - if ( is_numeric( $seen ) && time() - (int) $seen < self::SESSION_ACTIVITY_REFRESH_SECONDS ) {
850 - return;
851 - }
852 -
853 - // The TTL IS the idle window, so a missing transient means "not seen in a week".
854 - set_transient( $key, time(), self::SESSION_EVICTION_IDLE_SECONDS );
855 - }
856 -
857 - /**
858 - * Forget a session's recorded activity.
859 - *
860 - * @param string $jti Refresh token JTI (session identifier).
861 - */
862 - private function forget_session_activity( string $jti ): void {
863 - if ( '' !== $jti ) {
864 - delete_transient( self::SESSION_SEEN_TRANSIENT_PREFIX . $jti );
865 - }
866 - }
867 -
868 - /**
869 744 * Check if the current user can manage sessions for the target user.
870 745 *
871 746 * @param int $target_user_id The target user ID.
872 747 *
@@ -925,11 +800,10 @@
925 800 *
926 801 * @return bool
927 802 */
928 803 public function revoke_session_with_blacklist( int $user_id, string $refresh_jti ): bool {
929 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
930 - $session_data = \is_array( $refresh_tokens ) && isset( $refresh_tokens[ $refresh_jti ] ) ? $refresh_tokens[ $refresh_jti ] : array();
931 - $ttl = $this->get_access_token_blacklist_ttl( $session_data );
804 + $session_data = $this->sessions->entry( $user_id, $refresh_jti );
805 + $ttl = $this->get_access_token_blacklist_ttl( $session_data );
932 806
933 807 // Revoke the refresh token (session) from user meta.
934 808 $revoked = $this->revoke_session( $user_id, $refresh_jti );
935 809
@@ -942,233 +816,8 @@
942 816 return $revoked;
943 817 }
944 818
945 819 /**
946 - * Store refresh token JTI for tracking/revocation.
947 - *
948 - * @param int $user_id The user ID.
949 - * @param string $jti The token JTI.
950 - * @param int $expires The expiration timestamp.
951 - * @param null|Session_Context $context Request state the session is recorded
952 - * against. Defaults to the current request.
953 - */
954 - private function store_refresh_token_jti( int $user_id, string $jti, int $expires, ?Session_Context $context = null ): void {
955 - $context = null === $context ? Session_Context::from_request() : $context;
956 -
957 - // BEFORE the read: a pre-cap row can be too large to load, and this is the first
958 - // point in the login flow where WCPOS knows the user id.
959 - $this->discard_oversized_session_row( $user_id );
960 -
961 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
962 - if ( ! \is_array( $refresh_tokens ) ) {
963 - $refresh_tokens = array();
964 - }
965 -
966 - // Clean up expired tokens.
967 - $refresh_tokens = array_filter(
968 - $refresh_tokens,
969 - function ( $token ) {
970 - return $token['expires'] > time();
971 - }
972 - );
973 -
974 - // Capture session metadata.
975 - $current_time = time();
976 - $ip_address = $context->get_ip();
977 - $user_agent = $context->get_user_agent();
978 - $device_info = $this->parse_user_agent( $user_agent );
979 -
980 - // Check for explicit platform declaration from native apps (passed as a param in the auth request).
981 - $platform = $context->get_platform();
982 - $version = $context->get_version();
983 - $build = $context->get_build();
984 -
985 - // Override app_type if platform was explicitly provided by the client.
986 - if ( \in_array( $platform, array( 'ios', 'android', 'electron', 'web' ), true ) ) {
987 - $device_info['app_type'] = 'web' === $platform ? 'web' : $platform . '_app';
988 -
989 - // Set appropriate device type based on platform.
990 - if ( 'ios' === $platform || 'android' === $platform ) {
991 - $device_info['device_type'] = 'tablet'; // Default to tablet for mobile apps.
992 - } elseif ( 'electron' === $platform ) {
993 - $device_info['device_type'] = 'desktop';
994 - }
995 -
996 - // Use version from param if provided.
997 - if ( ! empty( $version ) ) {
998 - $device_info['browser_version'] = $version;
999 - }
1000 -
1001 - // Store build number if provided.
1002 - if ( ! empty( $build ) ) {
1003 - $device_info['build'] = $build;
1004 - }
1005 -
1006 - // Set browser to WooCommerce POS for native apps.
1007 - if ( 'web' !== $platform ) {
1008 - $device_info['browser'] = 'WooCommerce POS';
1009 - }
1010 - }
1011 -
1012 - // Add new token with metadata.
1013 - $refresh_tokens[ $jti ] = array(
1014 - 'expires' => $expires,
1015 - 'created' => $current_time,
1016 - 'last_active' => $current_time,
1017 - 'ip_address' => $ip_address,
1018 - 'user_agent' => $user_agent,
1019 - 'device_info' => $device_info,
1020 - );
1021 -
1022 - // Cap the number of stored sessions so programmatic clients cannot grow the row without bound.
1023 - $refresh_tokens = $this->evict_oldest_sessions( $refresh_tokens, $jti );
1024 -
1025 - update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
1026 - }
1027 -
1028 - /**
1029 - * Drop the least recently active sessions until the per-user cap is met.
1030 - *
1031 - * Evicted sessions are blacklisted the same way revoke_all_sessions_except() does, so the
1032 - * device that lost its slot is cleanly logged out instead of keeping a working access token
1033 - * for the remainder of that token's life.
1034 - *
1035 - * @param array $refresh_tokens Stored sessions keyed by refresh token JTI.
1036 - * @param string $protected_jti JTI that must never be evicted (the session being stored).
1037 - *
1038 - * @return array The sessions to persist.
1039 - */
1040 - private function evict_oldest_sessions( array $refresh_tokens, string $protected_jti ): array {
1041 - $evict_count = \count( $refresh_tokens ) - self::MAX_SESSIONS_PER_USER;
1042 - if ( $evict_count <= 0 ) {
1043 - return $refresh_tokens;
1044 - }
1045 -
1046 - $issued_at = time();
1047 - $idle_before = $issued_at - self::SESSION_EVICTION_IDLE_SECONDS;
1048 -
1049 - /*
1050 - * Order eviction candidates oldest-first. The insertion index breaks ties explicitly
1051 - * because usort() is not stable before PHP 8.0 and bulk logins share a timestamp.
1052 - *
1053 - * A session seen within SESSION_EVICTION_IDLE_SECONDS is NOT a candidate at any
1054 - * count. Being the oldest of N says nothing about being unused when N sessions were
1055 - * minted in an hour, and evicting a live one blacklists a working device's access
1056 - * token. The cap yields to that: a user whose sessions are all recent keeps them
1057 - * all, and the row stays bounded by MAX_SESSIONS_ROW_BYTES instead.
1058 - */
1059 - $candidates = array();
1060 - $index = 0;
1061 - foreach ( $refresh_tokens as $candidate_jti => $token_data ) {
1062 - $position = $index++;
1063 - if ( (string) $candidate_jti === $protected_jti ) {
1064 - continue;
1065 - }
1066 -
1067 - // The ROW timestamp is the cheap filter. It is authoritative when it says a
1068 - // session is live, because login and refresh both write it; when it says idle
1069 - // the activity transient still gets the final word, below.
1070 - $activity = $this->session_row_last_seen( $token_data );
1071 - if ( $activity > $idle_before ) {
1072 - continue;
1073 - }
1074 -
1075 - $candidates[] = array(
1076 - 'jti' => (string) $candidate_jti,
1077 - 'activity' => $activity,
1078 - 'index' => $position,
1079 - );
1080 - }
1081 -
1082 - usort(
1083 - $candidates,
1084 - function ( $a, $b ) {
1085 - if ( $a['activity'] === $b['activity'] ) {
1086 - return $a['index'] <=> $b['index'];
1087 - }
1088 -
1089 - return $a['activity'] <=> $b['activity'];
1090 - }
1091 - );
1092 -
1093 - foreach ( $candidates as $candidate ) {
1094 - if ( $evict_count <= 0 ) {
1095 - break;
1096 - }
1097 -
1098 - // Checked only for rows already stale, so this costs a handful of transient
1099 - // reads rather than one per stored session.
1100 - if ( $this->session_last_seen( $candidate['jti'], $refresh_tokens[ $candidate['jti'] ] ) > $idle_before ) {
1101 - continue;
1102 - }
1103 -
1104 - /*
1105 - * Blacklist ONLY a session that can still hold a live access token. An eviction
1106 - * is not a revoke: clearing a bloated row can drop thousands of long-dead
1107 - * sessions at once, and a transient for each would guard nothing — an expired
1108 - * access token is already rejected on its own `exp` claim, and the refresh token
1109 - * dies with the meta entry (`is_refresh_token_valid()` requires the entry). This
1110 - * also bounds each transient this path writes to one access-token lifetime,
1111 - * rather than the refresh-token expiry `get_access_token_blacklist_ttl()` falls
1112 - * back to for a session with no recorded access-token expiry.
1113 - */
1114 - $horizon = $this->access_token_horizon( $refresh_tokens[ $candidate['jti'] ] );
1115 - if ( $horizon > $issued_at ) {
1116 - $this->blacklist_token( $candidate['jti'], $horizon - $issued_at );
1117 - }
1118 -
1119 - $this->forget_session_activity( $candidate['jti'] );
1120 - unset( $refresh_tokens[ $candidate['jti'] ] );
1121 - --$evict_count;
1122 - }
1123 -
1124 - return $refresh_tokens;
1125 - }
1126 -
1127 - /**
1128 - * When a session was last seen, taking the later of the row and the activity record.
1129 - *
1130 - * The row is rewritten by login and refresh; the transient is written by ordinary
1131 - * authenticated requests. Neither alone is the whole picture — a device working through
1132 - * a long-lived access token has an old row timestamp and a fresh transient, and a
1133 - * session that has not been used at all has the reverse.
1134 - *
1135 - * @param string $jti Refresh token JTI (session identifier).
1136 - * @param array $token_data Stored session record.
1137 - *
1138 - * @return int Unix timestamp; 0 when neither source carries a usable timestamp.
1139 - */
1140 - private function session_last_seen( string $jti, array $token_data ): int {
1141 - $row_seen = $this->session_row_last_seen( $token_data );
1142 - $seen = '' === $jti ? false : get_transient( self::SESSION_SEEN_TRANSIENT_PREFIX . $jti );
1143 -
1144 - return is_numeric( $seen ) ? max( $row_seen, (int) $seen ) : $row_seen;
1145 - }
1146 -
1147 - /**
1148 - * When the stored record itself says a session was last seen.
1149 - *
1150 - * Login and refresh both rewrite `last_active` in the row, so this stays accurate for
1151 - * everything except the stretch between refreshes — which is what the activity
1152 - * transient covers.
1153 - *
1154 - * @param array $token_data Stored session record.
1155 - *
1156 - * @return int Unix timestamp; 0 when the record carries no usable timestamp.
1157 - */
1158 - private function session_row_last_seen( array $token_data ): int {
1159 - if ( isset( $token_data['last_active'] ) ) {
1160 - return (int) $token_data['last_active'];
1161 - }
1162 -
1163 - if ( isset( $token_data['created'] ) ) {
1164 - return (int) $token_data['created'];
1165 - }
1166 -
1167 - return 0;
1168 - }
1169 -
1170 - /**
1171 820 * The last moment an access token minted against a session can still validate.
1172 821 *
1173 822 * @param array $token_data Stored session record.
1174 823 *
@@ -1181,71 +830,14 @@
1181 830
1182 831 // Rows written before `access_expires` was recorded. The newest access token such a
1183 832 // session can hold was minted no later than its last recorded activity, so one
1184 833 // access-token lifetime past that moment is the outside limit.
1185 - $last_seen = $this->session_row_last_seen( $token_data );
834 + $last_seen = (int) ( $token_data['last_active'] ?? $token_data['created'] ?? 0 );
1186 835
1187 836 return $last_seen > 0 ? $this->get_access_token_expire( $last_seen ) : 0;
1188 837 }
1189 838
1190 839 /**
1191 - * Drop the stored session row when it is too large to be read safely.
1192 - *
1193 - * A LAST RESORT, not a tidy-up: discarding the row signs every one of that user's
1194 - * devices out at once, so the ceiling is set above anything measured to be readable
1195 - * (see MAX_SESSIONS_ROW_BYTES) and everything below it is TRIMMED by
1196 - * `evict_oldest_sessions()` on the same write instead. What this catches is the one
1197 - * case trimming cannot: a row so large that reading it exhausts the request before any
1198 - * of the code below runs, which — because that read happens on every login — locks the
1199 - * user out permanently (#1776). `LENGTH()` lets MySQL answer with a number instead of
1200 - * the value, so the size is checked without paying for the row.
1201 - *
1202 - * @param int $user_id The user ID.
1203 - */
1204 - private function discard_oversized_session_row( int $user_id ): void {
1205 - global $wpdb;
1206 -
1207 - $rows = $wpdb->get_results(
1208 - $wpdb->prepare(
1209 - "SELECT umeta_id, LENGTH(meta_value) AS meta_bytes FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s",
1210 - $user_id,
1211 - '_woocommerce_pos_refresh_tokens'
1212 - )
1213 - );
1214 -
1215 - if ( empty( $rows ) ) {
1216 - return;
1217 - }
1218 -
1219 - $bytes = 0;
1220 - foreach ( $rows as $row ) {
1221 - $bytes += (int) $row->meta_bytes;
1222 - }
1223 -
1224 - if ( $bytes <= self::MAX_SESSIONS_ROW_BYTES ) {
1225 - return;
1226 - }
1227 -
1228 - foreach ( $rows as $row ) {
1229 - $wpdb->delete( $wpdb->usermeta, array( 'umeta_id' => (int) $row->umeta_id ), array( '%d' ) );
1230 - }
1231 -
1232 - // The row may already be sitting in the user's meta cache from an earlier
1233 - // `get_user_meta()` in this request; without this the next read serves the value
1234 - // that was just deleted.
1235 - wp_cache_delete( $user_id, 'user_meta' );
1236 -
1237 - Logger::warning(
1238 - sprintf(
1239 - 'Discarded an unreadable WCPOS session row for user %d (%d bytes, ceiling %d). The row was too large to load safely, so every POS session for this user has been logged out once; it is rebuilt, capped, on this login.',
1240 - $user_id,
1241 - $bytes,
1242 - self::MAX_SESSIONS_ROW_BYTES
1243 - )
1244 - );
1245 - }
1246 -
1247 - /**
1248 840 * Filters the JWT access token expire time.
1249 841 * Default: 30 minutes for access tokens.
1250 842 *
1251 843 * @param int $issued_at Token issued timestamp.
@@ -1296,37 +888,8 @@
1296 888 return null;
1297 889 }
1298 890
1299 891 /**
1300 - * Record the latest access token expiry linked to a refresh-token session.
1301 - *
1302 - * @param int $user_id The user ID.
1303 - * @param string $refresh_jti Refresh token JTI.
1304 - * @param int $access_expires Access token expiry timestamp.
1305 - *
1306 - * @return bool
1307 - */
1308 - private function store_access_token_expiry( int $user_id, string $refresh_jti, int $access_expires ): bool {
1309 - if ( empty( $refresh_jti ) || $access_expires <= 0 ) {
1310 - return false;
1311 - }
1312 -
1313 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
1314 - if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $refresh_jti ] ) ) {
1315 - return false;
1316 - }
1317 -
1318 - $current_access_expires = isset( $refresh_tokens[ $refresh_jti ]['access_expires'] ) ? (int) $refresh_tokens[ $refresh_jti ]['access_expires'] : 0;
1319 - if ( $access_expires <= $current_access_expires ) {
1320 - return true;
1321 - }
1322 -
1323 - $refresh_tokens[ $refresh_jti ]['access_expires'] = $access_expires;
1324 -
1325 - return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
1326 - }
1327 -
1328 - /**
1329 892 * Calculate blacklist TTL for a session.
1330 893 *
1331 894 * @param array $session_data Session metadata.
1332 895 * @param null|int $issued_at Current timestamp.
@@ -1348,139 +911,8 @@
1348 911 $access_expire = max( $access_expire, (int) $session_data['expires'] );
1349 912 }
1350 913
1351 914 return max( 0, $access_expire - $issued_at );
1352 - }
1353 -
1354 - /**
1355 - * Check if refresh token is still valid (not revoked).
1356 - *
1357 - * @param int $user_id The user ID.
1358 - * @param string $jti The token JTI.
1359 - *
1360 - * @return bool
1361 - */
1362 - private function is_refresh_token_valid( int $user_id, string $jti ): bool {
1363 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
1364 - if ( ! \is_array( $refresh_tokens ) ) {
1365 - return false;
1366 - }
1367 -
1368 - return isset( $refresh_tokens[ $jti ] ) && $refresh_tokens[ $jti ]['expires'] > time();
1369 - }
1370 -
1371 - /**
1372 - * Parse user agent string to extract device information.
1373 - *
1374 - * @param string $user_agent The user agent string.
1375 - *
1376 - * @return array
1377 - */
1378 - private function parse_user_agent( string $user_agent ): array {
1379 - $device_info = array(
1380 - 'device_type' => 'unknown',
1381 - 'browser' => 'unknown',
1382 - 'browser_version' => '',
1383 - 'os' => 'unknown',
1384 - 'app_type' => 'web', // web, ios_app, android_app, electron_app.
1385 - );
1386 -
1387 - if ( empty( $user_agent ) ) {
1388 - return $device_info;
1389 - }
1390 -
1391 - // Detect WooCommerce POS apps first (custom identifiers)
1392 - // Check for Electron app (including just "WooCommercePOS" in user agent with Electron).
1393 - if ( preg_match( '/Electron/i', $user_agent ) && preg_match( '/WooCommercePOS|WCPOS/i', $user_agent ) ) {
1394 - $device_info['app_type'] = 'electron_app';
1395 - $device_info['browser'] = 'WooCommerce POS';
1396 - $device_info['device_type'] = 'desktop';
1397 - // Try to extract WooCommercePOS version.
1398 - if ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1399 - $device_info['browser_version'] = $matches[1];
1400 - } elseif ( preg_match( '/WCPOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1401 - $device_info['browser_version'] = $matches[1];
1402 - }
1403 - } elseif ( preg_match( '/WCPOS[-_]?iOS|WooCommercePOS[-_]?iOS/i', $user_agent ) ) {
1404 - $device_info['app_type'] = 'ios_app';
1405 - $device_info['browser'] = 'WooCommerce POS';
1406 - // Default to tablet unless explicitly detected as phone.
1407 - $device_info['device_type'] = preg_match( '/iphone|ipod/i', $user_agent ) ? 'mobile' : 'tablet';
1408 - if ( preg_match( '/WCPOS[-_]?iOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1409 - $device_info['browser_version'] = $matches[1];
1410 - } elseif ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1411 - $device_info['browser_version'] = $matches[1];
1412 - }
1413 - } elseif ( preg_match( '/WCPOS[-_]?Android|WooCommercePOS[-_]?Android/i', $user_agent ) ) {
1414 - $device_info['app_type'] = 'android_app';
1415 - $device_info['browser'] = 'WooCommerce POS';
1416 - // Default to tablet unless explicitly detected as mobile.
1417 - $device_info['device_type'] = preg_match( '/mobile/i', $user_agent ) && ! preg_match( '/tablet/i', $user_agent ) ? 'mobile' : 'tablet';
1418 - if ( preg_match( '/WCPOS[-_]?Android[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1419 - $device_info['browser_version'] = $matches[1];
1420 - } elseif ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1421 - $device_info['browser_version'] = $matches[1];
1422 - }
1423 - }
1424 -
1425 - // Detect standard device type (if not already set by app detection).
1426 - if ( 'web' === $device_info['app_type'] ) {
1427 - if ( preg_match( '/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/i', $user_agent ) ) {
1428 - $device_info['device_type'] = 'mobile';
1429 - } elseif ( preg_match( '/tablet|ipad|playbook|silk/i', $user_agent ) ) {
1430 - $device_info['device_type'] = 'tablet';
1431 - } else {
1432 - $device_info['device_type'] = 'desktop';
1433 - }
1434 - }
1435 -
1436 - // Detect browser (skip if we already detected a WCPOS app).
1437 - if ( 'WooCommerce POS' !== $device_info['browser'] ) {
1438 - if ( preg_match( '/MSIE|Trident/i', $user_agent ) ) {
1439 - $device_info['browser'] = 'Internet Explorer';
1440 - if ( preg_match( '/MSIE ([0-9.]+)/', $user_agent, $matches ) ) {
1441 - $device_info['browser_version'] = $matches[1];
1442 - }
1443 - } elseif ( preg_match( '/Edge\/([0-9.]+)/i', $user_agent, $matches ) ) {
1444 - $device_info['browser'] = 'Edge';
1445 - $device_info['browser_version'] = $matches[1];
1446 - } elseif ( preg_match( '/Edg\/([0-9.]+)/i', $user_agent, $matches ) ) {
1447 - $device_info['browser'] = 'Edge';
1448 - $device_info['browser_version'] = $matches[1];
1449 - } elseif ( preg_match( '/Firefox\/([0-9.]+)/i', $user_agent, $matches ) ) {
1450 - $device_info['browser'] = 'Firefox';
1451 - $device_info['browser_version'] = $matches[1];
1452 - } elseif ( preg_match( '/Chrome\/([0-9.]+)/i', $user_agent, $matches ) ) {
1453 - $device_info['browser'] = 'Chrome';
1454 - $device_info['browser_version'] = $matches[1];
1455 - } elseif ( preg_match( '/Safari\/([0-9.]+)/i', $user_agent, $matches ) ) {
1456 - // Safari should be checked after Chrome because Chrome also contains Safari.
1457 - if ( ! preg_match( '/Chrome/i', $user_agent ) ) {
1458 - $device_info['browser'] = 'Safari';
1459 - $device_info['browser_version'] = $matches[1];
1460 - }
1461 - } elseif ( preg_match( '/Opera\/([0-9.]+)/i', $user_agent, $matches ) ) {
1462 - $device_info['browser'] = 'Opera';
1463 - $device_info['browser_version'] = $matches[1];
1464 - }
1465 - }
1466 -
1467 - // Detect OS.
1468 - if ( preg_match( '/Windows NT ([0-9.]+)/i', $user_agent, $matches ) ) {
1469 - $device_info['os'] = 'Windows';
1470 - } elseif ( preg_match( '/Mac OS X ([0-9_]+)/i', $user_agent, $matches ) ) {
1471 - $device_info['os'] = 'macOS';
1472 - } elseif ( preg_match( '/Android ([0-9.]+)/i', $user_agent, $matches ) ) {
1473 - $device_info['os'] = 'Android';
1474 - } elseif ( preg_match( '/iPhone OS ([0-9_]+)/i', $user_agent, $matches ) ) {
1475 - $device_info['os'] = 'iOS';
1476 - } elseif ( preg_match( '/iPad.*OS ([0-9_]+)/i', $user_agent, $matches ) ) {
1477 - $device_info['os'] = 'iPadOS';
1478 - } elseif ( preg_match( '/Linux/i', $user_agent ) ) {
1479 - $device_info['os'] = 'Linux';
1480 - }
1481 -
1482 - return $device_info;
1483 915 }
1484 916
1485 917 /**
1486 918 * Check if a token JTI is blacklisted.