| @@ -96,11 +96,9 @@ | ||
| 96 | 96 | * |
| 97 | 97 | * @return array |
| 98 | 98 | */ |
| 99 | 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(); | |
| 100 | + return $this->read_row( $user_id ); | |
| 103 | 101 | } |
| 104 | 102 | |
| 105 | 103 | /** |
| 106 | 104 | * Read one stored session. |
| @@ -128,12 +126,9 @@ | ||
| 128 | 126 | // BEFORE the read: a pre-cap row can be too large to load, and this is the first |
| 129 | 127 | // point in the login flow where WCPOS knows the user id. |
| 130 | 128 | $this->discard_oversized_row( $user_id ); |
| 131 | 129 | |
| 132 | - $refresh_tokens = get_user_meta( $user_id, self::META_KEY, true ); | |
| 133 | - if ( ! \is_array( $refresh_tokens ) ) { | |
| 134 | - $refresh_tokens = array(); | |
| 135 | - } | |
| 130 | + $refresh_tokens = $this->read_row( $user_id ); | |
| 136 | 131 | |
| 137 | 132 | // Clean up expired tokens. |
| 138 | 133 | $refresh_tokens = array_filter( |
| 139 | 134 | $refresh_tokens, |
| @@ -205,10 +200,10 @@ | ||
| 205 | 200 | * |
| 206 | 201 | * @return array |
| 207 | 202 | */ |
| 208 | 203 | 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 ) ) { | |
| 204 | + $refresh_tokens = $this->read_row( $user_id ); | |
| 205 | + if ( empty( $refresh_tokens ) ) { | |
| 211 | 206 | return array(); |
| 212 | 207 | } |
| 213 | 208 | |
| 214 | 209 | $sessions = array(); |
| @@ -250,14 +245,12 @@ | ||
| 250 | 245 | * |
| 251 | 246 | * @return bool |
| 252 | 247 | */ |
| 253 | 248 | 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 | - } | |
| 249 | + $row = $this->read_row( $user_id ); | |
| 258 | 250 | |
| 259 | - return isset( $refresh_tokens[ $jti ] ) && $refresh_tokens[ $jti ]['expires'] > time(); | |
| 251 | + // read_row() has already skipped malformed entries, which are not live sessions. | |
| 252 | + return isset( $row[ $jti ] ) && (int) $row[ $jti ]['expires'] > time(); | |
| 260 | 253 | } |
| 261 | 254 | |
| 262 | 255 | /** |
| 263 | 256 | * Refresh a session's `last_active`, at most once every few minutes. |
| @@ -277,10 +270,11 @@ | ||
| 277 | 270 | |
| 278 | 271 | $key = self::SESSION_SEEN_TRANSIENT_PREFIX . $jti; |
| 279 | 272 | $seen = get_transient( $key ); |
| 280 | 273 | |
| 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. | |
| 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. | |
| 283 | 277 | if ( is_numeric( $seen ) && time() - (int) $seen < self::SESSION_ACTIVITY_REFRESH_SECONDS ) { |
| 284 | 278 | return; |
| 285 | 279 | } |
| 286 | 280 | |
| @@ -299,10 +293,10 @@ | ||
| 299 | 293 | public function refresh_activity( int $user_id, string $jti ): bool { |
| 300 | 294 | // Public surface: any caller reaching the row goes through the size guard first. |
| 301 | 295 | $this->discard_oversized_row( $user_id ); |
| 302 | 296 | |
| 303 | - $refresh_tokens = get_user_meta( $user_id, self::META_KEY, true ); | |
| 304 | - if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $jti ] ) ) { | |
| 297 | + $refresh_tokens = $this->read_row( $user_id ); | |
| 298 | + if ( ! isset( $refresh_tokens[ $jti ] ) ) { | |
| 305 | 299 | return false; |
| 306 | 300 | } |
| 307 | 301 | |
| 308 | 302 | $refresh_tokens[ $jti ]['last_active'] = time(); |
| @@ -323,10 +317,10 @@ | ||
| 323 | 317 | if ( empty( $refresh_jti ) || $access_expires <= 0 ) { |
| 324 | 318 | return false; |
| 325 | 319 | } |
| 326 | 320 | |
| 327 | - $refresh_tokens = get_user_meta( $user_id, self::META_KEY, true ); | |
| 328 | - if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $refresh_jti ] ) ) { | |
| 321 | + $refresh_tokens = $this->read_row( $user_id ); | |
| 322 | + if ( ! isset( $refresh_tokens[ $refresh_jti ] ) ) { | |
| 329 | 323 | return false; |
| 330 | 324 | } |
| 331 | 325 | |
| 332 | 326 | $current_access_expires = isset( $refresh_tokens[ $refresh_jti ]['access_expires'] ) ? (int) $refresh_tokens[ $refresh_jti ]['access_expires'] : 0; |
| @@ -361,8 +355,33 @@ | ||
| 361 | 355 | return true; |
| 362 | 356 | } |
| 363 | 357 | |
| 364 | 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 | + ); | |
| 365 | 384 | } |
| 366 | 385 | |
| 367 | 386 | /** |
| 368 | 387 | * Drop the stored session row when it is too large to be read safely. |