PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.22
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.22
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 1.10.1 1.10.0 1.9.17 All 166 releases
woocommerce-pos / includes / Services / Session_Registry.php

Session_Registry.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.22, at includes/Services/Session_Registry.php

771 lines 26.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Session registry.
4 *
5 * @package WCPOS\WooCommercePOS
6 */
7
8 namespace WCPOS\WooCommercePOS\Services;
9
10 use WCPOS\WooCommercePOS\Logger;
11 use const DAY_IN_SECONDS;
12 use const MINUTE_IN_SECONDS;
13
14 /**
15 * Stored sessions and their activity records.
16 */
17 final class Session_Registry {
18 /**
19 * User meta key for refresh-token sessions.
20 */
21 public const META_KEY = '_woocommerce_pos_refresh_tokens';
22
23 /**
24 * Maximum number of refresh-token sessions retained per user.
25 *
26 * Refresh tokens live for weeks and every entry carries a user agent plus parsed
27 * device info, so without a cap the `_woocommerce_pos_refresh_tokens` row grows until
28 * `get_user_meta()` can no longer unserialize it inside the PHP memory limit.
29 *
30 * This is a ceiling on ACCUMULATED CLUTTER, never a limit on how many devices may be
31 * signed in at once: `evict_oldest_sessions()` only ever removes sessions that have
32 * been idle for SESSION_EVICTION_IDLE_SECONDS, and lets the count exceed this number
33 * rather than log a live device out. Two hundred covers a large merchant's real
34 * devices with room to spare, and 200 entries serialize to roughly a hundred
35 * kilobytes.
36 */
37 public const MAX_SESSIONS_PER_USER = 200;
38
39 /**
40 * How long a session must have gone unseen before eviction may remove it.
41 *
42 * The cap alone is not a safe eviction rule. A client that authenticates
43 * programmatically mints sessions far faster than a merchant does, so "the oldest of
44 * N" can be a session created minutes ago and still in use — and evicting it
45 * blacklists its access token, logging a working device out mid-request. That is
46 * exactly what happened on the shared E2E cashier after #1798 shipped a 50-session
47 * cap. A week of silence is a long time for a till: a device seen inside that window
48 * is treated as live and is never a candidate, whatever the count.
49 */
50 public const SESSION_EVICTION_IDLE_SECONDS = 7 * DAY_IN_SECONDS;
51
52 /**
53 * How stale a session's `last_active` may get before an authenticated request rewrites it.
54 *
55 * `last_active` decides what eviction may touch, so it has to reflect USE, not just
56 * token refreshes — before this, only `refresh_access_token()` moved it, and a device
57 * happily working through a 30-minute access token looked idle the whole time. Every
58 * authenticated request now refreshes it, throttled to one write per session per five
59 * minutes so the POS's request volume does not turn into a write per call.
60 */
61 private const SESSION_ACTIVITY_REFRESH_SECONDS = 5 * MINUTE_IN_SECONDS;
62
63 /**
64 * Transient prefix for the per-session "last seen" record.
65 *
66 * Activity is recorded OUTSIDE the session row on purpose. Writing it into the row
67 * meant every authenticated request did a read-modify-write of the whole
68 * `_woocommerce_pos_refresh_tokens` array, which is neither atomic nor cheap: a
69 * request overlapping a login, logout or revoke for the same user could write back a
70 * stale copy and erase the concurrent change — losing a session that had just been
71 * issued, so the new client worked until its access token expired and was then refused
72 * a refresh. Four parallel E2E shards on one cashier do exactly that. A per-session key
73 * cannot collide with another session's write, and reading it costs no row load at all.
74 */
75 private const SESSION_SEEN_TRANSIENT_PREFIX = 'wcpos_session_seen_';
76
77 /**
78 * Byte ceiling on the stored session row before it is discarded UNREAD.
79 *
80 * This is a LAST RESORT for a row no longer safe to load, not a tidy-up threshold —
81 * discarding it signs every one of that user's devices out at once. The bar is set
82 * from measurement rather than caution: a 9,216,730-byte row (17,000 sessions) read
83 * fine under the 128 MB limit that produced the #1776 fatal — `get_user_meta()` cost
84 * ~26 MB to fetch and ~38 MB with the unserialize, and it was the WRITE-BACK, at ~42
85 * MB more, that exhausted the request. Six megabytes therefore sits below anything
86 * measured to be unreadable while still catching a row heading for that fatal. The
87 * first release of this guard used one megabyte, which is comfortably readable and
88 * threw away rows that eviction could simply have trimmed.
89 */
90 public const MAX_SESSIONS_ROW_BYTES = 6291456;
91
92 /**
93 * Read the stored session map without guarding or modifying it.
94 *
95 * @param int $user_id The user ID.
96 *
97 * @return array
98 */
99 public function entries( int $user_id ): array {
100 return $this->read_row( $user_id );
101 }
102
103 /**
104 * Read one stored session.
105 *
106 * @param int $user_id The user ID.
107 * @param string $jti Refresh token JTI.
108 *
109 * @return array
110 */
111 public function entry( int $user_id, string $jti ): array {
112 return $this->entries( $user_id )[ $jti ] ?? array();
113 }
114
115 /**
116 * Store refresh token JTI for tracking/revocation.
117 *
118 * @param int $user_id The user ID.
119 * @param string $jti The token JTI.
120 * @param int $expires The expiration timestamp.
121 * @param Session_Context $context Request state the session is recorded against.
122 *
123 * @return array Evicted entries keyed by refresh token JTI.
124 */
125 public function record( int $user_id, string $jti, int $expires, Session_Context $context ): array {
126 // BEFORE the read: a pre-cap row can be too large to load, and this is the first
127 // point in the login flow where WCPOS knows the user id.
128 $this->discard_oversized_row( $user_id );
129
130 $refresh_tokens = $this->read_row( $user_id );
131
132 // Clean up expired tokens.
133 $refresh_tokens = array_filter(
134 $refresh_tokens,
135 function ( $token ) {
136 return $token['expires'] > time();
137 }
138 );
139
140 // Capture session metadata.
141 $current_time = time();
142 $ip_address = $context->get_ip();
143 $user_agent = $context->get_user_agent();
144 $device_info = $this->parse_user_agent( $user_agent );
145
146 // Check for explicit platform declaration from native apps (passed as a param in the auth request).
147 $platform = $context->get_platform();
148 $version = $context->get_version();
149 $build = $context->get_build();
150
151 // Override app_type if platform was explicitly provided by the client.
152 if ( \in_array( $platform, array( 'ios', 'android', 'electron', 'web' ), true ) ) {
153 $device_info['app_type'] = 'web' === $platform ? 'web' : $platform . '_app';
154
155 // Set appropriate device type based on platform.
156 if ( 'ios' === $platform || 'android' === $platform ) {
157 $device_info['device_type'] = 'tablet'; // Default to tablet for mobile apps.
158 } elseif ( 'electron' === $platform ) {
159 $device_info['device_type'] = 'desktop';
160 }
161
162 // Use version from param if provided.
163 if ( ! empty( $version ) ) {
164 $device_info['browser_version'] = $version;
165 }
166
167 // Store build number if provided.
168 if ( ! empty( $build ) ) {
169 $device_info['build'] = $build;
170 }
171
172 // Set browser to WooCommerce POS for native apps.
173 if ( 'web' !== $platform ) {
174 $device_info['browser'] = 'WooCommerce POS';
175 }
176 }
177
178 // Add new token with metadata.
179 $refresh_tokens[ $jti ] = array(
180 'expires' => $expires,
181 'created' => $current_time,
182 'last_active' => $current_time,
183 'ip_address' => $ip_address,
184 'user_agent' => $user_agent,
185 'device_info' => $device_info,
186 );
187
188 // Cap the number of stored sessions so programmatic clients cannot grow the row without bound.
189 $evicted = $this->evict_oldest_sessions( $refresh_tokens, $jti );
190
191 update_user_meta( $user_id, self::META_KEY, $refresh_tokens );
192
193 return $evicted;
194 }
195
196 /**
197 * Get all active sessions for a user.
198 *
199 * @param int $user_id The user ID.
200 *
201 * @return array
202 */
203 public function list( int $user_id ): array {
204 $refresh_tokens = $this->read_row( $user_id );
205 if ( empty( $refresh_tokens ) ) {
206 return array();
207 }
208
209 $sessions = array();
210 $current_time = time();
211
212 foreach ( $refresh_tokens as $jti => $token_data ) {
213 // Skip expired sessions.
214 if ( $token_data['expires'] <= $current_time ) {
215 continue;
216 }
217
218 $sessions[] = array(
219 'jti' => $jti,
220 'created' => $token_data['created'] ?? $current_time,
221 'last_active' => $token_data['last_active'] ?? $token_data['created'] ?? $current_time,
222 'expires' => $token_data['expires'],
223 'ip_address' => $token_data['ip_address'] ?? '',
224 'user_agent' => $token_data['user_agent'] ?? '',
225 'device_info' => $token_data['device_info'] ?? array(),
226 );
227 }
228
229 // Sort by last_active descending (most recent first).
230 usort(
231 $sessions,
232 function ( $a, $b ) {
233 return $b['last_active'] - $a['last_active'];
234 }
235 );
236
237 return $sessions;
238 }
239
240 /**
241 * Check if refresh token is still valid (not revoked).
242 *
243 * @param int $user_id The user ID.
244 * @param string $jti The token JTI.
245 *
246 * @return bool
247 */
248 public function is_live( int $user_id, string $jti ): bool {
249 $row = $this->read_row( $user_id );
250
251 // read_row() has already skipped malformed entries, which are not live sessions.
252 return isset( $row[ $jti ] ) && (int) $row[ $jti ]['expires'] > time();
253 }
254
255 /**
256 * Refresh a session's `last_active`, at most once every few minutes.
257 *
258 * Called from token validation, so it runs on EVERY authenticated request. The
259 * throttle is what makes that affordable: the value only has to be accurate to within
260 * minutes for a rule that asks whether a session has been unseen for a week, and the
261 * read is already in the user's meta cache by this point.
262 *
263 * @param int $user_id The user ID.
264 * @param string $jti Refresh token JTI (session identifier).
265 */
266 public function touch( int $user_id, string $jti ): void {
267 if ( 0 === $user_id || '' === $jti ) {
268 return;
269 }
270
271 $key = self::SESSION_SEEN_TRANSIENT_PREFIX . $jti;
272 $seen = get_transient( $key );
273
274 // The throttle keeps activity in the transient, out of the session row: this runs
275 // on every authenticated request, and a per-request WRITE to the row would race
276 // logins and revokes.
277 if ( is_numeric( $seen ) && time() - (int) $seen < self::SESSION_ACTIVITY_REFRESH_SECONDS ) {
278 return;
279 }
280
281 // The TTL IS the idle window, so a missing transient means "not seen in a week".
282 set_transient( $key, time(), self::SESSION_EVICTION_IDLE_SECONDS );
283 }
284
285 /**
286 * Update last_active timestamp for a session.
287 *
288 * @param int $user_id The user ID.
289 * @param string $jti The token JTI.
290 *
291 * @return bool
292 */
293 public function refresh_activity( int $user_id, string $jti ): bool {
294 // Public surface: any caller reaching the row goes through the size guard first.
295 $this->discard_oversized_row( $user_id );
296
297 $refresh_tokens = $this->read_row( $user_id );
298 if ( ! isset( $refresh_tokens[ $jti ] ) ) {
299 return false;
300 }
301
302 $refresh_tokens[ $jti ]['last_active'] = time();
303
304 return update_user_meta( $user_id, self::META_KEY, $refresh_tokens );
305 }
306
307 /**
308 * Record the latest access token expiry linked to a refresh-token session.
309 *
310 * @param int $user_id The user ID.
311 * @param string $refresh_jti Refresh token JTI.
312 * @param int $access_expires Access token expiry timestamp.
313 *
314 * @return bool
315 */
316 public function record_access_expiry( int $user_id, string $refresh_jti, int $access_expires ): bool {
317 if ( empty( $refresh_jti ) || $access_expires <= 0 ) {
318 return false;
319 }
320
321 $refresh_tokens = $this->read_row( $user_id );
322 if ( ! isset( $refresh_tokens[ $refresh_jti ] ) ) {
323 return false;
324 }
325
326 $current_access_expires = isset( $refresh_tokens[ $refresh_jti ]['access_expires'] ) ? (int) $refresh_tokens[ $refresh_jti ]['access_expires'] : 0;
327 if ( $access_expires <= $current_access_expires ) {
328 return true;
329 }
330
331 $refresh_tokens[ $refresh_jti ]['access_expires'] = $access_expires;
332
333 return update_user_meta( $user_id, self::META_KEY, $refresh_tokens );
334 }
335
336 /**
337 * Revoke JWT Token by JTI.
338 *
339 * @param int $user_id The user ID.
340 * @param string $jti The token JTI.
341 *
342 * @return bool
343 */
344 public function revoke( int $user_id, string $jti ): bool {
345 $refresh_tokens = get_user_meta( $user_id, self::META_KEY, true );
346 if ( ! \is_array( $refresh_tokens ) ) {
347 return false;
348 }
349
350 if ( isset( $refresh_tokens[ $jti ] ) ) {
351 unset( $refresh_tokens[ $jti ] );
352 update_user_meta( $user_id, self::META_KEY, $refresh_tokens );
353 $this->forget_session_activity( $jti );
354
355 return true;
356 }
357
358 return false;
359 }
360
361 /**
362 * Read the stored sessions, skipping malformed entries.
363 *
364 * An entry that is not an array or has no expiry is not a session, so it is left out
365 * and the valid entries keep their keys. This never writes: a path that already saves
366 * the row saves it without the skipped entries, and a pure read leaves the row alone.
367 *
368 * @param int $user_id The user ID.
369 *
370 * @return array Valid session entries keyed by refresh token JTI.
371 */
372 private function read_row( int $user_id ): array {
373 $row = get_user_meta( $user_id, self::META_KEY, true );
374 if ( ! \is_array( $row ) ) {
375 return array();
376 }
377
378 return array_filter(
379 $row,
380 function ( $entry ) {
381 return \is_array( $entry ) && isset( $entry['expires'] );
382 }
383 );
384 }
385
386 /**
387 * Drop the stored session row when it is too large to be read safely.
388 *
389 * A LAST RESORT, not a tidy-up: discarding the row signs every one of that user's
390 * devices out at once, so the ceiling is set above anything measured to be readable
391 * (see MAX_SESSIONS_ROW_BYTES) and everything below it is TRIMMED by
392 * `evict_oldest_sessions()` on the same write instead. What this catches is the one
393 * case trimming cannot: a row so large that reading it exhausts the request before any
394 * of the code below runs, which — because that read happens on every login — locks the
395 * user out permanently (#1776). `LENGTH()` lets MySQL answer with a number instead of
396 * the value, so the size is checked without paying for the row.
397 *
398 * @param int $user_id The user ID.
399 */
400 private function discard_oversized_row( int $user_id ): void {
401 global $wpdb;
402
403 $rows = $wpdb->get_results(
404 $wpdb->prepare(
405 "SELECT umeta_id, LENGTH(meta_value) AS meta_bytes FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s",
406 $user_id,
407 self::META_KEY
408 )
409 );
410
411 if ( empty( $rows ) ) {
412 return;
413 }
414
415 $bytes = 0;
416 foreach ( $rows as $row ) {
417 $bytes += (int) $row->meta_bytes;
418 }
419
420 if ( $bytes <= self::MAX_SESSIONS_ROW_BYTES ) {
421 return;
422 }
423
424 foreach ( $rows as $row ) {
425 $wpdb->delete( $wpdb->usermeta, array( 'umeta_id' => (int) $row->umeta_id ), array( '%d' ) );
426 }
427
428 // The row may already be sitting in the user's meta cache from an earlier
429 // `get_user_meta()` in this request; without this the next read serves the value
430 // that was just deleted.
431 wp_cache_delete( $user_id, 'user_meta' );
432
433 Logger::warning(
434 sprintf(
435 '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.',
436 $user_id,
437 $bytes,
438 self::MAX_SESSIONS_ROW_BYTES
439 )
440 );
441 }
442
443 /**
444 * Forget a session's recorded activity.
445 *
446 * @param string $jti Refresh token JTI (session identifier).
447 */
448 private function forget_session_activity( string $jti ): void {
449 if ( '' !== $jti ) {
450 delete_transient( self::SESSION_SEEN_TRANSIENT_PREFIX . $jti );
451 }
452 }
453
454 /**
455 * Drop the least recently active sessions until the per-user cap is met.
456 *
457 * Auth blacklists the returned sessions, so the device that lost its slot is cleanly
458 * logged out instead of keeping a working access token for the remainder of its life.
459 *
460 * @param array $refresh_tokens Stored sessions keyed by refresh token JTI.
461 * @param string $protected_jti JTI that must never be evicted (the session being stored).
462 *
463 * @return array Evicted entries keyed by refresh token JTI.
464 */
465 private function evict_oldest_sessions( array &$refresh_tokens, string $protected_jti ): array {
466 $evict_count = \count( $refresh_tokens ) - self::MAX_SESSIONS_PER_USER;
467 if ( $evict_count <= 0 ) {
468 return array();
469 }
470
471 $evicted = array();
472 $issued_at = time();
473 $idle_before = $issued_at - self::SESSION_EVICTION_IDLE_SECONDS;
474
475 /*
476 * Order eviction candidates oldest-first. The insertion index breaks ties explicitly
477 * because usort() is not stable before PHP 8.0 and bulk logins share a timestamp.
478 *
479 * A session seen within SESSION_EVICTION_IDLE_SECONDS is NOT a candidate at any
480 * count. Being the oldest of N says nothing about being unused when N sessions were
481 * minted in an hour, and evicting a live one blacklists a working device's access
482 * token. The cap yields to that: a user whose sessions are all recent keeps them
483 * all, and the row stays bounded by MAX_SESSIONS_ROW_BYTES instead.
484 */
485 $candidates = array();
486 $index = 0;
487 foreach ( $refresh_tokens as $candidate_jti => $token_data ) {
488 $position = $index++;
489 if ( (string) $candidate_jti === $protected_jti ) {
490 continue;
491 }
492
493 // The ROW timestamp is the cheap filter. It is authoritative when it says a
494 // session is live, because login and refresh both write it; when it says idle
495 // the activity transient still gets the final word, below.
496 $activity = $this->session_row_last_seen( $token_data );
497 if ( $activity > $idle_before ) {
498 continue;
499 }
500
501 $candidates[] = array(
502 'jti' => (string) $candidate_jti,
503 'activity' => $activity,
504 'index' => $position,
505 );
506 }
507
508 usort(
509 $candidates,
510 function ( $a, $b ) {
511 if ( $a['activity'] === $b['activity'] ) {
512 return $a['index'] <=> $b['index'];
513 }
514
515 return $a['activity'] <=> $b['activity'];
516 }
517 );
518
519 foreach ( $candidates as $candidate ) {
520 if ( $evict_count <= 0 ) {
521 break;
522 }
523
524 // Checked only for rows already stale, so this costs a handful of transient
525 // reads rather than one per stored session.
526 if ( $this->session_last_seen( $candidate['jti'], $refresh_tokens[ $candidate['jti'] ] ) > $idle_before ) {
527 continue;
528 }
529
530 $evicted[ $candidate['jti'] ] = $refresh_tokens[ $candidate['jti'] ];
531 $this->forget_session_activity( $candidate['jti'] );
532 unset( $refresh_tokens[ $candidate['jti'] ] );
533 --$evict_count;
534 }
535
536 return $evicted;
537 }
538
539 /**
540 * When a session was last seen, taking the later of the row and the activity record.
541 *
542 * The row is rewritten by login and refresh; the transient is written by ordinary
543 * authenticated requests. Neither alone is the whole picture — a device working through
544 * a long-lived access token has an old row timestamp and a fresh transient, and a
545 * session that has not been used at all has the reverse.
546 *
547 * @param string $jti Refresh token JTI (session identifier).
548 * @param array $token_data Stored session record.
549 *
550 * @return int Unix timestamp; 0 when neither source carries a usable timestamp.
551 */
552 private function session_last_seen( string $jti, array $token_data ): int {
553 $row_seen = $this->session_row_last_seen( $token_data );
554 $seen = '' === $jti ? false : get_transient( self::SESSION_SEEN_TRANSIENT_PREFIX . $jti );
555
556 return is_numeric( $seen ) ? max( $row_seen, (int) $seen ) : $row_seen;
557 }
558
559 /**
560 * When the stored record itself says a session was last seen.
561 *
562 * Login and refresh both rewrite `last_active` in the row, so this stays accurate for
563 * everything except the stretch between refreshes — which is what the activity
564 * transient covers.
565 *
566 * @param array $token_data Stored session record.
567 *
568 * @return int Unix timestamp; 0 when the record carries no usable timestamp.
569 */
570 private function session_row_last_seen( array $token_data ): int {
571 if ( isset( $token_data['last_active'] ) ) {
572 return (int) $token_data['last_active'];
573 }
574
575 if ( isset( $token_data['created'] ) ) {
576 return (int) $token_data['created'];
577 }
578
579 return 0;
580 }
581
582 /**
583 * Parse user agent string to extract device information.
584 *
585 * @param string $user_agent The user agent string.
586 *
587 * @return array
588 */
589 private function parse_user_agent( string $user_agent ): array {
590 $device_info = array(
591 'device_type' => 'unknown',
592 'browser' => 'unknown',
593 'browser_version' => '',
594 'os' => 'unknown',
595 'app_type' => 'web', // web, ios_app, android_app, electron_app.
596 );
597
598 if ( empty( $user_agent ) ) {
599 return $device_info;
600 }
601
602 // Detect WooCommerce POS apps first (custom identifiers)
603 // Check for Electron app (including just "WooCommercePOS" in user agent with Electron).
604 if ( preg_match( '/Electron/i', $user_agent ) && preg_match( '/WooCommercePOS|WCPOS/i', $user_agent ) ) {
605 $device_info['app_type'] = 'electron_app';
606 $device_info['browser'] = 'WooCommerce POS';
607 $device_info['device_type'] = 'desktop';
608 // Try to extract WooCommercePOS version.
609 if ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
610 $device_info['browser_version'] = $matches[1];
611 } elseif ( preg_match( '/WCPOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
612 $device_info['browser_version'] = $matches[1];
613 }
614 } elseif ( preg_match( '/WCPOS[-_]?iOS|WooCommercePOS[-_]?iOS/i', $user_agent ) ) {
615 $device_info['app_type'] = 'ios_app';
616 $device_info['browser'] = 'WooCommerce POS';
617 // Default to tablet unless explicitly detected as phone.
618 $device_info['device_type'] = preg_match( '/iphone|ipod/i', $user_agent ) ? 'mobile' : 'tablet';
619 if ( preg_match( '/WCPOS[-_]?iOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
620 $device_info['browser_version'] = $matches[1];
621 } elseif ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
622 $device_info['browser_version'] = $matches[1];
623 }
624 } elseif ( preg_match( '/WCPOS[-_]?Android|WooCommercePOS[-_]?Android/i', $user_agent ) ) {
625 $device_info['app_type'] = 'android_app';
626 $device_info['browser'] = 'WooCommerce POS';
627 // Default to tablet unless explicitly detected as mobile.
628 $device_info['device_type'] = preg_match( '/mobile/i', $user_agent ) && ! preg_match( '/tablet/i', $user_agent ) ? 'mobile' : 'tablet';
629 if ( preg_match( '/WCPOS[-_]?Android[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
630 $device_info['browser_version'] = $matches[1];
631 } elseif ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
632 $device_info['browser_version'] = $matches[1];
633 }
634 }
635
636 // Detect standard device type (if not already set by app detection).
637 if ( 'web' === $device_info['app_type'] ) {
638 if ( preg_match( '/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/i', $user_agent ) ) {
639 $device_info['device_type'] = 'mobile';
640 } elseif ( preg_match( '/tablet|ipad|playbook|silk/i', $user_agent ) ) {
641 $device_info['device_type'] = 'tablet';
642 } else {
643 $device_info['device_type'] = 'desktop';
644 }
645 }
646
647 // Detect browser (skip if we already detected a WCPOS app).
648 if ( 'WooCommerce POS' !== $device_info['browser'] ) {
649 if ( preg_match( '/MSIE|Trident/i', $user_agent ) ) {
650 $device_info['browser'] = 'Internet Explorer';
651 if ( preg_match( '/MSIE ([0-9.]+)/', $user_agent, $matches ) ) {
652 $device_info['browser_version'] = $matches[1];
653 }
654 } elseif ( preg_match( '/Edge\/([0-9.]+)/i', $user_agent, $matches ) ) {
655 $device_info['browser'] = 'Edge';
656 $device_info['browser_version'] = $matches[1];
657 } elseif ( preg_match( '/Edg\/([0-9.]+)/i', $user_agent, $matches ) ) {
658 $device_info['browser'] = 'Edge';
659 $device_info['browser_version'] = $matches[1];
660 } elseif ( preg_match( '/Firefox\/([0-9.]+)/i', $user_agent, $matches ) ) {
661 $device_info['browser'] = 'Firefox';
662 $device_info['browser_version'] = $matches[1];
663 } elseif ( preg_match( '/Chrome\/([0-9.]+)/i', $user_agent, $matches ) ) {
664 $device_info['browser'] = 'Chrome';
665 $device_info['browser_version'] = $matches[1];
666 } elseif ( preg_match( '/Safari\/([0-9.]+)/i', $user_agent, $matches ) ) {
667 // Safari should be checked after Chrome because Chrome also contains Safari.
668 if ( ! preg_match( '/Chrome/i', $user_agent ) ) {
669 $device_info['browser'] = 'Safari';
670 $device_info['browser_version'] = $matches[1];
671 }
672 } elseif ( preg_match( '/Opera\/([0-9.]+)/i', $user_agent, $matches ) ) {
673 $device_info['browser'] = 'Opera';
674 $device_info['browser_version'] = $matches[1];
675 }
676 }
677
678 // Detect OS.
679 if ( preg_match( '/Windows NT ([0-9.]+)/i', $user_agent, $matches ) ) {
680 $device_info['os'] = 'Windows';
681 } elseif ( preg_match( '/Mac OS X ([0-9_]+)/i', $user_agent, $matches ) ) {
682 $device_info['os'] = 'macOS';
683 } elseif ( preg_match( '/Android ([0-9.]+)/i', $user_agent, $matches ) ) {
684 $device_info['os'] = 'Android';
685 } elseif ( preg_match( '/iPhone OS ([0-9_]+)/i', $user_agent, $matches ) ) {
686 $device_info['os'] = 'iOS';
687 } elseif ( preg_match( '/iPad.*OS ([0-9_]+)/i', $user_agent, $matches ) ) {
688 $device_info['os'] = 'iPadOS';
689 } elseif ( preg_match( '/Linux/i', $user_agent ) ) {
690 $device_info['os'] = 'Linux';
691 }
692
693 return $device_info;
694 }
695
696 /**
697 * Revoke all refresh tokens for a user.
698 *
699 * @param int $user_id The user ID.
700 *
701 * @return bool
702 */
703 public function revoke_all( int $user_id ): bool {
704 foreach ( $this->entries( $user_id ) as $jti => $token_data ) {
705 $this->forget_session_activity( (string) $jti );
706 }
707
708 return delete_user_meta( $user_id, self::META_KEY );
709 }
710
711 /**
712 * Revoke all sessions except the current one.
713 *
714 * @param int $user_id The user ID.
715 * @param string $current_jti The current token JTI.
716 *
717 * @return bool
718 */
719 public function keep_only( int $user_id, string $current_jti ): bool {
720 $refresh_tokens = get_user_meta( $user_id, self::META_KEY, true );
721 if ( ! \is_array( $refresh_tokens ) ) {
722 return false;
723 }
724
725 foreach ( $refresh_tokens as $jti => $token_data ) {
726 if ( $jti !== $current_jti ) {
727 $this->forget_session_activity( (string) $jti );
728 }
729 }
730
731 // Keep only the current session in user meta.
732 $refresh_tokens = array_filter(
733 $refresh_tokens,
734 function ( $_token, $jti ) use ( $current_jti ) {
735 return $jti === $current_jti;
736 },
737 ARRAY_FILTER_USE_BOTH
738 );
739
740 return update_user_meta( $user_id, self::META_KEY, $refresh_tokens );
741 }
742
743 /**
744 * Get the IDs of users with a stored session row.
745 *
746 * @return int[]
747 */
748 public function users_with_sessions(): array {
749 global $wpdb;
750
751 return array_map(
752 'intval',
753 $wpdb->get_col(
754 $wpdb->prepare(
755 "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s",
756 self::META_KEY
757 )
758 )
759 );
760 }
761
762 /**
763 * Guard the row before a refresh path reads it.
764 *
765 * @param int $user_id The user ID.
766 */
767 public function guard_row( int $user_id ): void {
768 $this->discard_oversized_row( $user_id );
769 }
770 }
771