wikimedia/mediawiki-core

View on GitHub
includes/session/SessionBackend.php

Summary

Maintainability
F
3 days
Test Coverage
<?php
/**
 * MediaWiki session backend
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 *
 * @file
 * @ingroup Session
 */

namespace MediaWiki\Session;

use CachedBagOStuff;
use InvalidArgumentException;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Request\WebRequest;
use MediaWiki\User\User;
use MWRestrictions;
use Psr\Log\LoggerInterface;
use Wikimedia\AtEase\AtEase;

/**
 * This is the actual workhorse for Session.
 *
 * Most code does not need to use this class, you want \MediaWiki\Session\Session.
 * The exceptions are SessionProviders and SessionMetadata hook functions,
 * which get an instance of this class rather than Session.
 *
 * The reasons for this split are:
 * 1. A session can be attached to multiple requests, but we want the Session
 *    object to have some features that correspond to just one of those
 *    requests.
 * 2. We want reasonable garbage collection behavior, but we also want the
 *    SessionManager to hold a reference to every active session so it can be
 *    saved when the request ends.
 *
 * @ingroup Session
 * @since 1.27
 */
final class SessionBackend {
    /** @var SessionId */
    private $id;

    /** @var bool */
    private $persist = false;

    /** @var bool */
    private $remember = false;

    /** @var bool */
    private $forceHTTPS = false;

    /** @var array|null */
    private $data = null;

    /** @var bool */
    private $forcePersist = false;

    /**
     * The reason for the next persistSession/unpersistSession call. Only used for logging. Can be:
     * - 'renew': triggered by a renew() call)
     * - 'no-store': the session was not found in the session store
     * - 'no-expiry': there was no expiry * in the session store data; this probably shouldn't happen
     * - null otherwise.
     * @var string|null
     */
    private $persistenceChangeType;

    /**
     * The data from the previous logPersistenceChange() log event. Used for deduplication.
     * @var array
     */
    private $persistenceChangeData = [];

    /** @var bool */
    private $metaDirty = false;

    /** @var bool */
    private $dataDirty = false;

    /** @var string Used to detect subarray modifications */
    private $dataHash = null;

    /** @var CachedBagOStuff */
    private $store;

    /** @var LoggerInterface */
    private $logger;

    /** @var HookRunner */
    private $hookRunner;

    /** @var int */
    private $lifetime;

    /** @var User */
    private $user;

    /** @var int */
    private $curIndex = 0;

    /** @var WebRequest[] Session requests */
    private $requests = [];

    /** @var SessionProvider provider */
    private $provider;

    /** @var array|null provider-specified metadata */
    private $providerMetadata = null;

    /** @var int */
    private $expires = 0;

    /** @var int */
    private $loggedOut = 0;

    /** @var int */
    private $delaySave = 0;

    /** @var bool */
    private $usePhpSessionHandling;
    /** @var bool */
    private $checkPHPSessionRecursionGuard = false;

    /** @var bool */
    private $shutdown = false;

    /**
     * @param SessionId $id
     * @param SessionInfo $info Session info to populate from
     * @param CachedBagOStuff $store Backend data store
     * @param LoggerInterface $logger
     * @param HookContainer $hookContainer
     * @param int $lifetime Session data lifetime in seconds
     */
    public function __construct(
        SessionId $id, SessionInfo $info, CachedBagOStuff $store, LoggerInterface $logger,
        HookContainer $hookContainer, $lifetime
    ) {
        $phpSessionHandling = MediaWikiServices::getInstance()->getMainConfig()
            ->get( MainConfigNames::PHPSessionHandling );
        $this->usePhpSessionHandling = $phpSessionHandling !== 'disable';

        if ( $info->getUserInfo() && !$info->getUserInfo()->isVerified() ) {
            throw new InvalidArgumentException(
                "Refusing to create session for unverified user {$info->getUserInfo()}"
            );
        }
        if ( $info->getProvider() === null ) {
            throw new InvalidArgumentException( 'Cannot create session without a provider' );
        }
        if ( $info->getId() !== $id->getId() ) {
            throw new InvalidArgumentException( 'SessionId and SessionInfo don\'t match' );
        }

        $this->id = $id;
        $this->user = $info->getUserInfo()
            ? $info->getUserInfo()->getUser()
            : MediaWikiServices::getInstance()->getUserFactory()->newAnonymous();
        $this->store = $store;
        $this->logger = $logger;
        $this->hookRunner = new HookRunner( $hookContainer );
        $this->lifetime = $lifetime;
        $this->provider = $info->getProvider();
        $this->persist = $info->wasPersisted();
        $this->remember = $info->wasRemembered();
        $this->forceHTTPS = $info->forceHTTPS();
        $this->providerMetadata = $info->getProviderMetadata();

        $blob = $store->get( $store->makeKey( 'MWSession', (string)$this->id ) );
        if ( !is_array( $blob ) ||
            !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] ) ||
            !isset( $blob['data'] ) || !is_array( $blob['data'] )
        ) {
            $this->data = [];
            $this->dataDirty = true;
            $this->metaDirty = true;
            $this->persistenceChangeType = 'no-store';
            $this->logger->debug(
                'SessionBackend "{session}" is unsaved, marking dirty in constructor',
                [
                    'session' => $this->id->__toString(),
                ] );
        } else {
            $this->data = $blob['data'];
            if ( isset( $blob['metadata']['loggedOut'] ) ) {
                $this->loggedOut = (int)$blob['metadata']['loggedOut'];
            }
            if ( isset( $blob['metadata']['expires'] ) ) {
                $this->expires = (int)$blob['metadata']['expires'];
            } else {
                $this->metaDirty = true;
                $this->persistenceChangeType = 'no-expiry';
                $this->logger->debug(
                    'SessionBackend "{session}" metadata dirty due to missing expiration timestamp',
                    [
                        'session' => $this->id->__toString(),
                    ] );
            }
        }
        $this->dataHash = md5( serialize( $this->data ) );
    }

    /**
     * Return a new Session for this backend
     * @param WebRequest $request
     * @return Session
     */
    public function getSession( WebRequest $request ) {
        $index = ++$this->curIndex;
        $this->requests[$index] = $request;
        $session = new Session( $this, $index, $this->logger );
        return $session;
    }

    /**
     * Deregister a Session
     * @internal For use by \MediaWiki\Session\Session::__destruct() only
     * @param int $index
     */
    public function deregisterSession( $index ) {
        unset( $this->requests[$index] );
        if ( !$this->shutdown && !count( $this->requests ) ) {
            $this->save( true );
            $this->provider->getManager()->deregisterSessionBackend( $this );
        }
    }

    /**
     * Shut down a session
     * @internal For use by \MediaWiki\Session\SessionManager::shutdown() only
     */
    public function shutdown() {
        $this->save( true );
        $this->shutdown = true;
    }

    /**
     * Returns the session ID.
     * @return string
     */
    public function getId() {
        return (string)$this->id;
    }

    /**
     * Fetch the SessionId object
     * @internal For internal use by WebRequest
     * @return SessionId
     */
    public function getSessionId() {
        return $this->id;
    }

    /**
     * Changes the session ID
     * @return string New ID (might be the same as the old)
     */
    public function resetId() {
        if ( $this->provider->persistsSessionId() ) {
            $oldId = (string)$this->id;
            $restart = $this->usePhpSessionHandling && $oldId === session_id() &&
                PHPSessionHandler::isEnabled();

            if ( $restart ) {
                // If this session is the one behind PHP's $_SESSION, we need
                // to close then reopen it.
                session_write_close();
            }

            $this->provider->getManager()->changeBackendId( $this );
            $this->provider->sessionIdWasReset( $this, $oldId );
            $this->metaDirty = true;
            $this->logger->debug(
                'SessionBackend "{session}" metadata dirty due to ID reset (formerly "{oldId}")',
                [
                    'session' => $this->id->__toString(),
                    'oldId' => $oldId,
                ] );

            if ( $restart ) {
                session_id( (string)$this->id );
                AtEase::quietCall( 'session_start' );
            }

            $this->autosave();

            // Delete the data for the old session ID now
            $this->store->delete( $this->store->makeKey( 'MWSession', $oldId ) );
        }

        return (string)$this->id;
    }

    /**
     * Fetch the SessionProvider for this session
     * @return SessionProviderInterface
     */
    public function getProvider() {
        return $this->provider;
    }

    /**
     * Indicate whether this session is persisted across requests
     *
     * For example, if cookies are set.
     *
     * @return bool
     */
    public function isPersistent() {
        return $this->persist;
    }

    /**
     * Make this session persisted across requests
     *
     * If the session is already persistent, equivalent to calling
     * $this->renew().
     */
    public function persist() {
        if ( !$this->persist ) {
            $this->persist = true;
            $this->forcePersist = true;
            $this->metaDirty = true;
            $this->logger->debug(
                'SessionBackend "{session}" force-persist due to persist()',
                [
                    'session' => $this->id->__toString(),
                ] );
            $this->autosave();
        } else {
            $this->renew();
        }
    }

    /**
     * Make this session not persisted across requests
     */
    public function unpersist() {
        if ( $this->persist ) {
            // Close the PHP session, if we're the one that's open
            if ( $this->usePhpSessionHandling && PHPSessionHandler::isEnabled() &&
                session_id() === (string)$this->id
            ) {
                $this->logger->debug(
                    'SessionBackend "{session}" Closing PHP session for unpersist',
                    [ 'session' => $this->id->__toString() ]
                );
                session_write_close();
                session_id( '' );
            }

            $this->persist = false;
            $this->forcePersist = true;
            $this->metaDirty = true;

            // Delete the session data, so the local cache-only write in
            // self::save() doesn't get things out of sync with the backend.
            $this->store->delete( $this->store->makeKey( 'MWSession', (string)$this->id ) );

            $this->autosave();
        }
    }

    /**
     * Indicate whether the user should be remembered independently of the
     * session ID.
     * @return bool
     */
    public function shouldRememberUser() {
        return $this->remember;
    }

    /**
     * Set whether the user should be remembered independently of the session
     * ID.
     * @param bool $remember
     */
    public function setRememberUser( $remember ) {
        if ( $this->remember !== (bool)$remember ) {
            $this->remember = (bool)$remember;
            $this->metaDirty = true;
            $this->logger->debug(
                'SessionBackend "{session}" metadata dirty due to remember-user change',
                [
                    'session' => $this->id->__toString(),
                ] );
            $this->autosave();
        }
    }

    /**
     * Returns the request associated with a Session
     * @param int $index Session index
     * @return WebRequest
     */
    public function getRequest( $index ) {
        if ( !isset( $this->requests[$index] ) ) {
            throw new InvalidArgumentException( 'Invalid session index' );
        }
        return $this->requests[$index];
    }

    /**
     * Returns the authenticated user for this session
     * @return User
     */
    public function getUser(): User {
        return $this->user;
    }

    /**
     * Fetch the rights allowed the user when this session is active.
     * @return null|string[] Allowed user rights, or null to allow all.
     */
    public function getAllowedUserRights() {
        return $this->provider->getAllowedUserRights( $this );
    }

    /**
     * Fetch any restrictions imposed on logins or actions when this
     * session is active.
     * @return MWRestrictions|null
     */
    public function getRestrictions(): ?MWRestrictions {
        return $this->provider->getRestrictions( $this->providerMetadata );
    }

    /**
     * Indicate whether the session user info can be changed
     * @return bool
     */
    public function canSetUser() {
        return $this->provider->canChangeUser();
    }

    /**
     * Set a new user for this session
     * @note This should only be called when the user has been authenticated via a login process
     * @param User $user User to set on the session.
     *   This may become a "UserValue" in the future, or User may be refactored
     *   into such.
     */
    public function setUser( $user ) {
        if ( !$this->canSetUser() ) {
            throw new \BadMethodCallException(
                'Cannot set user on this session; check $session->canSetUser() first'
            );
        }

        $this->user = $user;
        $this->metaDirty = true;
        $this->logger->debug(
            'SessionBackend "{session}" metadata dirty due to user change',
            [
                'session' => $this->id->__toString(),
            ] );
        $this->autosave();
    }

    /**
     * Get a suggested username for the login form
     * @param int $index Session index
     * @return string|null
     */
    public function suggestLoginUsername( $index ) {
        if ( !isset( $this->requests[$index] ) ) {
            throw new InvalidArgumentException( 'Invalid session index' );
        }
        return $this->provider->suggestLoginUsername( $this->requests[$index] );
    }

    /**
     * Whether HTTPS should be forced
     * @return bool
     */
    public function shouldForceHTTPS() {
        return $this->forceHTTPS;
    }

    /**
     * Set whether HTTPS should be forced
     * @param bool $force
     */
    public function setForceHTTPS( $force ) {
        if ( $this->forceHTTPS !== (bool)$force ) {
            $this->forceHTTPS = (bool)$force;
            $this->metaDirty = true;
            $this->logger->debug(
                'SessionBackend "{session}" metadata dirty due to force-HTTPS change',
                [
                    'session' => $this->id->__toString(),
                ] );
            $this->autosave();
        }
    }

    /**
     * Fetch the "logged out" timestamp
     * @return int
     */
    public function getLoggedOutTimestamp() {
        return $this->loggedOut;
    }

    /**
     * @param int|null $ts
     */
    public function setLoggedOutTimestamp( $ts = null ) {
        $ts = (int)$ts;
        if ( $this->loggedOut !== $ts ) {
            $this->loggedOut = $ts;
            $this->metaDirty = true;
            $this->logger->debug(
                'SessionBackend "{session}" metadata dirty due to logged-out-timestamp change',
                [
                    'session' => $this->id->__toString(),
                ] );
            $this->autosave();
        }
    }

    /**
     * Fetch provider metadata
     * @note For use by SessionProvider subclasses only
     * @return array|null
     */
    public function getProviderMetadata() {
        return $this->providerMetadata;
    }

    /**
     * @note For use by SessionProvider subclasses only
     * @param array|null $metadata
     */
    public function setProviderMetadata( $metadata ) {
        if ( $metadata !== null && !is_array( $metadata ) ) {
            throw new InvalidArgumentException( '$metadata must be an array or null' );
        }
        if ( $this->providerMetadata !== $metadata ) {
            $this->providerMetadata = $metadata;
            $this->metaDirty = true;
            $this->logger->debug(
                'SessionBackend "{session}" metadata dirty due to provider metadata change',
                [
                    'session' => $this->id->__toString(),
                ] );
            $this->autosave();
        }
    }

    /**
     * Fetch the session data array
     *
     * Note the caller is responsible for calling $this->dirty() if anything in
     * the array is changed.
     *
     * @internal For use by \MediaWiki\Session\Session only.
     * @return array
     */
    public function &getData() {
        return $this->data;
    }

    /**
     * Add data to the session.
     *
     * Overwrites any existing data under the same keys.
     *
     * @param array $newData Key-value pairs to add to the session
     */
    public function addData( array $newData ) {
        $data = &$this->getData();
        foreach ( $newData as $key => $value ) {
            if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) {
                $data[$key] = $value;
                $this->dataDirty = true;
                $this->logger->debug(
                    'SessionBackend "{session}" data dirty due to addData(): {callers}',
                    [
                        'session' => $this->id->__toString(),
                        'callers' => wfGetAllCallers( 5 ),
                    ] );
            }
        }
    }

    /**
     * Mark data as dirty
     * @internal For use by \MediaWiki\Session\Session only.
     */
    public function dirty() {
        $this->dataDirty = true;
        $this->logger->debug(
            'SessionBackend "{session}" data dirty due to dirty(): {callers}',
            [
                'session' => $this->id->__toString(),
                'callers' => wfGetAllCallers( 5 ),
            ] );
    }

    /**
     * Renew the session by resaving everything
     *
     * Resets the TTL in the backend store if the session is near expiring, and
     * re-persists the session to any active WebRequests if persistent.
     */
    public function renew() {
        if ( time() + $this->lifetime / 2 > $this->expires ) {
            $this->metaDirty = true;
            $this->logger->debug(
                'SessionBackend "{callers}" metadata dirty for renew(): {callers}',
                [
                    'session' => $this->id->__toString(),
                    'callers' => wfGetAllCallers( 5 ),
                ] );
            if ( $this->persist ) {
                $this->persistenceChangeType = 'renew';
                $this->forcePersist = true;
                $this->logger->debug(
                    'SessionBackend "{session}" force-persist for renew(): {callers}',
                    [
                        'session' => $this->id->__toString(),
                        'callers' => wfGetAllCallers( 5 ),
                    ] );
            }
        }
        $this->autosave();
    }

    /**
     * Delay automatic saving while multiple updates are being made
     *
     * Calls to save() will not be delayed.
     *
     * @return \Wikimedia\ScopedCallback When this goes out of scope, a save will be triggered
     */
    public function delaySave() {
        $this->delaySave++;
        return new \Wikimedia\ScopedCallback( function () {
            if ( --$this->delaySave <= 0 ) {
                $this->delaySave = 0;
                $this->save();
            }
        } );
    }

    /**
     * Save the session, unless delayed
     * @see SessionBackend::save()
     */
    private function autosave() {
        if ( $this->delaySave <= 0 ) {
            $this->save();
        }
    }

    /**
     * Save the session
     *
     * Update both the backend data and the associated WebRequest(s) to
     * reflect the state of the SessionBackend. This might include
     * persisting or unpersisting the session.
     *
     * @param bool $closing Whether the session is being closed
     */
    public function save( $closing = false ) {
        $anon = $this->user->isAnon();

        if ( !$anon && $this->provider->getManager()->isUserSessionPrevented( $this->user->getName() ) ) {
            $this->logger->debug(
                'SessionBackend "{session}" not saving, user {user} was ' .
                'passed to SessionManager::preventSessionsForUser',
                [
                    'session' => $this->id->__toString(),
                    'user' => $this->user->__toString(),
                ] );
            return;
        }

        // Ensure the user has a token
        // @codeCoverageIgnoreStart
        if ( !$anon && defined( 'MW_PHPUNIT_TEST' ) && MediaWikiServices::getInstance()->isStorageDisabled() ) {
            // Avoid making DB queries in non-database tests. We don't need to save the token when using
            // fake users, and it would probably be ignored anyway.
            return;
        }
        if ( !$anon && !$this->user->getToken( false ) ) {
            $this->logger->debug(
                'SessionBackend "{session}" creating token for user {user} on save',
                [
                    'session' => $this->id->__toString(),
                    'user' => $this->user->__toString(),
                ] );
            $this->user->setToken();
            if ( !MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly() ) {
                // Promise that the token set here will be valid; save it at end of request
                $user = $this->user;
                DeferredUpdates::addCallableUpdate( static function () use ( $user ) {
                    $user->saveSettings();
                } );
            }
            $this->metaDirty = true;
        }
        // @codeCoverageIgnoreEnd

        if ( !$this->metaDirty && !$this->dataDirty &&
            $this->dataHash !== md5( serialize( $this->data ) )
        ) {
            $this->logger->debug(
                'SessionBackend "{session}" data dirty due to hash mismatch, {expected} !== {got}',
                [
                    'session' => $this->id->__toString(),
                    'expected' => $this->dataHash,
                    'got' => md5( serialize( $this->data ) ),
                ] );
            $this->dataDirty = true;
        }

        if ( !$this->metaDirty && !$this->dataDirty && !$this->forcePersist ) {
            return;
        }

        $this->logger->debug(
            'SessionBackend "{session}" save: dataDirty={dataDirty} ' .
            'metaDirty={metaDirty} forcePersist={forcePersist}',
            [
                'session' => $this->id->__toString(),
                'dataDirty' => (int)$this->dataDirty,
                'metaDirty' => (int)$this->metaDirty,
                'forcePersist' => (int)$this->forcePersist,
            ] );

        // Persist or unpersist to the provider, if necessary
        if ( $this->metaDirty || $this->forcePersist ) {
            if ( $this->persist ) {
                foreach ( $this->requests as $request ) {
                    $request->setSessionId( $this->getSessionId() );
                    $this->logPersistenceChange( $request, true );
                    $this->provider->persistSession( $this, $request );
                }
                if ( !$closing ) {
                    $this->checkPHPSession();
                }
            } else {
                foreach ( $this->requests as $request ) {
                    if ( $request->getSessionId() === $this->id ) {
                        $this->logPersistenceChange( $request, false );
                        $this->provider->unpersistSession( $request );
                    }
                }
            }
        }

        $this->forcePersist = false;
        $this->persistenceChangeType = null;

        if ( !$this->metaDirty && !$this->dataDirty ) {
            return;
        }

        // Save session data to store, if necessary
        $metadata = $origMetadata = [
            'provider' => (string)$this->provider,
            'providerMetadata' => $this->providerMetadata,
            'userId' => $anon ? 0 : $this->user->getId(),
            'userName' => MediaWikiServices::getInstance()->getUserNameUtils()
                ->isValid( $this->user->getName() ) ? $this->user->getName() : null,
            'userToken' => $anon ? null : $this->user->getToken(),
            'remember' => !$anon && $this->remember,
            'forceHTTPS' => $this->forceHTTPS,
            'expires' => time() + $this->lifetime,
            'loggedOut' => $this->loggedOut,
            'persisted' => $this->persist,
        ];

        $this->hookRunner->onSessionMetadata( $this, $metadata, $this->requests );

        foreach ( $origMetadata as $k => $v ) {
            if ( $metadata[$k] !== $v ) {
                throw new \UnexpectedValueException( "SessionMetadata hook changed metadata key \"$k\"" );
            }
        }

        $flags = $this->persist ? 0 : CachedBagOStuff::WRITE_CACHE_ONLY;
        $this->store->set(
            $this->store->makeKey( 'MWSession', (string)$this->id ),
            [
                'data' => $this->data,
                'metadata' => $metadata,
            ],
            $metadata['expires'],
            $flags
        );

        $this->metaDirty = false;
        $this->dataDirty = false;
        $this->dataHash = md5( serialize( $this->data ) );
        $this->expires = $metadata['expires'];
    }

    /**
     * For backwards compatibility, open the PHP session when the global
     * session is persisted
     */
    private function checkPHPSession() {
        if ( !$this->checkPHPSessionRecursionGuard ) {
            $this->checkPHPSessionRecursionGuard = true;
            $reset = new \Wikimedia\ScopedCallback( function () {
                $this->checkPHPSessionRecursionGuard = false;
            } );

            if ( $this->usePhpSessionHandling && session_id() === '' && PHPSessionHandler::isEnabled() &&
                SessionManager::getGlobalSession()->getId() === (string)$this->id
            ) {
                $this->logger->debug(
                    'SessionBackend "{session}" Taking over PHP session',
                    [
                        'session' => $this->id->__toString(),
                    ] );
                session_id( (string)$this->id );
                AtEase::quietCall( 'session_start' );
            }
        }
    }

    /**
     * Helper method for logging persistSession/unpersistSession calls.
     * @param WebRequest $request
     * @param bool $persist True when persisting, false when unpersisting
     */
    private function logPersistenceChange( WebRequest $request, bool $persist ) {
        if ( !$this->isPersistent() && !$persist ) {
            // FIXME SessionManager calls unpersistSession() on anonymous requests (and the cookie
            //   filtering in WebResponse makes it a noop). Skip those.
            return;
        }

        $verb = $persist ? 'Persisting' : 'Unpersisting';
        if ( $this->persistenceChangeType === 'renew' ) {
            $message = "$verb session for renewal";
        } elseif ( $this->persistenceChangeType === 'no-store' ) {
            $message = "$verb session due to no pre-existing stored session";
        } elseif ( $this->persistenceChangeType === 'no-expiry' ) {
            $message = "$verb session due to lack of stored expiry";
        } elseif ( $this->persistenceChangeType === null ) {
            $message = "$verb session for unknown reason";
        }

        // Because SessionManager repeats session loading several times in the same request,
        // it will try to persist or unpersist several times. WebResponse deduplicates, but
        // we want to deduplicate logging as well since the volume is already fairly large.
        $id = $this->getId();
        $user = $this->getUser()->isAnon() ? '<anon>' : $this->getUser()->getName();
        if ( $this->persistenceChangeData
            && $this->persistenceChangeData['id'] === $id
            && $this->persistenceChangeData['user'] === $user
            // @phan-suppress-next-line PhanPossiblyUndeclaredVariable message always set
            && $this->persistenceChangeData['message'] === $message
        ) {
            return;
        }
        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable message always set
        $this->persistenceChangeData = [ 'id' => $id, 'user' => $user, 'message' => $message ];

        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable,PhanPossiblyUndeclaredVariable message always set
        $this->logger->info( $message, [
            'id' => $id,
            'provider' => get_class( $this->getProvider() ),
            'user' => $user,
            'clientip' => $request->getIP(),
            'userAgent' => $request->getHeader( 'user-agent' ),
        ] );
    }

}