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 +362 -6 1.10.3 → 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,8 +22,77 @@
20 22 * Auth Service class.
21 23 */
22 24 class Auth {
23 25 /**
26 + * Maximum number of refresh-token sessions retained per user.
27 + *
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.
38 + */
39 + public const MAX_SESSIONS_PER_USER = 200;
40 +
41 + /**
42 + * How long a session must have gone unseen before eviction may remove it.
43 + *
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.
51 + */
52 + public const SESSION_EVICTION_IDLE_SECONDS = 7 * DAY_IN_SECONDS;
53 +
54 + /**
55 + * How stale a session's `last_active` may get before an authenticated request rewrites it.
56 + *
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.
62 + */
63 + private const SESSION_ACTIVITY_REFRESH_SECONDS = 5 * MINUTE_IN_SECONDS;
64 +
65 + /**
66 + * Transient prefix for the per-session "last seen" record.
67 + *
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.
76 + */
77 + private const SESSION_SEEN_TRANSIENT_PREFIX = 'wcpos_session_seen_';
78 +
79 + /**
80 + * Byte ceiling on the stored session row before it is discarded UNREAD.
81 + *
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.
91 + */
92 + public const MAX_SESSIONS_ROW_BYTES = 6291456;
93 +
94 + /**
24 95 * The single instance of the class.
25 96 *
26 97 * @var null|Auth
27 98 */
@@ -211,8 +282,17 @@
211 282 'Session has been revoked',
212 283 array( 'status' => 403 )
213 284 );
214 285 }
286 +
287 + // The session is live: record that, so eviction can tell a device that is
288 + // working right now from one that has not been seen in a week.
289 + if ( isset( $decoded_token->refresh_jti ) ) {
290 + $this->touch_session_activity(
291 + absint( $decoded_token->data->user->id ),
292 + (string) $decoded_token->refresh_jti
293 + );
294 + }
215 295 }
216 296
217 297 // Everything looks good return the decoded token.
218 298 return $decoded_token;
@@ -467,14 +547,10 @@
467 547 'last_name' => $user->user_lastname,
468 548 'nice_name' => $user->user_nicename,
469 549 'display_name' => $user->display_name,
470 550 'roles' => array_values( $user->roles ),
471 - // Raw grants (role + user), the same vocabulary the POS Access settings
472 - // screen reads and writes. user_can() is wrong here: the singular meta
473 - // caps (edit_product, delete_product) cannot be checked without a post.
474 - 'capabilities' => array_values(
475 - array_filter( Access_Section::capability_names(), fn( $cap ) => ! empty( $user->allcaps[ $cap ] ) )
476 - ),
551 + // The helper reports effective grants, including role-editor denies.
552 + 'capabilities' => Access_Section::effective_capabilities( $user ),
477 553 'avatar_url' => get_avatar_url( $user->ID ),
478 554 // Token data.
479 555 'access_token' => $tokens['access_token'],
480 556 'refresh_token' => $tokens['refresh_token'],
@@ -521,8 +597,16 @@
521 597 if ( is_wp_error( $decoded ) ) {
522 598 return $decoded;
523 599 }
524 600
601 + /*
602 + * Before the first row read on this path. A refresh loads the whole session row —
603 + * `is_refresh_token_valid()` below, then `update_session_activity()` — so it needs
604 + * the same protection a login has against a row too large to read (#1776).
605 + * Validating an ACCESS token needs no such guard: it no longer touches the row.
606 + */
607 + $this->discard_oversized_session_row( absint( $decoded->data->user->id ) );
608 +
525 609 // Check if refresh token is still valid (not revoked).
526 610 if ( ! $this->is_refresh_token_valid( $decoded->data->user->id, $decoded->jti ?? '' ) ) {
527 611 return new WP_Error(
528 612 'woocommerce_pos_auth_refresh_token_revoked',
@@ -572,8 +656,9 @@
572 656
573 657 if ( isset( $refresh_tokens[ $jti ] ) ) {
574 658 unset( $refresh_tokens[ $jti ] );
575 659 update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
660 + $this->forget_session_activity( $jti );
576 661
577 662 return true;
578 663 }
579 664
@@ -604,8 +689,9 @@
604 689
605 690 foreach ( $refresh_tokens as $jti => $token_data ) {
606 691 $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
607 692 $this->blacklist_token( $jti, $ttl );
693 + $this->forget_session_activity( (string) $jti );
608 694 }
609 695 }
610 696
611 697 return delete_user_meta( $user_id, '_woocommerce_pos_refresh_tokens' );
@@ -696,8 +782,9 @@
696 782 foreach ( $refresh_tokens as $jti => $token_data ) {
697 783 if ( $jti !== $current_jti ) {
698 784 $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
699 785 $this->blacklist_token( $jti, $ttl );
786 + $this->forget_session_activity( (string) $jti );
700 787 }
701 788 }
702 789
703 790 // Keep only the current session in user meta.
@@ -720,8 +807,11 @@
720 807 *
721 808 * @return bool
722 809 */
723 810 public function update_session_activity( int $user_id, string $jti ): bool {
811 + // Public surface: any caller reaching the row goes through the size guard first.
812 + $this->discard_oversized_session_row( $user_id );
813 +
724 814 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
725 815 if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $jti ] ) ) {
726 816 return false;
727 817 }
@@ -731,8 +821,48 @@
731 821 return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
732 822 }
733 823
734 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 + /**
735 865 * Check if the current user can manage sessions for the target user.
736 866 *
737 867 * @param int $target_user_id The target user ID.
738 868 *
@@ -819,8 +949,12 @@
819 949 */
820 950 private function store_refresh_token_jti( int $user_id, string $jti, int $expires, ?Session_Context $context = null ): void {
821 951 $context = null === $context ? Session_Context::from_request() : $context;
822 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 +
823 957 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
824 958 if ( ! \is_array( $refresh_tokens ) ) {
825 959 $refresh_tokens = array();
826 960 }
@@ -880,9 +1014,231 @@
880 1014 'user_agent' => $user_agent,
881 1015 'device_info' => $device_info,
882 1016 );
883 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 +
884 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 + /**
1167 + * The last moment an access token minted against a session can still validate.
1168 + *
1169 + * @param array $token_data Stored session record.
1170 + *
1171 + * @return int Unix timestamp; 0 when the session carries no usable timestamp at all.
1172 + */
1173 + private function access_token_horizon( array $token_data ): int {
1174 + if ( isset( $token_data['access_expires'] ) ) {
1175 + return (int) $token_data['access_expires'];
1176 + }
1177 +
1178 + // Rows written before `access_expires` was recorded. The newest access token such a
1179 + // session can hold was minted no later than its last recorded activity, so one
1180 + // access-token lifetime past that moment is the outside limit.
1181 + $last_seen = $this->session_row_last_seen( $token_data );
1182 +
1183 + return $last_seen > 0 ? $this->get_access_token_expire( $last_seen ) : 0;
1184 + }
1185 +
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 + );
885 1241 }
886 1242
887 1243 /**
888 1244 * Filters the JWT access token expire time.