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