session -- mirroring its own * get_sessions()/update_sessions() line for line: integer legacy sessions * normalised, expired ones dropped, and the meta row deleted rather than * stored empty. * * A site that swaps the manager through `session_token_manager` (Redis, * a custom table) is detected by supported() and gets NO per-session * operations at all rather than writes to a meta row its manager never * reads: the Sessions screen says so, and "log out everywhere" plus the * idle timeout keep working through the public API. A named limit, not a * silent one. * * The verifier never leaves this class towards a browser. Screens name a * session by handle(): an HMAC of the user id and verifier under the * site's `auth` salt, so markup carries nothing that is stored anywhere, * nothing that can be replayed against another user's session, and * nothing a database reader could match back to a row without the salt. */ final class SessionStore { /** Core's own user-meta key for WP_User_Meta_Session_Tokens. */ public const META_KEY = 'session_tokens'; /** * The key this plugin adds INSIDE a session's own array for its last * recorded activity (a Unix timestamp). Inside the session rather than * beside it so it lives and dies with the session: destroyed, expired * or logged out, the timestamp goes with it and nothing is left to * purge. */ public const LAST_ACTIVITY_KEY = 'ops404_al_last_activity'; /** Length of a session handle, in hex characters. */ public const HANDLE_LENGTH = 32; /** The class core uses unless a site filters `session_token_manager`. */ private const CORE_MANAGER = 'WP_User_Meta_Session_Tokens'; /** * Whether sessions are stored where this class can name them one by one. * The exact class, not instanceof: a subclass is free to have moved the * storage, and writing a meta row it never reads would be a destroy that * reports success and ends nothing. */ public static function supported(): bool { return self::CORE_MANAGER === self::manager_class(); } /** * The session manager class in effect, for the screen's notice. */ public static function manager_class(): string { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's own filter, read exactly as WP_Session_Tokens::get_instance() reads it. $class = apply_filters( 'session_token_manager', self::CORE_MANAGER ); return is_string( $class ) ? $class : ''; } /** * A user's unexpired sessions, verifier => session, newest login first. * Empty when unsupported(). * * @param int $user_id The user. * @return array> */ public static function sessions( int $user_id ): array { if ( ! self::supported() || $user_id <= 0 ) { return array(); } $stored = get_user_meta( $user_id, self::META_KEY, true ); if ( ! is_array( $stored ) ) { return array(); } $now = time(); $sessions = array(); foreach ( $stored as $verifier => $session ) { // Core's own prepare_session(): a pre-4.0 session is a bare expiry. if ( is_int( $session ) ) { $session = array( 'expiration' => $session ); } if ( ! is_string( $verifier ) || ! is_array( $session ) || ! isset( $session['expiration'] ) || (int) $session['expiration'] < $now ) { continue; } $sessions[ $verifier ] = $session; } uasort( $sessions, static function ( array $a, array $b ): int { return (int) ( $b['login'] ?? 0 ) <=> (int) ( $a['login'] ?? 0 ); } ); return $sessions; } /** * End one session by verifier. Returns whether it existed. * * @param int $user_id The session's owner. * @param string $verifier The session's verifier. */ public static function destroy( int $user_id, string $verifier ): bool { $sessions = self::sessions( $user_id ); if ( ! isset( $sessions[ $verifier ] ) ) { return false; } unset( $sessions[ $verifier ] ); if ( array() === $sessions ) { delete_user_meta( $user_id, self::META_KEY ); } else { update_user_meta( $user_id, self::META_KEY, $sessions ); } return true; } /** * The opaque name a screen uses for one session. * * @param int $user_id The session's owner. * @param string $verifier The session's verifier. */ public static function handle( int $user_id, string $verifier ): string { return substr( hash_hmac( 'sha256', $user_id . '|' . $verifier, wp_salt( 'auth' ) ), 0, self::HANDLE_LENGTH ); } /** * Which of the user's current sessions a handle names, or null. A * handle that is not the exact shape handle() produces is refused * before any comparison; comparisons are constant-time. * * @param int $user_id The session's owner. * @param string $handle The handle a request carried. */ public static function verifier_for_handle( int $user_id, string $handle ): ?string { if ( self::HANDLE_LENGTH !== strlen( $handle ) || ! ctype_xdigit( $handle ) ) { return null; } foreach ( array_keys( self::sessions( $user_id ) ) as $verifier ) { if ( hash_equals( self::handle( $user_id, $verifier ), strtolower( $handle ) ) ) { return $verifier; } } return null; } /** * The verifier of the session THIS request is signed in with, or ''. * Core hashes the token the same way (WP_Session_Tokens::hash_token()). */ public static function current_verifier(): string { $token = wp_get_session_token(); return '' !== $token ? hash( 'sha256', $token ) : ''; } /** * One page of users who hold at least one stored session, by login * name. "Stored" -- a user whose every session has expired still has * the meta row until core next writes it; the screen skips them. * * On multisite this lists the CURRENT site's users only (WP_User_Query's * default blog_id), though the sessions themselves are network-wide. * * @param int $page 1-based page. * @param int $per_page Users per page. * @return array{users: array, total: int} */ public static function users_with_sessions( int $page, int $per_page ): array { if ( ! self::supported() ) { return array( 'users' => array(), 'total' => 0, ); } $query = new \WP_User_Query( array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- the only index to "who is signed in" core keeps; bounded by number/paged. 'meta_key' => self::META_KEY, 'meta_compare' => 'EXISTS', 'orderby' => 'login', 'order' => 'ASC', 'number' => max( 1, $per_page ), 'paged' => max( 1, $page ), 'count_total' => true, ) ); $users = array_values( array_filter( (array) $query->get_results(), static function ( $user ): bool { return $user instanceof \WP_User; } ) ); return array( 'users' => $users, 'total' => (int) $query->get_total(), ); } }