exists() ) { return self::NOT_APPLICABLE; } $token = wp_get_session_token(); if ( '' === $token ) { // Application passwords, XML-RPC, WP-CLI: no browser session. return self::NOT_APPLICABLE; } $idle_seconds = SessionPolicy::idle_seconds_for( $user ); if ( $idle_seconds <= 0 ) { return self::NOT_APPLICABLE; } $manager = \WP_Session_Tokens::get_instance( $user->ID ); $session = $manager->get( $token ); if ( ! is_array( $session ) ) { return self::NOT_APPLICABLE; } $last = isset( $session[ SessionStore::LAST_ACTIVITY_KEY ] ) && is_numeric( $session[ SessionStore::LAST_ACTIVITY_KEY ] ) ? (int) $session[ SessionStore::LAST_ACTIVITY_KEY ] : null; // A session that predates the policy (or its user's timeout) starts // its clock now. Measuring it from its login time instead would end, // the moment a policy is saved, every long session that is in use // right now -- including the administrator's who saved it. if ( null === $last ) { $session[ SessionStore::LAST_ACTIVITY_KEY ] = $now; $manager->update( $token, $session ); return self::TOUCHED; } $throttle = SessionPolicy::throttle_seconds( $idle_seconds ); // Never early: see SessionPolicy::throttle_seconds(). if ( $now - $last >= $idle_seconds + $throttle ) { $manager->destroy( $token ); SessionLog::idle_expired( $user, $session, $idle_seconds ); wp_set_current_user( 0 ); return self::EXPIRED; } if ( $passive || $now - $last < $throttle ) { return self::ACTIVE; } $session[ SessionStore::LAST_ACTIVITY_KEY ] = $now; $manager->update( $token, $session ); return self::TOUCHED; } /** * `set_logged_in_cookie`: a session was just created (or re-issued) for * this user; enforce the limit around it. * * @param string $logged_in_cookie Unused. * @param int $expire Unused. * @param int $expiration Unused. * @param int $user_id The user. * @param string $scheme Unused. * @param string $token The new session's token. */ public static function on_logged_in_cookie( $logged_in_cookie, $expire, $expiration, $user_id, $scheme = '', $token = '' ): void { unset( $logged_in_cookie, $expire, $expiration, $scheme ); self::enforce_limit( (int) $user_id, (string) $token ); } /** * End the user's oldest sessions until at most their limit remain, * never the one named by $token. Returns how many were ended. * * @param int $user_id The user. * @param string $token The session to keep (just created). */ public static function enforce_limit( int $user_id, string $token ): int { $user = get_userdata( $user_id ); if ( ! $user instanceof \WP_User || '' === $token || ! SessionStore::supported() ) { return 0; } $limit = SessionPolicy::session_limit_for( $user ); if ( $limit <= 0 ) { return 0; } $keep = hash( 'sha256', $token ); $sessions = SessionStore::sessions( $user_id ); $excess = count( $sessions ) - $limit; if ( $excess <= 0 ) { return 0; } // sessions() is newest-first; the oldest are at the end. $oldest_first = array_reverse( $sessions, true ); $ended = 0; foreach ( $oldest_first as $verifier => $session ) { if ( $ended >= $excess ) { break; } if ( hash_equals( $keep, (string) $verifier ) ) { continue; } if ( SessionStore::destroy( $user_id, (string) $verifier ) ) { SessionLog::limit_evicted( $user, $session, $limit ); ++$ended; } } return $ended; } /** * `wp_login_errors`: say why, on the login screen an idle logout lands * on. The flag carries no data -- anyone can add it to a URL and see * only this sentence. * * @param mixed $errors Core's WP_Error of login messages. * @return mixed */ public static function login_message( $errors ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a display-only flag; no state changes. if ( ! $errors instanceof \WP_Error || ! isset( $_GET[ self::LOGIN_FLAG ] ) ) { return $errors; } $errors->add( 'ops404_al_idle', __( 'You were logged out because your session was inactive for too long. Please log in again.', '404ops-activity-log' ), 'message' ); return $errors; } /** * Whether this request is WordPress's Heartbeat poll. */ private static function is_heartbeat(): bool { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read to classify the request, never to act on it. return wp_doing_ajax() && isset( $_POST['action'] ) && 'heartbeat' === $_POST['action']; } }