PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / Services / Session_Registry.php

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

752 lines 25.8 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 $entries = get_user_meta( $user_id, self::META_KEY, true );
101
102 return \is_array( $entries ) ? $entries : array();
103 }
104
105 /**
106 * Read one stored session.
107 *
108 * @param int $user_id The user ID.
109 * @param string $jti Refresh token JTI.
110 *
111 * @return array
112 */
113 public function entry( int $user_id, string $jti ): array {
114 return $this->entries( $user_id )[ $jti ] ?? array();
115 }
116
117 /**
118 * Store refresh token JTI for tracking/revocation.
119 *
120 * @param int $user_id The user ID.
121 * @param string $jti The token JTI.
122 * @param int $expires The expiration timestamp.
123 * @param Session_Context $context Request state the session is recorded against.
124 *
125 * @return array Evicted entries keyed by refresh token JTI.
126 */
127 public function record( int $user_id, string $jti, int $expires, Session_Context $context ): array {
128 // BEFORE the read: a pre-cap row can be too large to load, and this is the first
129 // point in the login flow where WCPOS knows the user id.
130 $this->discard_oversized_row( $user_id );
131
132 $refresh_tokens = get_user_meta( $user_id, self::META_KEY, true );
133 if ( ! \is_array( $refresh_tokens ) ) {
134 $refresh_tokens = array();
135 }
136
137 // Clean up expired tokens.
138 $refresh_tokens = array_filter(
139 $refresh_tokens,
140 function ( $token ) {
141 return $token['expires'] > time();
142 }
143 );
144
145 // Capture session metadata.
146 $current_time = time();
147 $ip_address = $context->get_ip();
148 $user_agent = $context->get_user_agent();
149 $device_info = $this->parse_user_agent( $user_agent );
150
151 // Check for explicit platform declaration from native apps (passed as a param in the auth request).
152 $platform = $context->get_platform();
153 $version = $context->get_version();
154 $build = $context->get_build();
155
156 // Override app_type if platform was explicitly provided by the client.
157 if ( \in_array( $platform, array( 'ios', 'android', 'electron', 'web' ), true ) ) {
158 $device_info['app_type'] = 'web' === $platform ? 'web' : $platform . '_app';
159
160 // Set appropriate device type based on platform.
161 if ( 'ios' === $platform || 'android' === $platform ) {
162 $device_info['device_type'] = 'tablet'; // Default to tablet for mobile apps.
163 } elseif ( 'electron' === $platform ) {
164 $device_info['device_type'] = 'desktop';
165 }
166
167 // Use version from param if provided.
168 if ( ! empty( $version ) ) {
169 $device_info['browser_version'] = $version;
170 }
171
172 // Store build number if provided.
173 if ( ! empty( $build ) ) {
174 $device_info['build'] = $build;
175 }
176
177 // Set browser to WooCommerce POS for native apps.
178 if ( 'web' !== $platform ) {
179 $device_info['browser'] = 'WooCommerce POS';
180 }
181 }
182
183 // Add new token with metadata.
184 $refresh_tokens[ $jti ] = array(
185 'expires' => $expires,
186 'created' => $current_time,
187 'last_active' => $current_time,
188 'ip_address' => $ip_address,
189 'user_agent' => $user_agent,
190 'device_info' => $device_info,
191 );
192
193 // Cap the number of stored sessions so programmatic clients cannot grow the row without bound.
194 $evicted = $this->evict_oldest_sessions( $refresh_tokens, $jti );
195
196 update_user_meta( $user_id, self::META_KEY, $refresh_tokens );
197
198 return $evicted;
199 }
200
201 /**
202 * Get all active sessions for a user.
203 *
204 * @param int $user_id The user ID.
205 *
206 * @return array
207 */
208 public function list( int $user_id ): array {
209 $refresh_tokens = get_user_meta( $user_id, self::META_KEY, true );
210 if ( ! \is_array( $refresh_tokens ) ) {
211 return array();
212 }
213
214 $sessions = array();
215 $current_time = time();
216
217 foreach ( $refresh_tokens as $jti => $token_data ) {
218 // Skip expired sessions.
219 if ( $token_data['expires'] <= $current_time ) {
220 continue;
221 }
222
223 $sessions[] = array(
224 'jti' => $jti,
225 'created' => $token_data['created'] ?? $current_time,
226 'last_active' => $token_data['last_active'] ?? $token_data['created'] ?? $current_time,
227 'expires' => $token_data['expires'],
228 'ip_address' => $token_data['ip_address'] ?? '',
229 'user_agent' => $token_data['user_agent'] ?? '',
230 'device_info' => $token_data['device_info'] ?? array(),
231 );
232 }
233
234 // Sort by last_active descending (most recent first).
235 usort(
236 $sessions,
237 function ( $a, $b ) {
238 return $b['last_active'] - $a['last_active'];
239 }
240 );
241
242 return $sessions;
243 }
244
245 /**
246 * Check if refresh token is still valid (not revoked).
247 *
248 * @param int $user_id The user ID.
249 * @param string $jti The token JTI.
250 *
251 * @return bool
252 */
253 public function is_live( int $user_id, string $jti ): bool {
254 $refresh_tokens = get_user_meta( $user_id, self::META_KEY, true );
255 if ( ! \is_array( $refresh_tokens ) ) {
256 return false;
257 }
258
259 return isset( $refresh_tokens[ $jti ] ) && $refresh_tokens[ $jti ]['expires'] > time();
260 }
261
262 /**
263 * Refresh a session's `last_active`, at most once every few minutes.
264 *
265 * Called from token validation, so it runs on EVERY authenticated request. The
266 * throttle is what makes that affordable: the value only has to be accurate to within
267 * minutes for a rule that asks whether a session has been unseen for a week, and the
268 * read is already in the user's meta cache by this point.
269 *
270 * @param int $user_id The user ID.
271 * @param string $jti Refresh token JTI (session identifier).
272 */
273 public function touch( int $user_id, string $jti ): void {
274 if ( 0 === $user_id || '' === $jti ) {
275 return;
276 }
277
278 $key = self::SESSION_SEEN_TRANSIENT_PREFIX . $jti;
279 $seen = get_transient( $key );
280
281 // The throttle reads the transient, never the session row: this runs on every
282 // authenticated request, and the row is the one thing this path must not touch.
283 if ( is_numeric( $seen ) && time() - (int) $seen < self::SESSION_ACTIVITY_REFRESH_SECONDS ) {
284 return;
285 }
286
287 // The TTL IS the idle window, so a missing transient means "not seen in a week".
288 set_transient( $key, time(), self::SESSION_EVICTION_IDLE_SECONDS );
289 }
290
291 /**
292 * Update last_active timestamp for a session.
293 *
294 * @param int $user_id The user ID.
295 * @param string $jti The token JTI.
296 *
297 * @return bool
298 */
299 public function refresh_activity( int $user_id, string $jti ): bool {
300 // Public surface: any caller reaching the row goes through the size guard first.
301 $this->discard_oversized_row( $user_id );
302
303 $refresh_tokens = get_user_meta( $user_id, self::META_KEY, true );
304 if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $jti ] ) ) {
305 return false;
306 }
307
308 $refresh_tokens[ $jti ]['last_active'] = time();
309
310 return update_user_meta( $user_id, self::META_KEY, $refresh_tokens );
311 }
312
313 /**
314 * Record the latest access token expiry linked to a refresh-token session.
315 *
316 * @param int $user_id The user ID.
317 * @param string $refresh_jti Refresh token JTI.
318 * @param int $access_expires Access token expiry timestamp.
319 *
320 * @return bool
321 */
322 public function record_access_expiry( int $user_id, string $refresh_jti, int $access_expires ): bool {
323 if ( empty( $refresh_jti ) || $access_expires <= 0 ) {
324 return false;
325 }
326
327 $refresh_tokens = get_user_meta( $user_id, self::META_KEY, true );
328 if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $refresh_jti ] ) ) {
329 return false;
330 }
331
332 $current_access_expires = isset( $refresh_tokens[ $refresh_jti ]['access_expires'] ) ? (int) $refresh_tokens[ $refresh_jti ]['access_expires'] : 0;
333 if ( $access_expires <= $current_access_expires ) {
334 return true;
335 }
336
337 $refresh_tokens[ $refresh_jti ]['access_expires'] = $access_expires;
338
339 return update_user_meta( $user_id, self::META_KEY, $refresh_tokens );
340 }
341
342 /**
343 * Revoke JWT Token by JTI.
344 *
345 * @param int $user_id The user ID.
346 * @param string $jti The token JTI.
347 *
348 * @return bool
349 */
350 public function revoke( int $user_id, string $jti ): bool {
351 $refresh_tokens = get_user_meta( $user_id, self::META_KEY, true );
352 if ( ! \is_array( $refresh_tokens ) ) {
353 return false;
354 }
355
356 if ( isset( $refresh_tokens[ $jti ] ) ) {
357 unset( $refresh_tokens[ $jti ] );
358 update_user_meta( $user_id, self::META_KEY, $refresh_tokens );
359 $this->forget_session_activity( $jti );
360
361 return true;
362 }
363
364 return false;
365 }
366
367 /**
368 * Drop the stored session row when it is too large to be read safely.
369 *
370 * A LAST RESORT, not a tidy-up: discarding the row signs every one of that user's
371 * devices out at once, so the ceiling is set above anything measured to be readable
372 * (see MAX_SESSIONS_ROW_BYTES) and everything below it is TRIMMED by
373 * `evict_oldest_sessions()` on the same write instead. What this catches is the one
374 * case trimming cannot: a row so large that reading it exhausts the request before any
375 * of the code below runs, which — because that read happens on every login — locks the
376 * user out permanently (#1776). `LENGTH()` lets MySQL answer with a number instead of
377 * the value, so the size is checked without paying for the row.
378 *
379 * @param int $user_id The user ID.
380 */
381 private function discard_oversized_row( int $user_id ): void {
382 global $wpdb;
383
384 $rows = $wpdb->get_results(
385 $wpdb->prepare(
386 "SELECT umeta_id, LENGTH(meta_value) AS meta_bytes FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s",
387 $user_id,
388 self::META_KEY
389 )
390 );
391
392 if ( empty( $rows ) ) {
393 return;
394 }
395
396 $bytes = 0;
397 foreach ( $rows as $row ) {
398 $bytes += (int) $row->meta_bytes;
399 }
400
401 if ( $bytes <= self::MAX_SESSIONS_ROW_BYTES ) {
402 return;
403 }
404
405 foreach ( $rows as $row ) {
406 $wpdb->delete( $wpdb->usermeta, array( 'umeta_id' => (int) $row->umeta_id ), array( '%d' ) );
407 }
408
409 // The row may already be sitting in the user's meta cache from an earlier
410 // `get_user_meta()` in this request; without this the next read serves the value
411 // that was just deleted.
412 wp_cache_delete( $user_id, 'user_meta' );
413
414 Logger::warning(
415 sprintf(
416 '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.',
417 $user_id,
418 $bytes,
419 self::MAX_SESSIONS_ROW_BYTES
420 )
421 );
422 }
423
424 /**
425 * Forget a session's recorded activity.
426 *
427 * @param string $jti Refresh token JTI (session identifier).
428 */
429 private function forget_session_activity( string $jti ): void {
430 if ( '' !== $jti ) {
431 delete_transient( self::SESSION_SEEN_TRANSIENT_PREFIX . $jti );
432 }
433 }
434
435 /**
436 * Drop the least recently active sessions until the per-user cap is met.
437 *
438 * Auth blacklists the returned sessions, so the device that lost its slot is cleanly
439 * logged out instead of keeping a working access token for the remainder of its life.
440 *
441 * @param array $refresh_tokens Stored sessions keyed by refresh token JTI.
442 * @param string $protected_jti JTI that must never be evicted (the session being stored).
443 *
444 * @return array Evicted entries keyed by refresh token JTI.
445 */
446 private function evict_oldest_sessions( array &$refresh_tokens, string $protected_jti ): array {
447 $evict_count = \count( $refresh_tokens ) - self::MAX_SESSIONS_PER_USER;
448 if ( $evict_count <= 0 ) {
449 return array();
450 }
451
452 $evicted = array();
453 $issued_at = time();
454 $idle_before = $issued_at - self::SESSION_EVICTION_IDLE_SECONDS;
455
456 /*
457 * Order eviction candidates oldest-first. The insertion index breaks ties explicitly
458 * because usort() is not stable before PHP 8.0 and bulk logins share a timestamp.
459 *
460 * A session seen within SESSION_EVICTION_IDLE_SECONDS is NOT a candidate at any
461 * count. Being the oldest of N says nothing about being unused when N sessions were
462 * minted in an hour, and evicting a live one blacklists a working device's access
463 * token. The cap yields to that: a user whose sessions are all recent keeps them
464 * all, and the row stays bounded by MAX_SESSIONS_ROW_BYTES instead.
465 */
466 $candidates = array();
467 $index = 0;
468 foreach ( $refresh_tokens as $candidate_jti => $token_data ) {
469 $position = $index++;
470 if ( (string) $candidate_jti === $protected_jti ) {
471 continue;
472 }
473
474 // The ROW timestamp is the cheap filter. It is authoritative when it says a
475 // session is live, because login and refresh both write it; when it says idle
476 // the activity transient still gets the final word, below.
477 $activity = $this->session_row_last_seen( $token_data );
478 if ( $activity > $idle_before ) {
479 continue;
480 }
481
482 $candidates[] = array(
483 'jti' => (string) $candidate_jti,
484 'activity' => $activity,
485 'index' => $position,
486 );
487 }
488
489 usort(
490 $candidates,
491 function ( $a, $b ) {
492 if ( $a['activity'] === $b['activity'] ) {
493 return $a['index'] <=> $b['index'];
494 }
495
496 return $a['activity'] <=> $b['activity'];
497 }
498 );
499
500 foreach ( $candidates as $candidate ) {
501 if ( $evict_count <= 0 ) {
502 break;
503 }
504
505 // Checked only for rows already stale, so this costs a handful of transient
506 // reads rather than one per stored session.
507 if ( $this->session_last_seen( $candidate['jti'], $refresh_tokens[ $candidate['jti'] ] ) > $idle_before ) {
508 continue;
509 }
510
511 $evicted[ $candidate['jti'] ] = $refresh_tokens[ $candidate['jti'] ];
512 $this->forget_session_activity( $candidate['jti'] );
513 unset( $refresh_tokens[ $candidate['jti'] ] );
514 --$evict_count;
515 }
516
517 return $evicted;
518 }
519
520 /**
521 * When a session was last seen, taking the later of the row and the activity record.
522 *
523 * The row is rewritten by login and refresh; the transient is written by ordinary
524 * authenticated requests. Neither alone is the whole picture — a device working through
525 * a long-lived access token has an old row timestamp and a fresh transient, and a
526 * session that has not been used at all has the reverse.
527 *
528 * @param string $jti Refresh token JTI (session identifier).
529 * @param array $token_data Stored session record.
530 *
531 * @return int Unix timestamp; 0 when neither source carries a usable timestamp.
532 */
533 private function session_last_seen( string $jti, array $token_data ): int {
534 $row_seen = $this->session_row_last_seen( $token_data );
535 $seen = '' === $jti ? false : get_transient( self::SESSION_SEEN_TRANSIENT_PREFIX . $jti );
536
537 return is_numeric( $seen ) ? max( $row_seen, (int) $seen ) : $row_seen;
538 }
539
540 /**
541 * When the stored record itself says a session was last seen.
542 *
543 * Login and refresh both rewrite `last_active` in the row, so this stays accurate for
544 * everything except the stretch between refreshes — which is what the activity
545 * transient covers.
546 *
547 * @param array $token_data Stored session record.
548 *
549 * @return int Unix timestamp; 0 when the record carries no usable timestamp.
550 */
551 private function session_row_last_seen( array $token_data ): int {
552 if ( isset( $token_data['last_active'] ) ) {
553 return (int) $token_data['last_active'];
554 }
555
556 if ( isset( $token_data['created'] ) ) {
557 return (int) $token_data['created'];
558 }
559
560 return 0;
561 }
562
563 /**
564 * Parse user agent string to extract device information.
565 *
566 * @param string $user_agent The user agent string.
567 *
568 * @return array
569 */
570 private function parse_user_agent( string $user_agent ): array {
571 $device_info = array(
572 'device_type' => 'unknown',
573 'browser' => 'unknown',
574 'browser_version' => '',
575 'os' => 'unknown',
576 'app_type' => 'web', // web, ios_app, android_app, electron_app.
577 );
578
579 if ( empty( $user_agent ) ) {
580 return $device_info;
581 }
582
583 // Detect WooCommerce POS apps first (custom identifiers)
584 // Check for Electron app (including just "WooCommercePOS" in user agent with Electron).
585 if ( preg_match( '/Electron/i', $user_agent ) && preg_match( '/WooCommercePOS|WCPOS/i', $user_agent ) ) {
586 $device_info['app_type'] = 'electron_app';
587 $device_info['browser'] = 'WooCommerce POS';
588 $device_info['device_type'] = 'desktop';
589 // Try to extract WooCommercePOS version.
590 if ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
591 $device_info['browser_version'] = $matches[1];
592 } elseif ( preg_match( '/WCPOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
593 $device_info['browser_version'] = $matches[1];
594 }
595 } elseif ( preg_match( '/WCPOS[-_]?iOS|WooCommercePOS[-_]?iOS/i', $user_agent ) ) {
596 $device_info['app_type'] = 'ios_app';
597 $device_info['browser'] = 'WooCommerce POS';
598 // Default to tablet unless explicitly detected as phone.
599 $device_info['device_type'] = preg_match( '/iphone|ipod/i', $user_agent ) ? 'mobile' : 'tablet';
600 if ( preg_match( '/WCPOS[-_]?iOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
601 $device_info['browser_version'] = $matches[1];
602 } elseif ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
603 $device_info['browser_version'] = $matches[1];
604 }
605 } elseif ( preg_match( '/WCPOS[-_]?Android|WooCommercePOS[-_]?Android/i', $user_agent ) ) {
606 $device_info['app_type'] = 'android_app';
607 $device_info['browser'] = 'WooCommerce POS';
608 // Default to tablet unless explicitly detected as mobile.
609 $device_info['device_type'] = preg_match( '/mobile/i', $user_agent ) && ! preg_match( '/tablet/i', $user_agent ) ? 'mobile' : 'tablet';
610 if ( preg_match( '/WCPOS[-_]?Android[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
611 $device_info['browser_version'] = $matches[1];
612 } elseif ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
613 $device_info['browser_version'] = $matches[1];
614 }
615 }
616
617 // Detect standard device type (if not already set by app detection).
618 if ( 'web' === $device_info['app_type'] ) {
619 if ( preg_match( '/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/i', $user_agent ) ) {
620 $device_info['device_type'] = 'mobile';
621 } elseif ( preg_match( '/tablet|ipad|playbook|silk/i', $user_agent ) ) {
622 $device_info['device_type'] = 'tablet';
623 } else {
624 $device_info['device_type'] = 'desktop';
625 }
626 }
627
628 // Detect browser (skip if we already detected a WCPOS app).
629 if ( 'WooCommerce POS' !== $device_info['browser'] ) {
630 if ( preg_match( '/MSIE|Trident/i', $user_agent ) ) {
631 $device_info['browser'] = 'Internet Explorer';
632 if ( preg_match( '/MSIE ([0-9.]+)/', $user_agent, $matches ) ) {
633 $device_info['browser_version'] = $matches[1];
634 }
635 } elseif ( preg_match( '/Edge\/([0-9.]+)/i', $user_agent, $matches ) ) {
636 $device_info['browser'] = 'Edge';
637 $device_info['browser_version'] = $matches[1];
638 } elseif ( preg_match( '/Edg\/([0-9.]+)/i', $user_agent, $matches ) ) {
639 $device_info['browser'] = 'Edge';
640 $device_info['browser_version'] = $matches[1];
641 } elseif ( preg_match( '/Firefox\/([0-9.]+)/i', $user_agent, $matches ) ) {
642 $device_info['browser'] = 'Firefox';
643 $device_info['browser_version'] = $matches[1];
644 } elseif ( preg_match( '/Chrome\/([0-9.]+)/i', $user_agent, $matches ) ) {
645 $device_info['browser'] = 'Chrome';
646 $device_info['browser_version'] = $matches[1];
647 } elseif ( preg_match( '/Safari\/([0-9.]+)/i', $user_agent, $matches ) ) {
648 // Safari should be checked after Chrome because Chrome also contains Safari.
649 if ( ! preg_match( '/Chrome/i', $user_agent ) ) {
650 $device_info['browser'] = 'Safari';
651 $device_info['browser_version'] = $matches[1];
652 }
653 } elseif ( preg_match( '/Opera\/([0-9.]+)/i', $user_agent, $matches ) ) {
654 $device_info['browser'] = 'Opera';
655 $device_info['browser_version'] = $matches[1];
656 }
657 }
658
659 // Detect OS.
660 if ( preg_match( '/Windows NT ([0-9.]+)/i', $user_agent, $matches ) ) {
661 $device_info['os'] = 'Windows';
662 } elseif ( preg_match( '/Mac OS X ([0-9_]+)/i', $user_agent, $matches ) ) {
663 $device_info['os'] = 'macOS';
664 } elseif ( preg_match( '/Android ([0-9.]+)/i', $user_agent, $matches ) ) {
665 $device_info['os'] = 'Android';
666 } elseif ( preg_match( '/iPhone OS ([0-9_]+)/i', $user_agent, $matches ) ) {
667 $device_info['os'] = 'iOS';
668 } elseif ( preg_match( '/iPad.*OS ([0-9_]+)/i', $user_agent, $matches ) ) {
669 $device_info['os'] = 'iPadOS';
670 } elseif ( preg_match( '/Linux/i', $user_agent ) ) {
671 $device_info['os'] = 'Linux';
672 }
673
674 return $device_info;
675 }
676
677 /**
678 * Revoke all refresh tokens for a user.
679 *
680 * @param int $user_id The user ID.
681 *
682 * @return bool
683 */
684 public function revoke_all( int $user_id ): bool {
685 foreach ( $this->entries( $user_id ) as $jti => $token_data ) {
686 $this->forget_session_activity( (string) $jti );
687 }
688
689 return delete_user_meta( $user_id, self::META_KEY );
690 }
691
692 /**
693 * Revoke all sessions except the current one.
694 *
695 * @param int $user_id The user ID.
696 * @param string $current_jti The current token JTI.
697 *
698 * @return bool
699 */
700 public function keep_only( int $user_id, string $current_jti ): bool {
701 $refresh_tokens = get_user_meta( $user_id, self::META_KEY, true );
702 if ( ! \is_array( $refresh_tokens ) ) {
703 return false;
704 }
705
706 foreach ( $refresh_tokens as $jti => $token_data ) {
707 if ( $jti !== $current_jti ) {
708 $this->forget_session_activity( (string) $jti );
709 }
710 }
711
712 // Keep only the current session in user meta.
713 $refresh_tokens = array_filter(
714 $refresh_tokens,
715 function ( $_token, $jti ) use ( $current_jti ) {
716 return $jti === $current_jti;
717 },
718 ARRAY_FILTER_USE_BOTH
719 );
720
721 return update_user_meta( $user_id, self::META_KEY, $refresh_tokens );
722 }
723
724 /**
725 * Get the IDs of users with a stored session row.
726 *
727 * @return int[]
728 */
729 public function users_with_sessions(): array {
730 global $wpdb;
731
732 return array_map(
733 'intval',
734 $wpdb->get_col(
735 $wpdb->prepare(
736 "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s",
737 self::META_KEY
738 )
739 )
740 );
741 }
742
743 /**
744 * Guard the row before a refresh path reads it.
745 *
746 * @param int $user_id The user ID.
747 */
748 public function guard_row( int $user_id ): void {
749 $this->discard_oversized_row( $user_id );
750 }
751 }
752