From a73c5b7395a07d490f7052fd3b2491ebd656b190 Mon Sep 17 00:00:00 2001 From: Brad Jorsch Date: Tue, 22 Sep 2015 10:33:24 -0400 Subject: [PATCH] Add SessionManager SessionManager is a general-purpose session management framework, rather than the cookie-based sessions that PHP wants to provide us. While fallback is provided for using $_SESSION and other PHP session management functions, they should be avoided in favor of using SessionManager directly. For proof-of-concept extensions, see OAuth change Ib40b221 and CentralAuth change I27ccabdb. Bug: T111296 Change-Id: Ic1ffea74f3ccc8f93c8a23b795ecab6f06abca72 --- RELEASE-NOTES-1.27 | 29 + autoload.php | 13 +- composer.json | 1 + docs/hooks.txt | 32 +- includes/DefaultSettings.php | 43 +- includes/DerivativeRequest.php | 4 + includes/FauxRequest.php | 44 +- includes/GlobalFunctions.php | 88 +- includes/MediaWiki.php | 4 +- includes/OutputPage.php | 18 +- includes/Setup.php | 102 +- includes/WebRequest.php | 64 +- includes/WebResponse.php | 59 +- includes/actions/RawAction.php | 3 +- includes/actions/SubmitAction.php | 6 +- includes/api/ApiCreateAccount.php | 6 +- includes/api/ApiLogin.php | 20 +- includes/api/ApiLogout.php | 10 + includes/api/ApiMain.php | 2 +- includes/context/RequestContext.php | 36 +- includes/jobqueue/jobs/UploadFromUrlJob.php | 56 +- .../objectcache/ObjectCacheSessionHandler.php | 207 -- includes/session/CookieSessionProvider.php | 324 ++++ .../ImmutableSessionProviderWithCookie.php | 153 ++ includes/session/PHPSessionHandler.php | 369 ++++ includes/session/Session.php | 364 ++++ includes/session/SessionBackend.php | 624 ++++++ includes/session/SessionId.php | 70 + includes/session/SessionInfo.php | 270 +++ includes/session/SessionManager.php | 997 ++++++++++ includes/session/SessionManagerInterface.php | 109 ++ includes/session/SessionProvider.php | 473 +++++ includes/session/SessionProviderInterface.php | 54 + includes/session/UserInfo.php | 187 ++ includes/specials/SpecialUserlogin.php | 27 +- includes/specials/SpecialUserlogout.php | 12 + includes/upload/UploadFromUrl.php | 2 +- includes/user/User.php | 226 +-- languages/i18n/en.json | 12 +- languages/i18n/qqq.json | 12 +- tests/TestsAutoLoader.php | 8 + tests/phpunit/MediaWikiTestCase.php | 15 + tests/phpunit/includes/TestLogger.php | 105 + tests/phpunit/includes/api/ApiMainTest.php | 3 +- tests/phpunit/includes/api/ApiTestCase.php | 6 +- .../includes/api/ApiTestCaseUpload.php | 2 - .../includes/context/RequestContextTest.php | 19 +- .../session/CookieSessionProviderTest.php | 726 +++++++ ...ImmutableSessionProviderWithCookieTest.php | 301 +++ .../session/PHPSessionHandlerTest.php | 353 ++++ .../includes/session/SessionBackendTest.php | 746 ++++++++ .../includes/session/SessionIdTest.php | 22 + .../includes/session/SessionInfoTest.php | 328 ++++ .../includes/session/SessionManagerTest.php | 1683 +++++++++++++++++ .../includes/session/SessionProviderTest.php | 177 ++ .../phpunit/includes/session/SessionTest.php | 201 ++ .../includes/session/TestBagOStuff.php | 78 + tests/phpunit/includes/session/TestUtils.php | 99 + .../phpunit/includes/session/UserInfoTest.php | 186 ++ .../includes/upload/UploadFromUrlTest.php | 8 +- tests/phpunit/includes/user/UserTest.php | 85 - .../mocks/session/DummySessionBackend.php | 29 + .../mocks/session/DummySessionProvider.php | 60 + tests/phpunit/phpunit.php | 14 + 64 files changed, 9724 insertions(+), 662 deletions(-) delete mode 100644 includes/objectcache/ObjectCacheSessionHandler.php create mode 100644 includes/session/CookieSessionProvider.php create mode 100644 includes/session/ImmutableSessionProviderWithCookie.php create mode 100644 includes/session/PHPSessionHandler.php create mode 100644 includes/session/Session.php create mode 100644 includes/session/SessionBackend.php create mode 100644 includes/session/SessionId.php create mode 100644 includes/session/SessionInfo.php create mode 100644 includes/session/SessionManager.php create mode 100644 includes/session/SessionManagerInterface.php create mode 100644 includes/session/SessionProvider.php create mode 100644 includes/session/SessionProviderInterface.php create mode 100644 includes/session/UserInfo.php create mode 100644 tests/phpunit/includes/TestLogger.php create mode 100644 tests/phpunit/includes/session/CookieSessionProviderTest.php create mode 100644 tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php create mode 100644 tests/phpunit/includes/session/PHPSessionHandlerTest.php create mode 100644 tests/phpunit/includes/session/SessionBackendTest.php create mode 100644 tests/phpunit/includes/session/SessionIdTest.php create mode 100644 tests/phpunit/includes/session/SessionInfoTest.php create mode 100644 tests/phpunit/includes/session/SessionManagerTest.php create mode 100644 tests/phpunit/includes/session/SessionProviderTest.php create mode 100644 tests/phpunit/includes/session/SessionTest.php create mode 100644 tests/phpunit/includes/session/TestBagOStuff.php create mode 100644 tests/phpunit/includes/session/TestUtils.php create mode 100644 tests/phpunit/includes/session/UserInfoTest.php create mode 100644 tests/phpunit/mocks/session/DummySessionBackend.php create mode 100644 tests/phpunit/mocks/session/DummySessionProvider.php diff --git a/RELEASE-NOTES-1.27 b/RELEASE-NOTES-1.27 index 32f2939c72..73b53d201c 100644 --- a/RELEASE-NOTES-1.27 +++ b/RELEASE-NOTES-1.27 @@ -62,6 +62,30 @@ production. $wgSharedDB and $wgSharedTables are properly set even on the "central" wiki that all others are sharing from and that $wgLocalDatabases is set to the full list of sharing wikis on all those wikis. +* Massive overhaul to session handling: +** $wgSessionsInObjectCache is no longer supported and must be true, due to + MediaWiki\Session\SessionManager. $wgSessionHandler is similarly no longer + used. +** ObjectCacheSessionHandler is removed, replaced with + MediaWiki\Session\PhpSessionHandler. +** PHP session handling in general ($_SESSION, session_id(), and so on) is + deprecated. Use MediaWiki\Session\SessionManager instead. A new config + variable, $wgPHPSessionHandling, is available to cause use of $_SESSION to + issue a deprecation warning or to cause most PHP session handling to throw + exceptions. +** Deprecated UserSetCookies hook. Session-handling extensions should generally + be creating a custom subclass of CookieSessionProvider. Other extensions + messing with cookies can no longer count on user data being saved in cookies + versus other methods. +** Deprecated UserLoadFromSession hook, extensions should create a + MediaWiki\Session\SessionProvider. +** The User cannot be loaded from session until after Setup.php completes. + Attempts to do so will be ignored and the User will remain unloaded. +* MediaWiki will now auto-create users as necessary, removing the need for + extensions to do so. An 'autocreateaccount' right is added to allow + auto-creation when 'createaccount' is not granted to all users. +* Deprecated AuthPluginAutoCreate hook in favor of LocalUserCreated. +* Most cookie-handling methods in User are deprecated. === New features in 1.27 === * $wgDataCenterId and $wgDataCenterRoles where added, which will serve as @@ -106,6 +130,10 @@ production. * It is now possible to patrol file uploads (both for new files and new versions of existing files). Special:NewFiles has gained an option to filter by patrol status. This functionality can be disabled using $wgUseFilePatrol. +* MediaWiki\Session infrastructure allows for easier use of session mechanisms + other than the usual cookies. +** SessionMetadata and SessionCheckInfo hooks allow for setting and checking + custom session metadata. === External library changes in 1.27 === @@ -119,6 +147,7 @@ production. * Added wikimedia/cldr-plural-rule-parser v1.0.0. * Added wikimedia/relpath v1.0.3. * Added wikimedia/running-stat v1.1.0. +* Added wikimedia/php-session-serializer v1.0.3. ==== Removed and replaced external libraries ==== diff --git a/autoload.php b/autoload.php index 1f8c93a85c..ecbb4bd76e 100644 --- a/autoload.php +++ b/autoload.php @@ -778,6 +778,18 @@ $wgAutoloadLocalClasses = array( 'MediaWiki\\Logger\\Monolog\\WikiProcessor' => __DIR__ . '/includes/debug/logger/monolog/WikiProcessor.php', 'MediaWiki\\Logger\\NullSpi' => __DIR__ . '/includes/debug/logger/NullSpi.php', 'MediaWiki\\Logger\\Spi' => __DIR__ . '/includes/debug/logger/Spi.php', + 'MediaWiki\\Session\\CookieSessionProvider' => __DIR__ . '/includes/session/CookieSessionProvider.php', + 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' => __DIR__ . '/includes/session/ImmutableSessionProviderWithCookie.php', + 'MediaWiki\\Session\\PHPSessionHandler' => __DIR__ . '/includes/session/PHPSessionHandler.php', + 'MediaWiki\\Session\\Session' => __DIR__ . '/includes/session/Session.php', + 'MediaWiki\\Session\\SessionBackend' => __DIR__ . '/includes/session/SessionBackend.php', + 'MediaWiki\\Session\\SessionId' => __DIR__ . '/includes/session/SessionId.php', + 'MediaWiki\\Session\\SessionInfo' => __DIR__ . '/includes/session/SessionInfo.php', + 'MediaWiki\\Session\\SessionManager' => __DIR__ . '/includes/session/SessionManager.php', + 'MediaWiki\\Session\\SessionManagerInterface' => __DIR__ . '/includes/session/SessionManagerInterface.php', + 'MediaWiki\\Session\\SessionProvider' => __DIR__ . '/includes/session/SessionProvider.php', + 'MediaWiki\\Session\\SessionProviderInterface' => __DIR__ . '/includes/session/SessionProviderInterface.php', + 'MediaWiki\\Session\\UserInfo' => __DIR__ . '/includes/session/UserInfo.php', 'MediaWiki\\Site\\MediaWikiPageNameNormalizer' => __DIR__ . '/includes/site/MediaWikiPageNameNormalizer.php', 'MediaWiki\\Tidy\\Html5Depurate' => __DIR__ . '/includes/tidy/Html5Depurate.php', 'MediaWiki\\Tidy\\RaggettBase' => __DIR__ . '/includes/tidy/RaggettBase.php', @@ -862,7 +874,6 @@ $wgAutoloadLocalClasses = array( 'ORAField' => __DIR__ . '/includes/db/DatabaseOracle.php', 'ORAResult' => __DIR__ . '/includes/db/DatabaseOracle.php', 'ObjectCache' => __DIR__ . '/includes/objectcache/ObjectCache.php', - 'ObjectCacheSessionHandler' => __DIR__ . '/includes/objectcache/ObjectCacheSessionHandler.php', 'ObjectFactory' => __DIR__ . '/includes/libs/ObjectFactory.php', 'ObjectFileCache' => __DIR__ . '/includes/cache/ObjectFileCache.php', 'OldChangesList' => __DIR__ . '/includes/changes/OldChangesList.php', diff --git a/composer.json b/composer.json index 89ad8a6d6e..8e64a3ce7e 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "wikimedia/cldr-plural-rule-parser": "1.0.0", "wikimedia/composer-merge-plugin": "1.3.0", "wikimedia/ip-set": "1.0.1", + "wikimedia/php-session-serializer": "1.0.3", "wikimedia/relpath": "1.0.3", "wikimedia/running-stat": "1.1.0", "wikimedia/utfnormal": "1.0.3", diff --git a/docs/hooks.txt b/docs/hooks.txt index 135a11363c..5e50cec333 100644 --- a/docs/hooks.txt +++ b/docs/hooks.txt @@ -741,8 +741,9 @@ viewing. redirect was followed. &$article: target article (object) -'AuthPluginAutoCreate': Called when creating a local account for an user logged -in from an external authentication method. +'AuthPluginAutoCreate': DEPRECATED! Use the 'LocalUserCreated' hook instead. +Called when creating a local account for an user logged in from an external +authentication method. $user: User object created locally 'AuthPluginSetup': Update or replace authentication plugin object ($wgAuth). @@ -2574,6 +2575,20 @@ $targetUser: the user whom to send watchlist email notification $title: the page title $enotif: EmailNotification object +'SessionCheckInfo': Validate a MediaWiki\Session\SessionInfo as it's being +loaded from storage. Return false to prevent it from being used. +&$reason: String rejection reason to be logged +$info: MediaWiki\Session\SessionInfo being validated +$request: WebRequest being loaded from +$metadata: Array|false Metadata array for the MediaWiki\Session\Session +$data: Array|false Data array for the MediaWiki\Session\Session + +'SessionMetadata': Add metadata to a session being saved. +$backend: MediaWiki\Session\SessionBackend being saved. +&$metadata: Array Metadata to be stored. Add new keys here. +$requests: Array of WebRequests potentially being saved to. Generally 0-1 real + request and 0+ FauxRequests. + 'SetupAfterCache': Called in Setup.php, after cache objects are set 'ShortPagesQuery': Allow extensions to modify the query used by @@ -3292,8 +3307,9 @@ $name: user name $user: user object &$s: database query object -'UserLoadFromSession': Called to authenticate users on external/environmental -means; occurs before session is loaded. +'UserLoadFromSession': DEPRECATED! Create a MediaWiki\Session\SessionProvider instead. +Called to authenticate users on external/environmental means; occurs before +session is loaded. $user: user object being loaded &$result: set this to a boolean value to abort the normal authentication process @@ -3384,9 +3400,13 @@ $user: User object 'UserSaveSettings': Called when saving user settings. $user: User object -'UserSetCookies': Called when setting user cookies. +'UserSetCookies': DEPRECATED! If you're trying to replace core session cookie +handling, you want to create a subclass of MediaWiki\Session\CookieSessionProvider +instead. Otherwise, you can no longer count on user data being saved to cookies +versus some other mechanism. +Called when setting user cookies. $user: User object -&$session: session array, will be added to $_SESSION +&$session: session array, will be added to the session &$cookies: cookies array mapping cookie name to its value 'UserSetEmail': Called when changing user email address. diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index d46bc03482..e56ad963e3 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -2181,7 +2181,7 @@ $wgMessageCacheType = CACHE_ANYTHING; $wgParserCacheType = CACHE_ANYTHING; /** - * The cache type for storing session data. Used if $wgSessionsInObjectCache is true. + * The cache type for storing session data. * * For available types see $wgMainCacheType. */ @@ -2316,30 +2316,29 @@ $wgParserCacheExpireTime = 86400; * * @deprecated since 1.20; Use $wgSessionsInObjectCache */ -$wgSessionsInMemcached = false; +$wgSessionsInMemcached = true; /** - * Store sessions in an object cache, configured by $wgSessionCacheType. This - * can be useful to improve performance, or to avoid the locking behavior of - * PHP's default session handler, which tends to prevent multiple requests for - * the same user from acting concurrently. + * @deprecated since 1.27, session data is always stored in object cache. */ -$wgSessionsInObjectCache = false; +$wgSessionsInObjectCache = true; /** - * The expiry time to use for session storage when $wgSessionsInObjectCache is - * enabled, in seconds. + * The expiry time to use for session storage, in seconds. */ $wgObjectCacheSessionExpiry = 3600; /** - * This is used for setting php's session.save_handler. In practice, you will - * almost never need to change this ever. Other options might be 'user' or - * 'session_mysql.' Setting to null skips setting this entirely (which might be - * useful if you're doing cross-application sessions, see bug 11381) + * @deprecated since 1.27, MediaWiki\\Session\\SessionManager doesn't use PHP session storage. */ $wgSessionHandler = null; +/** + * Whether to use PHP session handling ($_SESSION and session_*() functions) + * @var string 'enable', 'warn', or 'disable' + */ +$wgPHPSessionHandling = 'enable'; + /** * If enabled, will send MemCached debugging information to $wgDebugLogFile */ @@ -4660,6 +4659,24 @@ $wgUserrightsInterwikiDelimiter = '@'; */ $wgSecureLogin = false; +/** + * MediaWiki\Session\SessionProvider configuration. + * + * Value is an array of ObjectFactory specifications for the SessionProviders + * to be used. Keys in the array are ignored. Order is not significant. + * + * @since 1.27 + */ +$wgSessionProviders = array( + 'MediaWiki\\Session\\CookieSessionProvider' => array( + 'class' => 'MediaWiki\\Session\\CookieSessionProvider', + 'args' => array( array( + 'priority' => 30, + 'callUserSetCookiesHook' => true, + ) ), + ), +); + /** @} */ # end user accounts } /************************************************************************//** diff --git a/includes/DerivativeRequest.php b/includes/DerivativeRequest.php index dda1358f11..4c149ae3ef 100644 --- a/includes/DerivativeRequest.php +++ b/includes/DerivativeRequest.php @@ -61,6 +61,10 @@ class DerivativeRequest extends FauxRequest { return $this->base->getAllHeaders(); } + public function getSession() { + return $this->base->getSession(); + } + public function getSessionData( $key ) { return $this->base->getSessionData( $key ); } diff --git a/includes/FauxRequest.php b/includes/FauxRequest.php index 888f853a4d..f049d2ece0 100644 --- a/includes/FauxRequest.php +++ b/includes/FauxRequest.php @@ -23,6 +23,8 @@ * @file */ +use MediaWiki\Session\SessionManager; + /** * WebRequest clone which takes values from a provided array. * @@ -30,7 +32,6 @@ */ class FauxRequest extends WebRequest { private $wasPosted = false; - private $session = array(); private $requestUrl; protected $cookies = array(); @@ -38,7 +39,8 @@ class FauxRequest extends WebRequest { * @param array $data Array of *non*-urlencoded key => value pairs, the * fake GET/POST values * @param bool $wasPosted Whether to treat the data as POST - * @param array|null $session Session array or null + * @param MediaWiki\\Session\\Session|array|null $session Session, session + * data array, or null * @param string $protocol 'http' or 'https' * @throws MWException */ @@ -53,8 +55,16 @@ class FauxRequest extends WebRequest { throw new MWException( "FauxRequest() got bogus data" ); } $this->wasPosted = $wasPosted; - if ( $session ) { - $this->session = $session; + if ( $session instanceof MediaWiki\Session\Session ) { + $this->sessionId = $session->getSessionId(); + } elseif ( is_array( $session ) ) { + $mwsession = SessionManager::singleton()->getEmptySession( $this ); + $this->sessionId = $mwsession->getSessionId(); + foreach ( $session as $key => $value ) { + $mwsession->set( $key, $value ); + } + } elseif ( $session !== null ) { + throw new MWException( "FauxRequest() got bogus session" ); } $this->protocol = $protocol; } @@ -140,10 +150,6 @@ class FauxRequest extends WebRequest { } } - public function checkSessionCookie() { - return false; - } - /** * @since 1.25 */ @@ -186,31 +192,15 @@ class FauxRequest extends WebRequest { } /** - * @param string $key * @return array|null */ - public function getSessionData( $key ) { - if ( isset( $this->session[$key] ) ) { - return $this->session[$key]; + public function getSessionArray() { + if ( $this->sessionId !== null ) { + return iterator_to_array( $this->getSession() ); } return null; } - /** - * @param string $key - * @param array $data - */ - public function setSessionData( $key, $data ) { - $this->session[$key] = $data; - } - - /** - * @return array|mixed|null - */ - public function getSessionArray() { - return $this->session; - } - /** * FauxRequests shouldn't depend on raw request data (but that could be implemented here) * @return string diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php index 26fb2239f2..eda636a996 100644 --- a/includes/GlobalFunctions.php +++ b/includes/GlobalFunctions.php @@ -26,6 +26,7 @@ if ( !defined( 'MEDIAWIKI' ) ) { use Liuggio\StatsdClient\Sender\SocketSender; use MediaWiki\Logger\LoggerFactory; +use MediaWiki\Session\SessionManager; // Hide compatibility functions from Doxygen /// @cond @@ -3007,9 +3008,12 @@ function wfBaseConvert( $input, $sourceBase, $destBase, $pad = 1, /** * Check if there is sufficient entropy in php's built-in session generation * + * @deprecated since 1.27, PHP's session generation isn't used with + * MediaWiki\\Session\\SessionManager * @return bool True = there is sufficient entropy */ function wfCheckEntropy() { + wfDeprecated( __FUNCTION__, '1.27' ); return ( ( wfIsWindows() && version_compare( PHP_VERSION, '5.3.3', '>=' ) ) || ini_get( 'session.entropy_file' ) @@ -3018,83 +3022,65 @@ function wfCheckEntropy() { } /** - * Override session_id before session startup if php's built-in - * session generation code is not secure. + * @deprecated since 1.27, PHP's session generation isn't used with + * MediaWiki\\Session\\SessionManager */ function wfFixSessionID() { - // If the cookie or session id is already set we already have a session and should abort - if ( isset( $_COOKIE[session_name()] ) || session_id() ) { - return; - } - - // PHP's built-in session entropy is enabled if: - // - entropy_file is set or you're on Windows with php 5.3.3+ - // - AND entropy_length is > 0 - // We treat it as disabled if it doesn't have an entropy length of at least 32 - $entropyEnabled = wfCheckEntropy(); - - // If built-in entropy is not enabled or not sufficient override PHP's - // built in session id generation code - if ( !$entropyEnabled ) { - wfDebug( __METHOD__ . ": PHP's built in entropy is disabled or not sufficient, " . - "overriding session id generation using our cryptrand source.\n" ); - session_id( MWCryptRand::generateHex( 32 ) ); - } + wfDeprecated( __FUNCTION__, '1.27' ); } /** - * Reset the session_id + * Reset the session id * + * @deprecated since 1.27, use MediaWiki\\Session\\SessionManager instead * @since 1.22 */ function wfResetSessionID() { - global $wgCookieSecure; - $oldSessionId = session_id(); - $cookieParams = session_get_cookie_params(); - if ( wfCheckEntropy() && $wgCookieSecure == $cookieParams['secure'] ) { - session_regenerate_id( false ); - } else { - $tmp = $_SESSION; - session_destroy(); - wfSetupSession( MWCryptRand::generateHex( 32 ) ); - $_SESSION = $tmp; + wfDeprecated( __FUNCTION__, '1.27' ); + $session = SessionManager::getGlobalSession(); + $delay = $session->delaySave(); + + $session->resetId(); + + // Make sure a session is started, since that's what the old + // wfResetSessionID() did. + if ( session_id() !== $session->getId() ) { + wfSetupSession( $session->getId() ); } - $newSessionId = session_id(); + + ScopedCallback::consume( $delay ); } /** * Initialise php session * - * @param bool $sessionId + * @deprecated since 1.27, use MediaWiki\\Session\\SessionManager instead. + * Generally, "using" SessionManager will be calling ->getSessionById() or + * ::getGlobalSession() (depending on whether you were passing $sessionId + * here), then calling $session->persist(). + * @param bool|string $sessionId */ function wfSetupSession( $sessionId = false ) { - global $wgSessionsInObjectCache, $wgSessionHandler; - global $wgCookiePath, $wgCookieDomain, $wgCookieSecure, $wgCookieHttpOnly; + wfDeprecated( __FUNCTION__, '1.27' ); - if ( $wgSessionsInObjectCache ) { - ObjectCacheSessionHandler::install(); - } elseif ( $wgSessionHandler && $wgSessionHandler != ini_get( 'session.save_handler' ) ) { - # Only set this if $wgSessionHandler isn't null and session.save_handler - # hasn't already been set to the desired value (that causes errors) - ini_set( 'session.save_handler', $wgSessionHandler ); + // If they're calling this, they probably want our session management even + // if NO_SESSION was set for Setup.php. + if ( !MediaWiki\Session\PHPSessionHandler::isInstalled() ) { + MediaWiki\Session\PHPSessionHandler::install( SessionManager::singleton() ); } - session_set_cookie_params( - 0, $wgCookiePath, $wgCookieDomain, $wgCookieSecure, $wgCookieHttpOnly ); - session_cache_limiter( 'private, must-revalidate' ); if ( $sessionId ) { session_id( $sessionId ); - } else { - wfFixSessionID(); } - MediaWiki\suppressWarnings(); - session_start(); - MediaWiki\restoreWarnings(); + $session = SessionManager::getGlobalSession(); + $session->persist(); - if ( $wgSessionsInObjectCache ) { - ObjectCacheSessionHandler::renewCurrentSession(); + if ( session_id() !== $session->getId() ) { + session_id( $session->getId() ); } + + MediaWiki\quietCall( 'session_start' ); } /** diff --git a/includes/MediaWiki.php b/includes/MediaWiki.php index 3b5a1b107c..11afa75977 100644 --- a/includes/MediaWiki.php +++ b/includes/MediaWiki.php @@ -664,8 +664,10 @@ class MediaWiki { if ( $request->getProtocol() == 'http' && ( + $request->getSession()->shouldForceHTTPS() || + // Check the cookie manually, for paranoia $request->getCookie( 'forceHTTPS', '' ) || - // check for prefixed version for currently logged in users + // check for prefixed version that was used for a time in older MW versions $request->getCookie( 'forceHTTPS' ) || // Avoid checking the user and groups unless it's enabled. ( diff --git a/includes/OutputPage.php b/includes/OutputPage.php index 97165b4613..93ba702238 100644 --- a/includes/OutputPage.php +++ b/includes/OutputPage.php @@ -21,6 +21,7 @@ */ use MediaWiki\Logger\LoggerFactory; +use MediaWiki\Session\SessionManager; use WrappedString\WrappedString; /** @@ -1977,11 +1978,9 @@ class OutputPage extends ContextSource { if ( $cookies === null ) { $config = $this->getConfig(); $cookies = array_merge( + SessionManager::singleton()->getVaryCookies(), array( - $config->get( 'CookiePrefix' ) . 'Token', - $config->get( 'CookiePrefix' ) . 'LoggedOut', - "forceHTTPS", - session_name() + 'forceHTTPS', ), $config->get( 'CacheVaryCookies' ) ); @@ -2033,6 +2032,9 @@ class OutputPage extends ContextSource { * @return string */ public function getVaryHeader() { + foreach ( SessionManager::singleton()->getVaryHeaders() as $header => $options ) { + $this->addVaryHeader( $header, $options ); + } return 'Vary: ' . join( ', ', array_keys( $this->mVaryHeader ) ); } @@ -2050,6 +2052,10 @@ class OutputPage extends ContextSource { } $this->addVaryHeader( 'Cookie', $cookiesOption ); + foreach ( SessionManager::singleton()->getVaryHeaders() as $header => $options ) { + $this->addVaryHeader( $header, $options ); + } + $headers = array(); foreach ( $this->mVaryHeader as $header => $option ) { $newheader = $header; @@ -2173,8 +2179,8 @@ class OutputPage extends ContextSource { if ( $this->mEnableClientCache ) { if ( - $config->get( 'UseSquid' ) && session_id() == '' && !$this->isPrintable() && - $this->mCdnMaxage != 0 && !$this->haveCacheVaryCookies() + $config->get( 'UseSquid' ) && !SessionManager::getGlobalSession()->isPersistent() && + !$this->isPrintable() && $this->mCdnMaxage != 0 && !$this->haveCacheVaryCookies() ) { if ( $config->get( 'UseESI' ) ) { # We'll purge the proxy cache explicitly, but require end user agents diff --git a/includes/Setup.php b/includes/Setup.php index 2723258585..b7d0f4245b 100644 --- a/includes/Setup.php +++ b/includes/Setup.php @@ -497,10 +497,25 @@ if ( $wgMaximalPasswordLength !== false ) { $wgPasswordPolicy['policies']['default']['MaximalPasswordLength'] = $wgMaximalPasswordLength; } -// Backwards compatibility with deprecated alias -// Must be before call to wfSetupSession() -if ( $wgSessionsInMemcached ) { - $wgSessionsInObjectCache = true; +// Backwards compatibility warning +if ( !$wgSessionsInObjectCache && !$wgSessionsInMemcached ) { + wfDeprecated( '$wgSessionsInObjectCache = false', '1.27' ); + if ( $wgSessionHandler ) { + wfDeprecated( '$wgSessionsHandler', '1.27' ); + } + $cacheType = get_class( ObjectCache::getInstance( $wgSessionCacheType ) ); + wfDebugLog( + "Session data will be stored in \"$cacheType\" cache with " . + "expiry $wgObjectCacheSessionExpiry seconds" + ); +} +$wgSessionsInObjectCache = true; + +if ( $wgPHPSessionHandling !== 'enable' && + $wgPHPSessionHandling !== 'warn' && + $wgPHPSessionHandling !== 'disable' +) { + $wgPHPSessionHandling = 'warn'; } Profiler::instance()->scopedProfileOut( $ps_default ); @@ -655,20 +670,6 @@ Profiler::instance()->scopedProfileOut( $ps_memcached ); // Most of the config is out, some might want to run hooks here. Hooks::run( 'SetupAfterCache' ); -$ps_session = Profiler::instance()->scopedProfileIn( $fname . '-session' ); - -if ( !defined( 'MW_NO_SESSION' ) && !$wgCommandLineMode ) { - // If session.auto_start is there, we can't touch session name - if ( !wfIniGetBool( 'session.auto_start' ) ) { - session_name( $wgSessionName ? $wgSessionName : $wgCookiePrefix . '_session' ); - } - - if ( $wgRequest->checkSessionCookie() || isset( $_COOKIE[$wgCookiePrefix . 'Token'] ) ) { - wfSetupSession(); - } -} - -Profiler::instance()->scopedProfileOut( $ps_session ); $ps_globals = Profiler::instance()->scopedProfileIn( $fname . '-globals' ); /** @@ -681,6 +682,56 @@ $wgContLang->initContLang(); // Now that variant lists may be available... $wgRequest->interpolateTitle(); +if ( !is_object( $wgAuth ) ) { + $wgAuth = new AuthPlugin; + Hooks::run( 'AuthPluginSetup', array( &$wgAuth ) ); +} + +// Set up the session +$ps_session = Profiler::instance()->scopedProfileIn( $fname . '-session' ); +if ( !defined( 'MW_NO_SESSION' ) && !$wgCommandLineMode ) { + // If session.auto_start is there, we can't touch session name + if ( $wgPHPSessionHandling !== 'disable' && !wfIniGetBool( 'session.auto_start' ) ) { + session_name( $wgSessionName ? $wgSessionName : $wgCookiePrefix . '_session' ); + } + + // Create the SessionManager singleton and set up our session handler + MediaWiki\Session\PHPSessionHandler::install( + MediaWiki\Session\SessionManager::singleton() + ); + + // Initialize the session + try { + $session = MediaWiki\Session\SessionManager::getGlobalSession(); + } catch ( OverflowException $ex ) { + if ( isset( $ex->sessionInfos ) && count( $ex->sessionInfos ) >= 2 ) { + // The exception is because the request had multiple possible + // sessions tied for top priority. Report this to the user. + $list = array(); + foreach ( $ex->sessionInfos as $info ) { + $list[] = $info->getProvider()->describe( $wgContLang ); + } + $list = $wgContLang->listToText( $list ); + throw new HttpError( 400, + Message::newFromKey( 'sessionmanager-tie', $list )->inLanguage( $wgContLang )->plain() + ); + } + + // Not the one we want, rethrow + throw $ex; + } + + $session->renew(); + if ( MediaWiki\Session\PHPSessionHandler::isEnabled() && + ( $session->isPersistent() || $session->shouldRememberUser() ) + ) { + // Start the PHP-session for backwards compatibility + session_id( $session->getId() ); + MediaWiki\quietCall( 'session_start' ); + } +} +Profiler::instance()->scopedProfileOut( $ps_session ); + /** * @var User $wgUser */ @@ -701,11 +752,6 @@ $wgOut = RequestContext::getMain()->getOutput(); // BackCompat */ $wgParser = new StubObject( 'wgParser', $wgParserConf['class'], array( $wgParserConf ) ); -if ( !is_object( $wgAuth ) ) { - $wgAuth = new AuthPlugin; - Hooks::run( 'AuthPluginSetup', array( &$wgAuth ) ); -} - /** * @var Title $wgTitle */ @@ -737,6 +783,16 @@ foreach ( $wgExtensionFunctions as $func ) { Profiler::instance()->scopedProfileOut( $ps_ext_func ); } +// If the session user has a 0 id but a valid name, that means we need to +// autocreate it. +$sessionUser = MediaWiki\Session\SessionManager::getGlobalSession()->getUser(); +if ( $sessionUser->getId() === 0 && User::isValidUserName( $sessionUser->getName() ) ) { + $ps_autocreate = Profiler::instance()->scopedProfileIn( $fname . '-autocreate' ); + MediaWiki\Session\SessionManager::autoCreateUser( $sessionUser ); + Profiler::instance()->scopedProfileOut( $ps_autocreate ); +} +unset( $sessionUser ); + wfDebug( "Fully initialised\n" ); $wgFullyInitialised = true; diff --git a/includes/WebRequest.php b/includes/WebRequest.php index 7b76592348..730610515e 100644 --- a/includes/WebRequest.php +++ b/includes/WebRequest.php @@ -23,6 +23,8 @@ * @file */ +use MediaWiki\Session\SessionManager; + /** * The WebRequest class encapsulates getting at data passed in the * URL or via a POSTed form stripping illegal input characters and @@ -63,6 +65,13 @@ class WebRequest { */ protected $protocol; + /** + * @var \\MediaWiki\\Session\\SessionId|null Session ID to use for this + * request. We can't save the session directly due to reference cycles not + * working too well (slow GC in Zend and never collected in HHVM). + */ + protected $sessionId = null; + public function __construct() { $this->requestTime = isset( $_SERVER['REQUEST_TIME_FLOAT'] ) ? $_SERVER['REQUEST_TIME_FLOAT'] : microtime( true ); @@ -638,18 +647,44 @@ class WebRequest { } /** - * Returns true if there is a session cookie set. + * Return the session for this request + * @since 1.27 + * @note For performance, keep the session locally if you will be making + * much use of it instead of calling this method repeatedly. + * @return MediaWiki\\Session\\Session + */ + public function getSession() { + if ( $this->sessionId !== null ) { + return SessionManager::singleton()->getSessionById( (string)$this->sessionId, false, $this ); + } + + $session = SessionManager::singleton()->getSessionForRequest( $this ); + $this->sessionId = $session->getSessionId(); + return $session; + } + + /** + * Set the session for this request + * @since 1.27 + * @private For use by MediaWiki\\Session classes only + * @param MediaWiki\\Session\\SessionId $sessionId + */ + public function setSessionId( MediaWiki\Session\SessionId $sessionId ) { + $this->sessionId = $sessionId; + } + + /** + * Returns true if the request has a persistent session. * This does not necessarily mean that the user is logged in! * - * If you want to check for an open session, use session_id() - * instead; that will also tell you if the session was opened - * during the current request (in which case the cookie will - * be sent back to the client at the end of the script run). - * + * @deprecated since 1.27, use + * \\MediaWiki\\Session\\SessionManager::singleton()->getPersistedSessionId() + * instead. * @return bool */ public function checkSessionCookie() { - return isset( $_COOKIE[session_name()] ); + wfDeprecated( __METHOD__, '1.27' ); + return SessionManager::singleton()->getPersistedSessionId( $this ) !== null; } /** @@ -907,26 +942,25 @@ class WebRequest { } /** - * Get data from $_SESSION + * Get data from the session * - * @param string $key Name of key in $_SESSION + * @note Prefer $this->getSession() instead if making multiple calls. + * @param string $key Name of key in the session * @return mixed */ public function getSessionData( $key ) { - if ( !isset( $_SESSION[$key] ) ) { - return null; - } - return $_SESSION[$key]; + return $this->getSession()->get( $key ); } /** * Set session data * - * @param string $key Name of key in $_SESSION + * @note Prefer $this->getSession() instead if making multiple calls. + * @param string $key Name of key in the session * @param mixed $data */ public function setSessionData( $key, $data ) { - $_SESSION[$key] = $data; + return $this->getSession()->set( $key, $data ); } /** diff --git a/includes/WebResponse.php b/includes/WebResponse.php index 26fb20f331..f14cf2289c 100644 --- a/includes/WebResponse.php +++ b/includes/WebResponse.php @@ -27,6 +27,11 @@ */ class WebResponse { + /** @var array Used to record set cookies, because PHP's setcookie() will + * happily send an identical Set-Cookie to the client. + */ + protected static $setCookies = array(); + /** * Output an HTTP header, wrapper for PHP's header() * @param string $string Header to output @@ -62,6 +67,15 @@ class WebResponse { HttpStatus::header( $code ); } + /** + * Test if headers have been sent + * @since 1.27 + * @return bool + */ + public function headersSent() { + return headers_sent(); + } + /** * Set the browser cookie * @param string $name The name of the cookie. @@ -115,25 +129,26 @@ class WebResponse { $func = $options['raw'] ? 'setrawcookie' : 'setcookie'; if ( Hooks::run( 'WebResponseSetCookie', array( &$name, &$value, &$expire, $options ) ) ) { - wfDebugLog( 'cookie', - $func . ': "' . implode( '", "', - array( - $options['prefix'] . $name, - $value, - $expire, - $options['path'], - $options['domain'], - $options['secure'], - $options['httpOnly'] ) ) . '"' ); - - call_user_func( $func, - $options['prefix'] . $name, - $value, - $expire, - $options['path'], - $options['domain'], - $options['secure'], - $options['httpOnly'] ); + $cookie = $options['prefix'] . $name; + $data = array( + (string)$cookie, + (string)$value, + (int)$expire, + (string)$options['path'], + (string)$options['domain'], + (bool)$options['secure'], + (bool)$options['httpOnly'], + ); + if ( !isset( self::$setCookies[$cookie] ) || + self::$setCookies[$cookie] !== array( $func, $data ) + ) { + wfDebugLog( 'cookie', $func . ': "' . implode( '", "', $data ) . '"' ); + if ( call_user_func_array( $func, $data ) ) { + self::$setCookies[$cookie] = array( $func, $data ); + } + } else { + wfDebugLog( 'cookie', 'already set ' . $func . ': "' . implode( '", "', $data ) . '"' ); + } } } @@ -156,7 +171,7 @@ class WebResponse { */ class FauxResponse extends WebResponse { private $headers; - private $cookies; + private $cookies = array(); private $code; /** @@ -192,6 +207,10 @@ class FauxResponse extends WebResponse { $this->code = intval( $code ); } + public function headersSent() { + return false; + } + /** * @param string $key The name of the header to get (case insensitive). * @return string|null The header value (if set); null otherwise. diff --git a/includes/actions/RawAction.php b/includes/actions/RawAction.php index 69cd7aa1bd..b371848e7b 100644 --- a/includes/actions/RawAction.php +++ b/includes/actions/RawAction.php @@ -83,7 +83,8 @@ class RawAction extends FormlessAction { $response->header( 'Content-type: ' . $contentType . '; charset=UTF-8' ); // Output may contain user-specific data; // vary generated content for open sessions on private wikis - $privateCache = !User::isEveryoneAllowed( 'read' ) && ( $smaxage == 0 || session_id() != '' ); + $privateCache = !User::isEveryoneAllowed( 'read' ) && + ( $smaxage == 0 || MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent() ); // Don't accidentally cache cookies if user is logged in (T55032) $privateCache = $privateCache || $this->getUser()->isLoggedIn(); $mode = $privateCache ? 'private' : 'public'; diff --git a/includes/actions/SubmitAction.php b/includes/actions/SubmitAction.php index fae49f61e3..8990b75f75 100644 --- a/includes/actions/SubmitAction.php +++ b/includes/actions/SubmitAction.php @@ -32,10 +32,8 @@ class SubmitAction extends EditAction { } public function show() { - if ( session_id() === '' ) { - // Send a cookie so anons get talk message notifications - wfSetupSession(); - } + // Send a cookie so anons get talk message notifications + MediaWiki\Session\SessionManager::getGlobalSession()->persist(); parent::show(); } diff --git a/includes/api/ApiCreateAccount.php b/includes/api/ApiCreateAccount.php index 1368bda1fc..a044be2565 100644 --- a/includes/api/ApiCreateAccount.php +++ b/includes/api/ApiCreateAccount.php @@ -59,10 +59,8 @@ class ApiCreateAccount extends ApiBase { $params = $this->extractRequestParams(); - // Init session if necessary - if ( session_id() == '' ) { - wfSetupSession(); - } + // Make sure session is persisted + MediaWiki\Session\SessionManager::getGlobalSession()->persist(); if ( $params['mailpassword'] && !$params['email'] ) { $this->dieUsageMsg( 'noemail' ); diff --git a/includes/api/ApiLogin.php b/includes/api/ApiLogin.php index eb376d3f80..03e77cfe8e 100644 --- a/includes/api/ApiLogin.php +++ b/includes/api/ApiLogin.php @@ -62,9 +62,19 @@ class ApiLogin extends ApiBase { $result = array(); - // Init session if necessary - if ( session_id() == '' ) { - wfSetupSession(); + // Make sure session is persisted + $session = MediaWiki\Session\SessionManager::getGlobalSession(); + $session->persist(); + + // Make sure it's possible to log in + if ( !$session->canSetUser() ) { + $this->getResult()->addValue( null, 'login', array( + 'result' => 'Aborted', + 'reason' => 'Cannot log in when using ' . + $session->getProvider()->describe( Language::factory( 'en' ) ), + ) ); + + return; } $context = new DerivativeContext( $this->getContext() ); @@ -107,7 +117,7 @@ class ApiLogin extends ApiBase { // SessionManager/AuthManager are *really* going to break it. $result['lgtoken'] = $user->getToken(); $result['cookieprefix'] = $this->getConfig()->get( 'CookiePrefix' ); - $result['sessionid'] = session_id(); + $result['sessionid'] = MediaWiki\Session\SessionManager::getGlobalSession()->getId(); break; case LoginForm::NEED_TOKEN: @@ -116,7 +126,7 @@ class ApiLogin extends ApiBase { // @todo: See above about deprecation $result['cookieprefix'] = $this->getConfig()->get( 'CookiePrefix' ); - $result['sessionid'] = session_id(); + $result['sessionid'] = MediaWiki\Session\SessionManager::getGlobalSession()->getId(); break; case LoginForm::WRONG_TOKEN: diff --git a/includes/api/ApiLogout.php b/includes/api/ApiLogout.php index bf0ca9c68b..b40f5a33b4 100644 --- a/includes/api/ApiLogout.php +++ b/includes/api/ApiLogout.php @@ -33,6 +33,16 @@ class ApiLogout extends ApiBase { public function execute() { + // Make sure it's possible to log out + $session = MediaWiki\Session\SessionManager::getGlobalSession(); + if ( !$session->canSetUser() ) { + $this->dieUsage( + 'Cannot log out when using ' . + $session->getProvider()->describe( Language::factory( 'en' ) ), + 'cannotlogout' + ); + } + $user = $this->getUser(); $oldName = $user->getName(); $user->logout(); diff --git a/includes/api/ApiMain.php b/includes/api/ApiMain.php index ef9f901ec8..6ddc28af21 100644 --- a/includes/api/ApiMain.php +++ b/includes/api/ApiMain.php @@ -769,7 +769,7 @@ class ApiMain extends ApiBase { return; } // Logged out, send normal public headers below - } elseif ( session_id() != '' ) { + } elseif ( MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent() ) { // Logged in or otherwise has session (e.g. anonymous users who have edited) // Mark request private $response->header( "Cache-Control: $privateCache" ); diff --git a/includes/context/RequestContext.php b/includes/context/RequestContext.php index 36c644aa08..16f11eeb3e 100644 --- a/includes/context/RequestContext.php +++ b/includes/context/RequestContext.php @@ -513,7 +513,7 @@ class RequestContext implements IContextSource, MutableContext { return array( 'ip' => $this->getRequest()->getIP(), 'headers' => $this->getRequest()->getAllHeaders(), - 'sessionId' => session_id(), + 'sessionId' => MediaWiki\Session\SessionManager::getGlobalSession()->getId(), 'userId' => $this->getUser()->getId() ); } @@ -541,7 +541,9 @@ class RequestContext implements IContextSource, MutableContext { * @since 1.21 */ public static function importScopedSession( array $params ) { - if ( session_id() != '' && strlen( $params['sessionId'] ) ) { + if ( strlen( $params['sessionId'] ) && + MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent() + ) { // Sanity check to avoid sending random cookies for the wrong users. // This method should only called by CLI scripts or by HTTP job runners. throw new MWException( "Sessions can only be imported when none is active." ); @@ -563,23 +565,37 @@ class RequestContext implements IContextSource, MutableContext { global $wgRequest, $wgUser; $context = RequestContext::getMain(); + // Commit and close any current session - session_write_close(); // persist - session_id( '' ); // detach - $_SESSION = array(); // clear in-memory array - // Remove any user IP or agent information - $context->setRequest( new FauxRequest() ); + if ( MediaWiki\Session\PHPSessionHandler::isEnabled() ) { + session_write_close(); // persist + session_id( '' ); // detach + $_SESSION = array(); // clear in-memory array + } + + // Get new session, if applicable + $session = null; + if ( strlen( $params['sessionId'] ) ) { // don't make a new random ID + $session = MediaWiki\Session\SessionManager::singleton() + ->getSessionById( $params['sessionId'] ); + } + + // Remove any user IP or agent information, and attach the request + // with the new session. + $context->setRequest( new FauxRequest( array(), false, $session ) ); $wgRequest = $context->getRequest(); // b/c + // Now that all private information is detached from the user, it should // be safe to load the new user. If errors occur or an exception is thrown // and caught (leaving the main context in a mixed state), there is no risk // of the User object being attached to the wrong IP, headers, or session. $context->setUser( $user ); $wgUser = $context->getUser(); // b/c - if ( strlen( $params['sessionId'] ) ) { // don't make a new random ID - wfSetupSession( $params['sessionId'] ); // sets $_SESSION + if ( $session && MediaWiki\Session\PHPSessionHandler::isEnabled() ) { + session_id( $session->getId() ); + MediaWiki\quietCall( 'session_start' ); } - $request = new FauxRequest( array(), false, $_SESSION ); + $request = new FauxRequest( array(), false, $session ); $request->setIP( $params['ip'] ); foreach ( $params['headers'] as $name => $value ) { $request->setHeader( $name, $value ); diff --git a/includes/jobqueue/jobs/UploadFromUrlJob.php b/includes/jobqueue/jobs/UploadFromUrlJob.php index ade481068d..28e3c405ef 100644 --- a/includes/jobqueue/jobs/UploadFromUrlJob.php +++ b/includes/jobqueue/jobs/UploadFromUrlJob.php @@ -93,10 +93,10 @@ class UploadFromUrlJob extends Job { $this->params['url'] )->text() ); } else { - wfSetupSession( $this->params['sessionId'] ); - $this->storeResultInSession( 'Warning', + $session = MediaWiki\Session\SessionManager::singleton() + ->getSessionById( $this->params['sessionId'] ); + $this->storeResultInSession( $session, 'Warning', 'warnings', $warnings ); - session_write_close(); } return true; @@ -139,15 +139,15 @@ class UploadFromUrlJob extends Job { )->text() ); } } else { - wfSetupSession( $this->params['sessionId'] ); + $session = MediaWiki\Session\SessionManager::singleton() + ->getSessionById( $this->params['sessionId'] ); if ( $status->isOk() ) { - $this->storeResultInSession( 'Success', + $this->storeResultInSession( $session, 'Success', 'filename', $this->upload->getLocalFile()->getName() ); } else { - $this->storeResultInSession( 'Failure', + $this->storeResultInSession( $session, 'Failure', 'errors', $status->getErrorsArray() ); } - session_write_close(); } } @@ -155,33 +155,55 @@ class UploadFromUrlJob extends Job { * Store a result in the session data. Note that the caller is responsible * for appropriate session_start and session_write_close calls. * + * @param MediaWiki\\Session\\Session $session Session to store result into * @param string $result The result (Success|Warning|Failure) * @param string $dataKey The key of the extra data * @param mixed $dataValue The extra data itself */ - protected function storeResultInSession( $result, $dataKey, $dataValue ) { - $session =& self::getSessionData( $this->params['sessionKey'] ); - $session['result'] = $result; - $session[$dataKey] = $dataValue; + protected function storeResultInSession( + MediaWiki\Session\Session $session, $result, $dataKey, $dataValue + ) { + $data = self::getSessionData( $session, $this->params['sessionKey'] ); + $data['result'] = $result; + $data[$dataKey] = $dataValue; + self::setSessionData( $session, $this->params['sessionKey'], $data ); } /** * Initialize the session data. Sets the initial result to queued. */ public function initializeSessionData() { - $session =& self::getSessionData( $this->params['sessionKey'] ); - $session['result'] = 'Queued'; + $session = MediaWiki\Session\SessionManager::getGlobalSession(); + $data = self::getSessionData( $session, $this->params['sessionKey'] ); + $data['result'] = 'Queued'; + self::setSessionData( $session, $this->params['sessionKey'], $data ); } /** + * @param MediaWiki\\Session\\Session $session * @param string $key * @return mixed */ - public static function &getSessionData( $key ) { - if ( !isset( $_SESSION[self::SESSION_KEYNAME][$key] ) ) { - $_SESSION[self::SESSION_KEYNAME][$key] = array(); + public static function getSessionData( MediaWiki\Session\Session $session, $key ) { + $data = $session->get( self::SESSION_KEYNAME ); + if ( !is_array( $data ) || !isset( $data[$key] ) ) { + self::setSessionData( $session, $key, array() ); + return array(); } + return $data[$key]; + } - return $_SESSION[self::SESSION_KEYNAME][$key]; + /** + * @param MediaWiki\\Session\\Session $session + * @param string $key + * @param mixed $value + */ + public static function setSessionData( MediaWiki\Session\Session $session, $key, $value ) { + $data = $session->get( self::SESSION_KEYNAME, array() ); + if ( !is_array( $data ) ) { + $data = array(); + } + $data[$key] = $value; + $session->set( self::SESSION_KEYNAME, $data ); } } diff --git a/includes/objectcache/ObjectCacheSessionHandler.php b/includes/objectcache/ObjectCacheSessionHandler.php deleted file mode 100644 index cc85074175..0000000000 --- a/includes/objectcache/ObjectCacheSessionHandler.php +++ /dev/null @@ -1,207 +0,0 @@ - SHA-1 of the data) */ - protected static $hashCache = array(); - - /** - * Install a session handler for the current web request - */ - static function install() { - session_set_save_handler( - array( __CLASS__, 'open' ), - array( __CLASS__, 'close' ), - array( __CLASS__, 'read' ), - array( __CLASS__, 'write' ), - array( __CLASS__, 'destroy' ), - array( __CLASS__, 'gc' ) ); - - // It's necessary to register a shutdown function to call session_write_close(), - // because by the time the request shutdown function for the session module is - // called, the BagOStuff has already been destroyed. Shutdown functions registered - // this way are called before object destruction. - register_shutdown_function( array( __CLASS__, 'handleShutdown' ) ); - } - - /** - * Get the cache storage object to use for session storage - * @return BagOStuff - */ - protected static function getCache() { - global $wgSessionCacheType; - - return ObjectCache::getInstance( $wgSessionCacheType ); - } - - /** - * Get a cache key for the given session id. - * - * @param string $id Session id - * @return string Cache key - */ - protected static function getKey( $id ) { - return wfMemcKey( 'session', $id ); - } - - /** - * @param mixed $data - * @return string - */ - protected static function getHash( $data ) { - return sha1( serialize( $data ) ); - } - - /** - * Callback when opening a session. - * - * @param string $save_path Path used to store session files, unused - * @param string $session_name Session name - * @return bool Success - */ - static function open( $save_path, $session_name ) { - return true; - } - - /** - * Callback when closing a session. - * NOP. - * - * @return bool Success - */ - static function close() { - return true; - } - - /** - * Callback when reading session data. - * - * @param string $id Session id - * @return mixed Session data - */ - static function read( $id ) { - $stime = microtime( true ); - $data = self::getCache()->get( self::getKey( $id ) ); - $real = microtime( true ) - $stime; - - RequestContext::getMain()->getStats()->timing( "session.read", 1000 * $real ); - - self::$hashCache = array( $id => self::getHash( $data ) ); - - return ( $data === false ) ? '' : $data; - } - - /** - * Callback when writing session data. - * - * @param string $id Session id - * @param string $data Session data - * @return bool Success - */ - static function write( $id, $data ) { - global $wgObjectCacheSessionExpiry; - - // Only issue a write if anything changed (PHP 5.6 already does this) - if ( !isset( self::$hashCache[$id] ) - || self::getHash( $data ) !== self::$hashCache[$id] - ) { - $stime = microtime( true ); - self::getCache()->set( self::getKey( $id ), $data, $wgObjectCacheSessionExpiry ); - $real = microtime( true ) - $stime; - - RequestContext::getMain()->getStats()->timing( "session.write", 1000 * $real ); - } - - return true; - } - - /** - * Callback to destroy a session when calling session_destroy(). - * - * @param string $id Session id - * @return bool Success - */ - static function destroy( $id ) { - $stime = microtime( true ); - self::getCache()->delete( self::getKey( $id ) ); - $real = microtime( true ) - $stime; - - RequestContext::getMain()->getStats()->timing( "session.destroy", 1000 * $real ); - - return true; - } - - /** - * Callback to execute garbage collection. - * NOP: Object caches perform garbage collection implicitly - * - * @param int $maxlifetime Maximum session life time - * @return bool Success - */ - static function gc( $maxlifetime ) { - return true; - } - - /** - * Shutdown function. - * See the comment inside ObjectCacheSessionHandler::install for rationale. - */ - static function handleShutdown() { - session_write_close(); - } - - /** - * Pre-emptive session renewal function - */ - static function renewCurrentSession() { - global $wgObjectCacheSessionExpiry; - - // Once a session is at half TTL, renew it - $window = $wgObjectCacheSessionExpiry / 2; - $logger = LoggerFactory::getInstance( 'SessionHandler' ); - - $now = microtime( true ); - // Session are only written in object stores when $_SESSION changes, - // which also renews the TTL ($wgObjectCacheSessionExpiry). If a user - // is active but not causing session data changes, it may suddenly - // expire as they view a form, blocking the first submission. - // Make a dummy change every so often to avoid this. - if ( !isset( $_SESSION['wsExpiresUnix'] ) ) { - $_SESSION['wsExpiresUnix'] = $now + $wgObjectCacheSessionExpiry; - - $logger->info( "Set expiry for session " . session_id(), array() ); - } elseif ( ( $now + $window ) > $_SESSION['wsExpiresUnix'] ) { - $_SESSION['wsExpiresUnix'] = $now + $wgObjectCacheSessionExpiry; - - $logger->info( "Renewed session " . session_id(), array() ); - } - } -} diff --git a/includes/session/CookieSessionProvider.php b/includes/session/CookieSessionProvider.php new file mode 100644 index 0000000000..f92a519ac2 --- /dev/null +++ b/includes/session/CookieSessionProvider.php @@ -0,0 +1,324 @@ + array(), + // @codeCoverageIgnoreStart + ); + // @codeCoverageIgnoreEnd + + if ( !isset( $params['priority'] ) ) { + throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' ); + } + if ( $params['priority'] < SessionInfo::MIN_PRIORITY || + $params['priority'] > SessionInfo::MAX_PRIORITY + ) { + throw new \InvalidArgumentException( __METHOD__ . ': Invalid priority' ); + } + + if ( !is_array( $params['cookieOptions'] ) ) { + throw new \InvalidArgumentException( __METHOD__ . ': cookieOptions must be an array' ); + } + + $this->priority = $params['priority']; + $this->cookieOptions = $params['cookieOptions']; + $this->params = $params; + unset( $this->params['priority'] ); + unset( $this->params['cookieOptions'] ); + } + + public function setConfig( Config $config ) { + parent::setConfig( $config ); + + // @codeCoverageIgnoreStart + $this->params += array( + // @codeCoverageIgnoreEnd + 'callUserSetCookiesHook' => false, + 'sessionName' => + $config->get( 'SessionName' ) ?: $config->get( 'CookiePrefix' ) . '_session', + ); + + // @codeCoverageIgnoreStart + $this->cookieOptions += array( + // @codeCoverageIgnoreEnd + 'prefix' => $config->get( 'CookiePrefix' ), + 'path' => $config->get( 'CookiePath' ), + 'domain' => $config->get( 'CookieDomain' ), + 'secure' => $config->get( 'CookieSecure' ), + 'httpOnly' => $config->get( 'CookieHttpOnly' ), + ); + } + + public function provideSessionInfo( WebRequest $request ) { + $info = array( + 'id' => $request->getCookie( $this->params['sessionName'], '' ) + ); + if ( !SessionManager::validateSessionId( $info['id'] ) ) { + unset( $info['id'] ); + } + + list( $userId, $userName, $token ) = $this->getUserInfoFromCookies( $request ); + if ( $userId !== null ) { + try { + $userInfo = UserInfo::newFromId( $userId ); + } catch ( \InvalidArgumentException $ex ) { + return null; + } + + // Sanity check + if ( $userName !== null && $userInfo->getName() !== $userName ) { + return null; + } + + if ( $token !== null ) { + if ( !hash_equals( $userInfo->getToken(), $token ) ) { + return null; + } + $info['userInfo'] = $userInfo->verified(); + } elseif ( isset( $info['id'] ) ) { // No point if no session ID + $info['userInfo'] = $userInfo; + } + } + + if ( !$info ) { + return null; + } + + $info += array( + 'provider' => $this, + 'persisted' => isset( $info['id'] ), + 'forceHTTPS' => $request->getCookie( 'forceHTTPS', '', false ) + ); + + return new SessionInfo( $this->priority, $info ); + } + + public function persistsSessionId() { + return true; + } + + public function canChangeUser() { + return true; + } + + public function persistSession( SessionBackend $session, WebRequest $request ) { + $response = $request->response(); + if ( $response->headersSent() ) { + // Can't do anything now + $this->logger->debug( __METHOD__ . ': Headers already sent' ); + return; + } + + $user = $session->getUser(); + + $cookies = $this->cookieDataToExport( $user, $session->shouldRememberUser() ); + $sessionData = $this->sessionDataToExport( $user ); + + // Legacy hook + if ( $this->params['callUserSetCookiesHook'] && !$user->isAnon() ) { + \Hooks::run( 'UserSetCookies', array( $user, &$sessionData, &$cookies ) ); + } + + $options = $this->cookieOptions; + if ( $session->shouldForceHTTPS() || $user->requiresHTTPS() ) { + $response->setCookie( 'forceHTTPS', 'true', $session->shouldRememberUser() ? 0 : null, + array( 'prefix' => '', 'secure' => false ) + $options ); + $options['secure'] = true; + } + + $response->setCookie( $this->params['sessionName'], $session->getId(), null, + array( 'prefix' => '' ) + $options + ); + + $extendedCookies = $this->config->get( 'ExtendedLoginCookies' ); + $extendedExpiry = $this->config->get( 'ExtendedLoginCookieExpiration' ); + + foreach ( $cookies as $key => $value ) { + if ( $value === false ) { + $response->clearCookie( $key, $options ); + } else { + if ( $extendedExpiry !== null && in_array( $key, $extendedCookies ) ) { + $expiry = time() + (int)$extendedExpiry; + } else { + $expiry = 0; // Default cookie expiration + } + $response->setCookie( $key, (string)$value, $expiry, $options ); + } + } + + $this->setLoggedOutCookie( $session->getLoggedOutTimestamp(), $request ); + + if ( $sessionData ) { + $session->addData( $sessionData ); + } + } + + public function unpersistSession( WebRequest $request ) { + $response = $request->response(); + if ( $response->headersSent() ) { + // Can't do anything now + $this->logger->debug( __METHOD__ . ': Headers already sent' ); + return; + } + + $cookies = array( + 'UserID' => false, + 'Token' => false, + ); + + $response->clearCookie( + $this->params['sessionName'], array( 'prefix' => '' ) + $this->cookieOptions + ); + + foreach ( $cookies as $key => $value ) { + $response->clearCookie( $key, $this->cookieOptions ); + } + + $response->clearCookie( 'forceHTTPS', + array( 'prefix' => '', 'secure' => false ) + $this->cookieOptions ); + } + + /** + * Set the "logged out" cookie + * @param int $loggedOut timestamp + * @param WebRequest $request + */ + protected function setLoggedOutCookie( $loggedOut, WebRequest $request ) { + if ( $loggedOut + 86400 > time() && + $loggedOut !== (int)$request->getCookie( 'LoggedOut', $this->cookieOptions['prefix'] ) + ) { + $request->response()->setCookie( 'LoggedOut', $loggedOut, $loggedOut + 86400, + $this->cookieOptions ); + } + } + + public function getVaryCookies() { + return array( + // Vary on token and session because those are the real authn + // determiners. UserID and UserName don't matter without those. + $this->cookieOptions['prefix'] . 'Token', + $this->cookieOptions['prefix'] . 'LoggedOut', + $this->params['sessionName'], + 'forceHTTPS', + ); + } + + public function suggestLoginUsername( WebRequest $request ) { + $name = $request->getCookie( 'UserName', $this->cookieOptions['prefix'] ); + if ( $name !== null ) { + $name = User::getCanonicalName( $name, 'usable' ); + } + return $name === false ? null : $name; + } + + /** + * Fetch the user identity from cookies + * @return array (int|null $id, string|null $token) + */ + protected function getUserInfoFromCookies( $request ) { + $prefix = $this->cookieOptions['prefix']; + return array( + $request->getCookie( 'UserID', $prefix ), + $request->getCookie( 'UserName', $prefix ), + $request->getCookie( 'Token', $prefix ), + ); + } + + /** + * Return the data to store in cookies + * @param User $user + * @param bool $remember + * @return array $cookies Set value false to unset the cookie + */ + protected function cookieDataToExport( $user, $remember ) { + if ( $user->isAnon() ) { + return array( + 'UserID' => false, + 'Token' => false, + ); + } else { + return array( + 'UserID' => $user->getId(), + 'UserName' => $user->getName(), + 'Token' => $remember ? (string)$user->getToken() : false, + ); + } + } + + /** + * Return extra data to store in the session + * @param User $user + * @return array $session + */ + protected function sessionDataToExport( $user ) { + // If we're calling the legacy hook, we should populate $session + // like User::setCookies() did. + if ( !$user->isAnon() && $this->params['callUserSetCookiesHook'] ) { + return array( + 'wsUserID' => $user->getId(), + 'wsToken' => $user->getToken(), + 'wsUserName' => $user->getName(), + ); + } + + return array(); + } + + public function whyNoSession() { + return wfMessage( 'sessionprovider-nocookies' ); + } + +} diff --git a/includes/session/ImmutableSessionProviderWithCookie.php b/includes/session/ImmutableSessionProviderWithCookie.php new file mode 100644 index 0000000000..98f7e5c079 --- /dev/null +++ b/includes/session/ImmutableSessionProviderWithCookie.php @@ -0,0 +1,153 @@ +sessionCookieName = $params['sessionCookieName']; + } + if ( isset( $params['sessionCookieOptions'] ) ) { + if ( !is_array( $params['sessionCookieOptions'] ) ) { + throw new \InvalidArgumentException( 'sessionCookieOptions must be an array' ); + } + $this->sessionCookieOptions = $params['sessionCookieOptions']; + } + } + + /** + * Get the session ID from the cookie, if any. + * + * Only call this if $this->sessionCookieName !== null. If + * sessionCookieName is null, do some logic (probably involving a call to + * $this->hashToSessionId()) to create the single session ID corresponding + * to this WebRequest instead of calling this method. + * + * @param WebRequest $request + * @return string|null + */ + protected function getSessionIdFromCookie( WebRequest $request ) { + if ( $this->sessionCookieName === null ) { + throw new \BadMethodCallException( + __METHOD__ . ' may not be called when $this->sessionCookieName === null' + ); + } + + $prefix = isset( $this->sessionCookieOptions['prefix'] ) + ? $this->sessionCookieOptions['prefix'] + : $this->config->get( 'CookiePrefix' ); + $id = $request->getCookie( $this->sessionCookieName, $prefix ); + return SessionManager::validateSessionId( $id ) ? $id : null; + } + + public function persistsSessionId() { + return $this->sessionCookieName !== null; + } + + public function canChangeUser() { + return false; + } + + public function persistSession( SessionBackend $session, WebRequest $request ) { + if ( $this->sessionCookieName === null ) { + return; + } + + $response = $request->response(); + if ( $response->headersSent() ) { + // Can't do anything now + $this->logger->debug( __METHOD__ . ': Headers already sent' ); + return; + } + + $options = $this->sessionCookieOptions; + if ( $session->shouldForceHTTPS() || $session->getUser()->requiresHTTPS() ) { + $response->setCookie( 'forceHTTPS', 'true', $session->shouldRememberUser() ? 0 : null, + array( 'prefix' => '', 'secure' => false ) + $options ); + $options['secure'] = true; + } + + $response->setCookie( $this->sessionCookieName, $session->getId(), null, $options ); + } + + public function unpersistSession( WebRequest $request ) { + if ( $this->sessionCookieName === null ) { + return; + } + + $response = $request->response(); + if ( $response->headersSent() ) { + // Can't do anything now + $this->logger->debug( __METHOD__ . ': Headers already sent' ); + return; + } + + $response->clearCookie( $this->sessionCookieName, $this->sessionCookieOptions ); + } + + public function getVaryCookies() { + if ( $this->sessionCookieName === null ) { + return array(); + } + + $prefix = isset( $this->sessionCookieOptions['prefix'] ) + ? $this->sessionCookieOptions['prefix'] + : $this->config->get( 'CookiePrefix' ); + return array( $prefix . $this->sessionCookieName ); + } + + public function whyNoSession() { + return wfMessage( 'sessionprovider-nocookies' ); + } +} diff --git a/includes/session/PHPSessionHandler.php b/includes/session/PHPSessionHandler.php new file mode 100644 index 0000000000..c59cc9639f --- /dev/null +++ b/includes/session/PHPSessionHandler.php @@ -0,0 +1,369 @@ +setEnableFlags( + \RequestContext::getMain()->getConfig()->get( 'PHPSessionHandling' ) + ); + $manager->setupPHPSessionHandler( $this ); + } + + /** + * Set $this->enable and $this->warn + * + * Separate just because there doesn't seem to be a good way to test it + * otherwise. + * + * @param string $PHPSessionHandling See $wgPHPSessionHandling + */ + private function setEnableFlags( $PHPSessionHandling ) { + switch ( $PHPSessionHandling ) { + case 'enable': + $this->enable = true; + $this->warn = false; + break; + + case 'warn': + $this->enable = true; + $this->warn = true; + break; + + case 'disable': + $this->enable = false; + $this->warn = false; + break; + } + } + + /** + * Test whether the handler is installed + * @return bool + */ + public static function isInstalled() { + return (bool)self::$instance; + } + + /** + * Test whether the handler is installed and enabled + * @return bool + */ + public static function isEnabled() { + return self::$instance && self::$instance->enable; + } + + /** + * Install a session handler for the current web request + * @param SessionManager $manager + */ + public static function install( SessionManager $manager ) { + if ( self::$instance ) { + $manager->setupPHPSessionHandler( self::$instance ); + return; + } + + self::$instance = new self( $manager ); + + // Close any auto-started session, before we replace it + session_write_close(); + + // Tell PHP not to mess with cookies itself + ini_set( 'session.use_cookies', 0 ); + ini_set( 'session.use_trans_sid', 0 ); + + // Also set a sane serialization handler + \Wikimedia\PhpSessionSerializer::setSerializeHandler(); + + session_set_save_handler( + array( self::$instance, 'open' ), + array( self::$instance, 'close' ), + array( self::$instance, 'read' ), + array( self::$instance, 'write' ), + array( self::$instance, 'destroy' ), + array( self::$instance, 'gc' ) + ); + + // It's necessary to register a shutdown function to call session_write_close(), + // because by the time the request shutdown function for the session module is + // called, other needed objects may have already been destroyed. Shutdown functions + // registered this way are called before object destruction. + register_shutdown_function( array( self::$instance, 'handleShutdown' ) ); + } + + /** + * Set the manager, store, and logger + * @private Use self::install(). + * @param SessionManager $manager + * @param BagOStuff $store + * @param LoggerInterface $store + */ + public function setManager( + SessionManager $manager, BagOStuff $store, LoggerInterface $logger + ) { + if ( $this->manager !== $manager ) { + // Close any existing session before we change stores + if ( $this->manager ) { + session_write_close(); + } + $this->manager = $manager; + $this->store = $store; + $this->logger = $logger; + \Wikimedia\PhpSessionSerializer::setLogger( $this->logger ); + } + } + + /** + * Initialize the session (handler) + * @private For internal use only + * @param string $save_path Path used to store session files (ignored) + * @param string $session_name Session name (ignored) + * @return bool Success + */ + public function open( $save_path, $session_name ) { + if ( self::$instance !== $this ) { + throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' ); + } + if ( !$this->enable ) { + throw new \BadMethodCallException( 'Attempt to use PHP session management' ); + } + return true; + } + + /** + * Close the session (handler) + * @private For internal use only + * @return bool Success + */ + public function close() { + if ( self::$instance !== $this ) { + throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' ); + } + $this->sessionFieldCache = array(); + return true; + } + + /** + * Read session data + * @private For internal use only + * @param string $id Session id + * @return string Session data + */ + public function read( $id ) { + if ( self::$instance !== $this ) { + throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' ); + } + if ( !$this->enable ) { + throw new \BadMethodCallException( 'Attempt to use PHP session management' ); + } + + $session = $this->manager->getSessionById( $id, true ); + if ( !$session ) { + return ''; + } + $session->persist(); + + $data = iterator_to_array( $session ); + $this->sessionFieldCache[$id] = $data; + return (string)\Wikimedia\PhpSessionSerializer::encode( $data ); + } + + /** + * Write session data + * @private For internal use only + * @param string $id Session id + * @param string $dataStr Session data. Not that you should ever call this + * directly, but note that this has the same issues with code injection + * via user-controlled data as does PHP's unserialize function. + * @return bool Success + */ + public function write( $id, $dataStr ) { + if ( self::$instance !== $this ) { + throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' ); + } + if ( !$this->enable ) { + throw new \BadMethodCallException( 'Attempt to use PHP session management' ); + } + + $session = $this->manager->getSessionById( $id ); + + // First, decode the string PHP handed us + $data = \Wikimedia\PhpSessionSerializer::decode( $dataStr ); + if ( $data === null ) { + // @codeCoverageIgnoreStart + return false; + // @codeCoverageIgnoreEnd + } + + // Now merge the data into the Session object. + $changed = false; + $cache = isset( $this->sessionFieldCache[$id] ) ? $this->sessionFieldCache[$id] : array(); + foreach ( $data as $key => $value ) { + if ( !isset( $cache[$key] ) ) { + if ( $session->exists( $key ) ) { + // New in both, so ignore and log + $this->logger->warning( + __METHOD__ . ": Key \"$key\" added in both Session and \$_SESSION!" + ); + } else { + // New in $_SESSION, keep it + $session->set( $key, $value ); + $changed = true; + } + } elseif ( $cache[$key] === $value ) { + // Unchanged in $_SESSION, so ignore it + } elseif ( !$session->exists( $key ) ) { + // Deleted in Session, keep but log + $this->logger->warning( + __METHOD__ . ": Key \"$key\" deleted in Session and changed in \$_SESSION!" + ); + $session->set( $key, $value ); + $changed = true; + } elseif ( $cache[$key] === $session->get( $key ) ) { + // Unchanged in Session, so keep it + $session->set( $key, $value ); + $changed = true; + } else { + // Changed in both, so ignore and log + $this->logger->warning( + __METHOD__ . ": Key \"$key\" changed in both Session and \$_SESSION!" + ); + } + } + // Anything deleted in $_SESSION and unchanged in Session should be deleted too + // (but not if $_SESSION can't represent it at all) + \Wikimedia\PhpSessionSerializer::setLogger( new \Psr\Log\NullLogger() ); + foreach ( $cache as $key => $value ) { + if ( !isset( $data[$key] ) && $session->exists( $key ) && + \Wikimedia\PhpSessionSerializer::encode( array( $key => true ) ) + ) { + if ( $cache[$key] === $session->get( $key ) ) { + // Unchanged in Session, delete it + $session->remove( $key ); + $changed = true; + } else { + // Changed in Session, ignore deletion and log + $this->logger->warning( + __METHOD__ . ": Key \"$key\" changed in Session and deleted in \$_SESSION!" + ); + } + } + } + \Wikimedia\PhpSessionSerializer::setLogger( $this->logger ); + + // Save and update cache if anything changed + if ( $changed ) { + if ( $this->warn ) { + wfDeprecated( '$_SESSION', '1.27' ); + $this->logger->warning( 'Something wrote to $_SESSION!' ); + } + + $session->save(); + $this->sessionFieldCache[$id] = iterator_to_array( $session ); + } + + $session->persist(); + + return true; + } + + /** + * Destroy a session + * @private For internal use only + * @param string $id Session id + * @return bool Success + */ + public function destroy( $id ) { + if ( self::$instance !== $this ) { + throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' ); + } + if ( !$this->enable ) { + throw new \BadMethodCallException( 'Attempt to use PHP session management' ); + } + $session = $this->manager->getSessionById( $id, true ); + if ( $session ) { + $session->clear(); + } + return true; + } + + /** + * Execute garbage collection. + * @private For internal use only + * @param int $maxlifetime Maximum session life time (ignored) + * @return bool Success + */ + public function gc( $maxlifetime ) { + if ( self::$instance !== $this ) { + throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' ); + } + $before = date( 'YmdHis', time() ); + $this->store->deleteObjectsExpiringBefore( $before ); + return true; + } + + /** + * Shutdown function. + * + * See the comment inside self::install for rationale. + * @codeCoverageIgnore + * @private For internal use only + */ + public function handleShutdown() { + if ( $this->enable ) { + session_write_close(); + } + } + +} diff --git a/includes/session/Session.php b/includes/session/Session.php new file mode 100644 index 0000000000..049e5f5325 --- /dev/null +++ b/includes/session/Session.php @@ -0,0 +1,364 @@ +backend = $backend; + $this->index = $index; + } + + public function __destruct() { + $this->backend->deregisterSession( $this->index ); + } + + /** + * Returns the session ID + * @return string + */ + public function getId() { + return $this->backend->getId(); + } + + /** + * Returns the SessionId object + * @private For internal use by WebRequest + * @return SessionId + */ + public function getSessionId() { + return $this->backend->getSessionId(); + } + + /** + * Changes the session ID + * @return string New ID (might be the same as the old) + */ + public function resetId() { + return $this->backend->resetId(); + } + + /** + * Fetch the SessionProvider for this session + * @return SessionProviderInterface + */ + public function getProvider() { + return $this->backend->getProvider(); + } + + /** + * Indicate whether this session is persisted across requests + * + * For example, if cookies are set. + * + * @return bool + */ + public function isPersistent() { + return $this->backend->isPersistent(); + } + + /** + * Make this session persisted across requests + * + * If the session is already persistent, equivalent to calling + * $this->renew(). + */ + public function persist() { + $this->backend->persist(); + } + + /** + * Indicate whether the user should be remembered independently of the + * session ID. + * @return bool + */ + public function shouldRememberUser() { + return $this->backend->shouldRememberUser(); + } + + /** + * Set whether the user should be remembered independently of the session + * ID. + * @param bool $remember + */ + public function setRememberUser( $remember ) { + $this->backend->setRememberUser( $remember ); + } + + /** + * Returns the request associated with this session + * @return WebRequest + */ + public function getRequest() { + return $this->backend->getRequest( $this->index ); + } + + /** + * Returns the authenticated user for this session + * @return User + */ + public function getUser() { + return $this->backend->getUser(); + } + + /** + * Indicate whether the session user info can be changed + * @return bool + */ + public function canSetUser() { + return $this->backend->canSetUser(); + } + + /** + * Set a new user for this session + * @note This should only be called when the user has been authenticated + * @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 ) { + $this->backend->setUser( $user ); + } + + /** + * Get a suggested username for the login form + * @return string|null + */ + public function suggestLoginUsername() { + return $this->backend->suggestLoginUsername( $this->index ); + } + + /** + * Whether HTTPS should be forced + * @return bool + */ + public function shouldForceHTTPS() { + return $this->backend->shouldForceHTTPS(); + } + + /** + * Set whether HTTPS should be forced + * @param bool $force + */ + public function setForceHTTPS( $force ) { + $this->backend->setForceHTTPS( $force ); + } + + /** + * Fetch the "logged out" timestamp + * @return int + */ + public function getLoggedOutTimestamp() { + return $this->backend->getLoggedOutTimestamp(); + } + + /** + * Set the "logged out" timestamp + * @param int $ts + */ + public function setLoggedOutTimestamp( $ts ) { + $this->backend->setLoggedOutTimestamp( $ts ); + } + + /** + * Fetch provider metadata + * @protected For use by SessionProvider subclasses only + * @return mixed + */ + public function getProviderMetadata() { + return $this->backend->getProviderMetadata(); + } + + /** + * Delete all session data and clear the user (if possible) + */ + public function clear() { + $data = &$this->backend->getData(); + if ( $data ) { + $data = array(); + $this->backend->dirty(); + } + if ( $this->backend->canSetUser() ) { + $this->backend->setUser( new User ); + } + $this->backend->save(); + } + + /** + * Renew the session + * + * 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() { + $this->backend->renew(); + } + + /** + * Fetch a copy of this session attached to an alternative WebRequest + * + * Actions on the copy will affect this session too, and vice versa. + * + * @param WebRequest $request Any existing session associated with this + * WebRequest object will be overwritten. + * @return Session + */ + public function sessionWithRequest( WebRequest $request ) { + $request->setSessionId( $this->backend->getSessionId() ); + return $this->backend->getSession( $request ); + } + + /** + * Fetch a value from the session + * @param string|int $key + * @param mixed $default + * @return mixed + */ + public function get( $key, $default = null ) { + $data = &$this->backend->getData(); + return array_key_exists( $key, $data ) ? $data[$key] : $default; + } + + /** + * Test if a value exists in the session + * @param string|int $key + * @return bool + */ + public function exists( $key ) { + $data = &$this->backend->getData(); + return array_key_exists( $key, $data ); + } + + /** + * Set a value in the session + * @param string|int $key + * @param mixed $value + */ + public function set( $key, $value ) { + $data = &$this->backend->getData(); + if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) { + $data[$key] = $value; + $this->backend->dirty(); + } + } + + /** + * Remove a value from the session + * @param string|int $key + */ + public function remove( $key ) { + $data = &$this->backend->getData(); + if ( array_key_exists( $key, $data ) ) { + unset( $data[$key] ); + $this->backend->dirty(); + } + } + + /** + * Delay automatic saving while multiple updates are being made + * + * Calls to save() or clear() will not be delayed. + * + * @return \ScopedCallback When this goes out of scope, a save will be triggered + */ + public function delaySave() { + return $this->backend->delaySave(); + } + + /** + * Save the session + */ + public function save() { + $this->backend->save(); + } + + /** + * @name Interface methods + * @{ + */ + + public function count() { + $data = &$this->backend->getData(); + return count( $data ); + } + + public function current() { + $data = &$this->backend->getData(); + return current( $data ); + } + + public function key() { + $data = &$this->backend->getData(); + return key( $data ); + } + + public function next() { + $data = &$this->backend->getData(); + next( $data ); + } + + public function rewind() { + $data = &$this->backend->getData(); + reset( $data ); + } + + public function valid() { + $data = &$this->backend->getData(); + return key( $data ) !== null; + } + + /**@}*/ + +} diff --git a/includes/session/SessionBackend.php b/includes/session/SessionBackend.php new file mode 100644 index 0000000000..80d3474521 --- /dev/null +++ b/includes/session/SessionBackend.php @@ -0,0 +1,624 @@ +getConfig()->get( '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() : new User; + $this->store = $store; + $this->logger = $logger; + $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( wfMemcKey( 'MWSession', (string)$this->id ) ); + if ( !is_array( $blob ) || + !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] ) || + !isset( $blob['data'] ) || !is_array( $blob['data'] ) + ) { + $this->data = array(); + $this->dataDirty = true; + $this->metaDirty = true; + $this->logger->debug( "SessionBackend $this->id is unsaved, marking dirty in constructor" ); + } 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->logger->debug( + "SessionBackend $this->id metadata dirty due to missing expiration timestamp" + ); + } + } + $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 ); + return $session; + } + + /** + * Deregister a Session + * @private For use by \\MediaWiki\\Session\\Session::__destruct() only + * @param int $index + */ + public function deregisterSession( $index ) { + unset( $this->requests[$index] ); + if ( !count( $this->requests ) ) { + $this->save( true ); + $this->provider->getManager()->deregisterSessionBackend( $this ); + } + } + + /** + * Returns the session ID. + * @return string + */ + public function getId() { + return (string)$this->id; + } + + /** + * Fetch the SessionId object + * @private 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 $this->id metadata dirty due to ID reset (formerly $oldId)" + ); + + if ( $restart ) { + session_id( (string)$this->id ); + \MediaWiki\quietCall( 'session_start' ); + } + + $this->autosave(); + + // Delete the data for the old session ID now + $this->store->delete( wfMemcKey( 'MWSession', $oldId ) ); + } + } + + /** + * 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->logger->debug( "SessionBackend $this->id force-persist due to persist()" ); + $this->autosave(); + } else { + $this->renew(); + } + } + + /** + * 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 $this->id metadata dirty due to remember-user change" ); + $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() { + return $this->user; + } + + /** + * 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 $this->id metadata dirty due to user change" ); + $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 $this->id metadata dirty due to force-HTTPS change" ); + $this->autosave(); + } + } + + /** + * Fetch the "logged out" timestamp + * @return int + */ + public function getLoggedOutTimestamp() { + return $this->loggedOut; + } + + /** + * Set the "logged out" timestamp + * @param int $ts + */ + public function setLoggedOutTimestamp( $ts = null ) { + $ts = (int)$ts; + if ( $this->loggedOut !== $ts ) { + $this->loggedOut = $ts; + $this->metaDirty = true; + $this->logger->debug( + "SessionBackend $this->id metadata dirty due to logged-out-timestamp change" + ); + $this->autosave(); + } + } + + /** + * Fetch provider metadata + * @protected For use by SessionProvider subclasses only + * @return mixed + */ + public function getProviderMetadata() { + return $this->providerMetadata; + } + + /** + * Fetch the session data array + * + * Note the caller is responsible for calling $this->dirty() if anything in + * the array is changed. + * + * @private 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 $this->id data dirty due to addData(): " . wfGetAllCallers( 5 ) + ); + } + } + } + + /** + * Mark data as dirty + * @private For use by \\MediaWiki\\Session\\Session only. + */ + public function dirty() { + $this->dataDirty = true; + $this->logger->debug( + "SessionBackend $this->id data dirty due to dirty(): " . 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 $this->id metadata dirty for renew(): " . wfGetAllCallers( 5 ) + ); + if ( $this->persist ) { + $this->forcePersist = true; + $this->logger->debug( + "SessionBackend $this->id force-persist for renew(): " . wfGetAllCallers( 5 ) + ); + } + } + $this->autosave(); + } + + /** + * Delay automatic saving while multiple updates are being made + * + * Calls to save() will not be delayed. + * + * @return \ScopedCallback When this goes out of scope, a save will be triggered + */ + public function delaySave() { + $that = $this; + $this->delaySave++; + $ref = &$this->delaySave; + return new \ScopedCallback( function () use ( $that, &$ref ) { + if ( --$ref <= 0 ) { + $ref = 0; + $that->save(); + } + } ); + } + + /** + * Save and persist session data, unless delayed + */ + private function autosave() { + if ( $this->delaySave <= 0 ) { + $this->save(); + } + } + + /** + * Save and persist session data + * @param bool $closing Whether the session is being closed + */ + public function save( $closing = false ) { + if ( $this->provider->getManager()->isUserSessionPrevented( $this->user->getName() ) ) { + $this->logger->debug( + "SessionBackend $this->id not saving, " . + "user {$this->user} was passed to SessionManager::preventSessionsForUser" + ); + return; + } + + // Ensure the user has a token + // @codeCoverageIgnoreStart + $anon = $this->user->isAnon(); + if ( !$anon && !$this->user->getToken() ) { + $this->logger->debug( + "SessionBackend $this->id creating token for user {$this->user} on save" + ); + $this->user->setToken(); + if ( !wfReadOnly() ) { + $this->user->saveSettings(); + } + $this->metaDirty = true; + } + // @codeCoverageIgnoreEnd + + if ( !$this->metaDirty && !$this->dataDirty && + $this->dataHash !== md5( serialize( $this->data ) ) + ) { + $this->logger->debug( "SessionBackend $this->id data dirty due to hash mismatch, " . + "$this->dataHash !== " . md5( serialize( $this->data ) ) ); + $this->dataDirty = true; + } + + if ( !$this->metaDirty && !$this->dataDirty && !$this->forcePersist ) { + return; + } + + $this->logger->debug( "SessionBackend $this->id save: " . + 'dataDirty=' . (int)$this->dataDirty . ' ' . + 'metaDirty=' . (int)$this->metaDirty . ' ' . + 'forcePersist=' . (int)$this->forcePersist + ); + + // Persist to the provider, if flagged + if ( $this->persist && ( $this->metaDirty || $this->forcePersist ) ) { + foreach ( $this->requests as $request ) { + $request->setSessionId( $this->getSessionId() ); + $this->provider->persistSession( $this, $request ); + } + if ( !$closing ) { + $this->checkPHPSession(); + } + } + + $this->forcePersist = false; + + if ( !$this->metaDirty && !$this->dataDirty ) { + return; + } + + // Save session data to store, if necessary + $metadata = $origMetadata = array( + 'provider' => (string)$this->provider, + 'providerMetadata' => $this->providerMetadata, + 'userId' => $anon ? 0 : $this->user->getId(), + 'userName' => $anon ? null : $this->user->getName(), + 'userToken' => $anon ? null : $this->user->getToken(), + 'remember' => !$anon && $this->remember, + 'forceHTTPS' => $this->forceHTTPS, + 'expires' => time() + $this->lifetime, + 'loggedOut' => $this->loggedOut, + ); + + \Hooks::run( 'SessionMetadata', array( $this, &$metadata, $this->requests ) ); + + foreach ( $origMetadata as $k => $v ) { + if ( $metadata[$k] !== $v ) { + throw new \UnexpectedValueException( "SessionMetadata hook changed metadata key \"$k\"" ); + } + } + + $this->store->set( + wfMemcKey( 'MWSession', (string)$this->id ), + array( + 'data' => $this->data, + 'metadata' => $metadata, + ), + $metadata['expires'] + ); + + $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; + $ref = &$this->checkPHPSessionRecursionGuard; + $reset = new \ScopedCallback( function () use ( &$ref ) { + $ref = false; + } ); + + if ( $this->usePhpSessionHandling && session_id() === '' && PHPSessionHandler::isEnabled() && + SessionManager::getGlobalSession()->getId() === (string)$this->id + ) { + $this->logger->debug( "SessionBackend $this->id: Taking over PHP session" ); + session_id( (string)$this->id ); + \MediaWiki\quietCall( 'session_start' ); + } + } + } + +} diff --git a/includes/session/SessionId.php b/includes/session/SessionId.php new file mode 100644 index 0000000000..0669100784 --- /dev/null +++ b/includes/session/SessionId.php @@ -0,0 +1,70 @@ +id = $id; + } + + /** + * Get the ID + * @return string + */ + public function getId() { + return $this->id; + } + + /** + * Set the ID + * @private For use by \\MediaWiki\\Session\\SessionManager only + * @param string $id + */ + public function setId( $id ) { + $this->id = $id; + } + + public function __toString() { + return $this->id; + } + +} diff --git a/includes/session/SessionInfo.php b/includes/session/SessionInfo.php new file mode 100644 index 0000000000..9fe2cdf15c --- /dev/null +++ b/includes/session/SessionInfo.php @@ -0,0 +1,270 @@ +canChangeUser() is false, a verified user + * must be provided. + * - persisted: (bool) Whether this session was persisted + * - remembered: (bool) Whether the verified user was remembered. + * Defaults to true. + * - forceHTTPS: (bool) Whether to force HTTPS for this session + * - metadata: (array) Provider metadata, to be returned by + * Session::getProviderMetadata(). + * - idIsSafe: (bool) Set true if the 'id' did not come from the user. + * Generally you'll use this from SessionProvider::newEmptySession(), + * and not from any other method. + * - copyFrom: (SessionInfo) SessionInfo to copy other data items from. + */ + public function __construct( $priority, array $data ) { + if ( $priority < self::MIN_PRIORITY || $priority > self::MAX_PRIORITY ) { + throw new \InvalidArgumentException( 'Invalid priority' ); + } + + if ( isset( $data['copyFrom'] ) ) { + $from = $data['copyFrom']; + if ( !$from instanceof SessionInfo ) { + throw new \InvalidArgumentException( 'Invalid copyFrom' ); + } + $data += array( + 'provider' => $from->provider, + 'id' => $from->id, + 'userInfo' => $from->userInfo, + 'persisted' => $from->persisted, + 'remembered' => $from->remembered, + 'forceHTTPS' => $from->forceHTTPS, + 'metadata' => $from->providerMetadata, + 'idIsSafe' => $from->idIsSafe, + // @codeCoverageIgnoreStart + ); + // @codeCoverageIgnoreEnd + } else { + $data += array( + 'provider' => null, + 'id' => null, + 'userInfo' => null, + 'persisted' => false, + 'remembered' => true, + 'forceHTTPS' => false, + 'metadata' => null, + 'idIsSafe' => false, + // @codeCoverageIgnoreStart + ); + // @codeCoverageIgnoreEnd + } + + if ( $data['id'] !== null && !SessionManager::validateSessionId( $data['id'] ) ) { + throw new \InvalidArgumentException( 'Invalid session ID' ); + } + + if ( $data['userInfo'] !== null && !$data['userInfo'] instanceof UserInfo ) { + throw new \InvalidArgumentException( 'Invalid userInfo' ); + } + + if ( !$data['provider'] && $data['id'] === null ) { + throw new \InvalidArgumentException( + 'Must supply an ID when no provider is given' + ); + } + + if ( $data['metadata'] !== null && !is_array( $data['metadata'] ) ) { + throw new \InvalidArgumentException( 'Invalid metadata' ); + } + + $this->provider = $data['provider']; + if ( $data['id'] !== null ) { + $this->id = $data['id']; + $this->idIsSafe = $data['idIsSafe']; + } else { + $this->id = $this->provider->getManager()->generateSessionId(); + $this->idIsSafe = true; + } + $this->priority = (int)$priority; + $this->userInfo = $data['userInfo']; + $this->persisted = (bool)$data['persisted']; + if ( $data['provider'] !== null ) { + if ( $this->userInfo !== null && !$this->userInfo->isAnon() && $this->userInfo->isVerified() ) { + $this->remembered = (bool)$data['remembered']; + } + $this->providerMetadata = $data['metadata']; + } + $this->forceHTTPS = (bool)$data['forceHTTPS']; + } + + /** + * Return the provider + * @return SessionProvider|null + */ + final public function getProvider() { + return $this->provider; + } + + /** + * Return the session ID + * @return string + */ + final public function getId() { + return $this->id; + } + + /** + * Indicate whether the ID is "safe" + * + * The ID is safe in the following cases: + * - The ID was randomly generated by the constructor. + * - The ID was found in the backend data store. + * - $this->getProvider()->persistsSessionId() is false. + * - The constructor was explicitly told it's safe using the 'idIsSafe' + * parameter. + * + * @return bool + */ + final public function isIdSafe() { + return $this->idIsSafe; + } + + /** + * Return the priority + * @return int + */ + final public function getPriority() { + return $this->priority; + } + + /** + * Return the user + * @return UserInfo|null + */ + final public function getUserInfo() { + return $this->userInfo; + } + + /** + * Return whether the session is persisted + * + * i.e. a session ID was given to the constuctor + * + * @return bool + */ + final public function wasPersisted() { + return $this->persisted; + } + + /** + * Return provider metadata + * @return array|null + */ + final public function getProviderMetadata() { + return $this->providerMetadata; + } + + /** + * Return whether the user was remembered + * + * For providers that can persist the user separately from the session, + * the human using it may not actually *want* that to be done. For example, + * a cookie-based provider can set cookies that are longer-lived than the + * backend session data, but on a public terminal the human likely doesn't + * want those cookies set. + * + * This is false unless a non-anonymous verified user was passed to + * the SessionInfo constructor by the provider, and the provider didn't + * pass false for the 'remembered' data item. + * + * @return bool + */ + final public function wasRemembered() { + return $this->remembered; + } + + /** + * Whether this session should only be used over HTTPS + * @return bool + */ + final public function forceHTTPS() { + return $this->forceHTTPS; + } + + public function __toString() { + return '[' . $this->getPriority() . ']' . + ( $this->getProvider() ?: 'null' ) . + ( $this->userInfo ?: '' ) . $this->getId(); + } + + /** + * Compare two SessionInfo objects by priority + * @param SessionInfo $a + * @param SessionInfo $b + * @return int Negative if $a < $b, positive if $a > $b, zero if equal + */ + public static function compare( $a, $b ) { + return $a->getPriority() - $b->getPriority(); + } + +} diff --git a/includes/session/SessionManager.php b/includes/session/SessionManager.php new file mode 100644 index 0000000000..1c8686c08f --- /dev/null +++ b/includes/session/SessionManager.php @@ -0,0 +1,997 @@ +getRequest(). + * + * @return Session + */ + public static function getGlobalSession() { + if ( !PHPSessionHandler::isEnabled() ) { + $id = ''; + } else { + $id = session_id(); + } + + $request = \RequestContext::getMain()->getRequest(); + if ( + !self::$globalSession // No global session is set up yet + || self::$globalSessionRequest !== $request // The global WebRequest changed + || $id !== '' && self::$globalSession->getId() !== $id // Someone messed with session_id() + ) { + self::$globalSessionRequest = $request; + if ( $id === '' ) { + // session_id() wasn't used, so fetch the Session from the WebRequest. + // We use $request->getSession() instead of $singleton->getSessionForRequest() + // because doing the latter would require a public + // "$request->getSessionId()" method that would confuse end + // users by returning SessionId|null where they'd expect it to + // be short for $request->getSession()->getId(), and would + // wind up being a duplicate of the code in + // $request->getSession() anyway. + self::$globalSession = $request->getSession(); + } else { + // Someone used session_id(), so we need to follow suit. + // Note this overwrites whatever session might already be + // associated with $request with the one for $id. + self::$globalSession = self::singleton()->getSessionById( $id, false, $request ); + } + } + return self::$globalSession; + } + + /** + * @param array $options + * - config: Config to fetch configuration from. Defaults to the default 'main' config. + * - logger: LoggerInterface to use for logging. Defaults to the 'session' channel. + * - store: BagOStuff to store session data in. + */ + public function __construct( $options = array() ) { + if ( isset( $options['config'] ) ) { + $this->config = $options['config']; + if ( !$this->config instanceof Config ) { + throw new \InvalidArgumentException( + '$options[\'config\'] must be an instance of Config' + ); + } + } else { + $this->config = \ConfigFactory::getDefaultInstance()->makeConfig( 'main' ); + } + + if ( isset( $options['logger'] ) ) { + if ( !$options['logger'] instanceof LoggerInterface ) { + throw new \InvalidArgumentException( + '$options[\'logger\'] must be an instance of LoggerInterface' + ); + } + $this->setLogger( $options['logger'] ); + } else { + $this->setLogger( \MediaWiki\Logger\LoggerFactory::getInstance( 'session' ) ); + } + + if ( isset( $options['store'] ) ) { + if ( !$options['store'] instanceof BagOStuff ) { + throw new \InvalidArgumentException( + '$options[\'store\'] must be an instance of BagOStuff' + ); + } + $this->store = $options['store']; + } else { + $this->store = \ObjectCache::getInstance( $this->config->get( 'SessionCacheType' ) ); + $this->store->setLogger( $this->logger ); + } + + register_shutdown_function( array( $this, 'shutdown' ) ); + } + + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + public function getPersistedSessionId( WebRequest $request ) { + $info = $this->getSessionInfoForRequest( $request ); + if ( $info && $info->wasPersisted() ) { + return $info->getId(); + } else { + return null; + } + } + + public function getSessionForRequest( WebRequest $request ) { + $info = $this->getSessionInfoForRequest( $request ); + + if ( !$info ) { + $session = $this->getEmptySession( $request ); + } else { + $session = $this->getSessionFromInfo( $info, $request ); + } + return $session; + } + + public function getSessionById( $id, $noEmpty = false, WebRequest $request = null ) { + if ( !self::validateSessionId( $id ) ) { + throw new \InvalidArgumentException( 'Invalid session ID' ); + } + if ( !$request ) { + $request = new FauxRequest; + } + + $session = null; + + // Test this here to provide a better log message for the common case + // of "no such ID" + $key = wfMemcKey( 'MWSession', $id ); + if ( is_array( $this->store->get( $key ) ) ) { + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( 'id' => $id, 'idIsSafe' => true ) ); + if ( $this->loadSessionInfoFromStore( $info, $request ) ) { + $session = $this->getSessionFromInfo( $info, $request ); + } + } + + if ( !$noEmpty && $session === null ) { + $ex = null; + try { + $session = $this->getEmptySessionInternal( $request, $id ); + } catch ( \Exception $ex ) { + $this->logger->error( __METHOD__ . ': failed to create empty session: ' . + $ex->getMessage() ); + $session = null; + } + if ( $session === null ) { + throw new \UnexpectedValueException( + 'Can neither load the session nor create an empty session', 0, $ex + ); + } + } + + return $session; + } + + public function getEmptySession( WebRequest $request = null ) { + return $this->getEmptySessionInternal( $request ); + } + + /** + * @see SessionManagerInterface::getEmptySession + * @param WebRequest|null $request + * @param string|null $id ID to force on the new session + * @return Session + */ + private function getEmptySessionInternal( WebRequest $request = null, $id = null ) { + if ( $id !== null ) { + if ( !self::validateSessionId( $id ) ) { + throw new \InvalidArgumentException( 'Invalid session ID' ); + } + + $key = wfMemcKey( 'MWSession', $id ); + if ( is_array( $this->store->get( $key ) ) ) { + throw new \InvalidArgumentException( 'Session ID already exists' ); + } + } + if ( !$request ) { + $request = new FauxRequest; + } + + $infos = array(); + foreach ( $this->getProviders() as $provider ) { + $info = $provider->newSessionInfo( $id ); + if ( !$info ) { + continue; + } + if ( $info->getProvider() !== $provider ) { + throw new \UnexpectedValueException( + "$provider returned an empty session info for a different provider: $info" + ); + } + if ( $id !== null && $info->getId() !== $id ) { + throw new \UnexpectedValueException( + "$provider returned empty session info with a wrong id: " . + $info->getId() . ' != ' . $id + ); + } + if ( !$info->isIdSafe() ) { + throw new \UnexpectedValueException( + "$provider returned empty session info with id flagged unsafe" + ); + } + $compare = $infos ? SessionInfo::compare( $infos[0], $info ) : -1; + if ( $compare > 0 ) { + continue; + } + if ( $compare === 0 ) { + $infos[] = $info; + } else { + $infos = array( $info ); + } + } + + // Make sure there's exactly one + if ( count( $infos ) > 1 ) { + throw new \UnexpectedValueException( + 'Multiple empty sessions tied for top priority: ' . join( ', ', $infos ) + ); + } elseif ( count( $infos ) < 1 ) { + throw new \UnexpectedValueException( 'No provider could provide an empty session!' ); + } + + return $this->getSessionFromInfo( $infos[0], $request ); + } + + public function getVaryHeaders() { + if ( $this->varyHeaders === null ) { + $headers = array(); + foreach ( $this->getProviders() as $provider ) { + foreach ( $provider->getVaryHeaders() as $header => $options ) { + if ( !isset( $headers[$header] ) ) { + $headers[$header] = array(); + } + if ( is_array( $options ) ) { + $headers[$header] = array_unique( array_merge( $headers[$header], $options ) ); + } + } + } + $this->varyHeaders = $headers; + } + return $this->varyHeaders; + } + + public function getVaryCookies() { + if ( $this->varyCookies === null ) { + $cookies = array(); + foreach ( $this->getProviders() as $provider ) { + $cookies = array_merge( $cookies, $provider->getVaryCookies() ); + } + $this->varyCookies = array_values( array_unique( $cookies ) ); + } + return $this->varyCookies; + } + + /** + * Validate a session ID + * @param string $id + * @return bool + */ + public static function validateSessionId( $id ) { + return is_string( $id ) && preg_match( '/^[a-zA-Z0-9_-]{32,}$/', $id ); + } + + /** + * @name Internal methods + * @{ + */ + + /** + * Auto-create the given user, if necessary + * @private Don't call this yourself. Let Setup.php do it for you at the right time. + * @note This more properly belongs in AuthManager, but we need it now. + * When AuthManager comes, this will be deprecated and will pass-through + * to the corresponding AuthManager method. + * @param User $user User to auto-create + * @return bool Success + */ + public static function autoCreateUser( User $user ) { + global $wgAuth; + + $logger = self::singleton()->logger; + + // Much of this code is based on that in CentralAuth + + // Try the local user from the slave DB + $localId = User::idFromName( $user->getName() ); + + // Fetch the user ID from the master, so that we don't try to create the user + // when they already exist, due to replication lag + // @codeCoverageIgnoreStart + if ( !$localId && wfGetLB()->getReaderIndex() != 0 ) { + $localId = User::idFromName( $user->getName(), User::READ_LATEST ); + } + // @codeCoverageIgnoreEnd + + if ( $localId ) { + // User exists after all. + $user->setId( $localId ); + $user->loadFromId(); + return false; + } + + // Denied by AuthPlugin? But ignore AuthPlugin itself. + if ( get_class( $wgAuth ) !== 'AuthPlugin' && !$wgAuth->autoCreate() ) { + $logger->debug( __METHOD__ . ': denied by AuthPlugin' ); + $user->setId( 0 ); + $user->loadFromId(); + return false; + } + + // Wiki is read-only? + if ( wfReadOnly() ) { + $logger->debug( __METHOD__ . ': denied by wfReadOnly()' ); + $user->setId( 0 ); + $user->loadFromId(); + return false; + } + + $userName = $user->getName(); + + // Check the session, if we tried to create this user already there's + // no point in retrying. + $session = self::getGlobalSession(); + $reason = $session->get( 'MWSession::AutoCreateBlacklist' ); + if ( $reason ) { + $logger->debug( __METHOD__ . ": blacklisted in session ($reason)" ); + $user->setId( 0 ); + $user->loadFromId(); + return false; + } + + // Is the IP user able to create accounts? + $anon = new User; + if ( !$anon->isAllowedAny( 'createaccount', 'autocreateaccount' ) + || $anon->isBlockedFromCreateAccount() + ) { + // Blacklist the user to avoid repeated DB queries subsequently + $logger->debug( __METHOD__ . ': user is blocked from this wiki, blacklisting' ); + $session->set( 'MWSession::AutoCreateBlacklist', 'blocked', 600 ); + $session->persist(); + $user->setId( 0 ); + $user->loadFromId(); + return false; + } + + // Check for validity of username + if ( !User::isCreatableName( $userName ) ) { + $logger->debug( __METHOD__ . ': Invalid username, blacklisting' ); + $session->set( 'MWSession::AutoCreateBlacklist', 'invalid username', 600 ); + $session->persist(); + $user->setId( 0 ); + $user->loadFromId(); + return false; + } + + // Give other extensions a chance to stop auto creation. + $user->loadDefaults( $userName ); + $abortMessage = ''; + if ( !\Hooks::run( 'AbortAutoAccount', array( $user, &$abortMessage ) ) ) { + // In this case we have no way to return the message to the user, + // but we can log it. + $logger->debug( __METHOD__ . ": denied by hook: $abortMessage" ); + $session->set( 'MWSession::AutoCreateBlacklist', "hook aborted: $abortMessage", 600 ); + $session->persist(); + $user->setId( 0 ); + $user->loadFromId(); + return false; + } + + // Make sure the name has not been changed + if ( $user->getName() !== $userName ) { + $user->setId( 0 ); + $user->loadFromId(); + throw new \UnexpectedValueException( + 'AbortAutoAccount hook tried to change the user name' + ); + } + + // Ignore warnings about master connections/writes...hard to avoid here + \Profiler::instance()->getTransactionProfiler()->resetExpectations(); + + $cache = \ObjectCache::getLocalClusterInstance(); + $backoffKey = wfMemcKey( 'MWSession', 'autocreate-failed', md5( $userName ) ); + if ( $cache->get( $backoffKey ) ) { + $logger->debug( __METHOD__ . ': denied by prior creation attempt failures' ); + $user->setId( 0 ); + $user->loadFromId(); + return false; + } + + // Checks passed, create the user... + $from = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : 'CLI'; + $logger->info( __METHOD__ . ": creating new user ($userName) - from: $from" ); + + try { + // Insert the user into the local DB master + $status = $user->addToDatabase(); + if ( !$status->isOK() ) { + // @codeCoverageIgnoreStart + $logger->error( __METHOD__ . ': failed with message ' . $status->getWikiText() ); + $user->setId( 0 ); + $user->loadFromId(); + return false; + // @codeCoverageIgnoreEnd + } + } catch ( \Exception $ex ) { + // @codeCoverageIgnoreStart + $logger->error( __METHOD__ . ': failed with exception ' . $ex->getMessage() ); + // Do not keep throwing errors for a while + $cache->set( $backoffKey, 1, 600 ); + // Bubble up error; which should normally trigger DB rollbacks + throw $ex; + // @codeCoverageIgnoreEnd + } + + # Notify hooks (e.g. Newuserlog) + \Hooks::run( 'AuthPluginAutoCreate', array( $user ) ); + \Hooks::run( 'LocalUserCreated', array( $user, true ) ); + + # Update user count + \DeferredUpdates::addUpdate( new \SiteStatsUpdate( 0, 0, 0, 0, 1 ) ); + + # Watch user's userpage and talk page + $user->addWatch( $user->getUserPage(), \WatchedItem::IGNORE_USER_RIGHTS ); + + return true; + } + + /** + * Prevent future sessions for the user + * + * The intention is that the named account will never again be usable for + * normal login (i.e. there is no way to undo the prevention of access). + * + * @private For use from \\User::newSystemUser only + * @param string $username + */ + public function preventSessionsForUser( $username ) { + $this->preventUsers[$username] = true; + + // Reset the user's token to kill existing sessions + $user = User::newFromName( $username ); + if ( $user && $user->getToken() ) { + $user->setToken( true ); + $user->saveSettings(); + } + + // Instruct the session providers to kill any other sessions too. + foreach ( $this->getProviders() as $provider ) { + $provider->preventSessionsForUser( $username ); + } + } + + /** + * Test if a user is prevented + * @private For use from SessionBackend only + * @param string $username + * @return bool + */ + public function isUserSessionPrevented( $username ) { + return !empty( $this->preventUsers[$username] ); + } + + /** + * Get the available SessionProviders + * @return SessionProvider[] + */ + protected function getProviders() { + if ( $this->sessionProviders === null ) { + $this->sessionProviders = array(); + foreach ( $this->config->get( 'SessionProviders' ) as $spec ) { + $provider = \ObjectFactory::getObjectFromSpec( $spec ); + $provider->setLogger( $this->logger ); + $provider->setConfig( $this->config ); + $provider->setManager( $this ); + if ( isset( $this->sessionProviders[(string)$provider] ) ) { + throw new \UnexpectedValueException( "Duplicate provider name \"$provider\"" ); + } + $this->sessionProviders[(string)$provider] = $provider; + } + } + return $this->sessionProviders; + } + + /** + * Get a session provider by name + * + * Generally, this will only be used by internal implementation of some + * special session-providing mechanism. General purpose code, if it needs + * to access a SessionProvider at all, will use Session::getProvider(). + * + * @param string $name + * @return SessionProvider|null + */ + public function getProvider( $name ) { + $providers = $this->getProviders(); + return isset( $providers[$name] ) ? $providers[$name] : null; + } + + /** + * Save all active sessions on shutdown + * @private For internal use with register_shutdown_function() + */ + public function shutdown() { + if ( $this->allSessionBackends ) { + $this->logger->debug( 'Saving all sessions on shutdown' ); + if ( session_id() !== '' ) { + // @codeCoverageIgnoreStart + session_write_close(); + } + // @codeCoverageIgnoreEnd + foreach ( $this->allSessionBackends as $backend ) { + $backend->save( true ); + } + } + } + + /** + * Fetch the SessionInfo(s) for a request + * @param WebRequest $request + * @return SessionInfo|null + */ + private function getSessionInfoForRequest( WebRequest $request ) { + // Call all providers to fetch "the" session + $infos = array(); + foreach ( $this->getProviders() as $provider ) { + $info = $provider->provideSessionInfo( $request ); + if ( !$info ) { + continue; + } + if ( $info->getProvider() !== $provider ) { + throw new \UnexpectedValueException( + "$provider returned session info for a different provider: $info" + ); + } + $infos[] = $info; + } + + // Sort the SessionInfos. Then find the first one that can be + // successfully loaded, and then all the ones after it with the same + // priority. + usort( $infos, 'MediaWiki\\Session\\SessionInfo::compare' ); + $retInfos = array(); + while ( $infos ) { + $info = array_pop( $infos ); + if ( $this->loadSessionInfoFromStore( $info, $request ) ) { + $retInfos[] = $info; + while ( $infos ) { + $info = array_pop( $infos ); + if ( SessionInfo::compare( $retInfos[0], $info ) ) { + // We hit a lower priority, stop checking. + break; + } + if ( $this->loadSessionInfoFromStore( $info, $request ) ) { + // This is going to error out below, but we want to + // provide a complete list. + $retInfos[] = $info; + } + } + } + } + + if ( count( $retInfos ) > 1 ) { + $ex = new \OverflowException( + 'Multiple sessions for this request tied for top priority: ' . join( ', ', $retInfos ) + ); + $ex->sessionInfos = $retInfos; + throw $ex; + } + + return $retInfos ? $retInfos[0] : null; + } + + /** + * Load and verify the session info against the store + * + * @param SessionInfo &$info Will likely be replaced with an updated SessionInfo instance + * @param WebRequest $request + * @return bool Whether the session info matches the stored data (if any) + */ + private function loadSessionInfoFromStore( SessionInfo &$info, WebRequest $request ) { + $blob = $this->store->get( wfMemcKey( 'MWSession', $info->getId() ) ); + + $newParams = array(); + + if ( $blob !== false ) { + // Sanity check: blob must be an array, if it's saved at all + if ( !is_array( $blob ) ) { + $this->logger->warning( "Session $info: Bad data" ); + return false; + } + + // Sanity check: blob has data and metadata arrays + if ( !isset( $blob['data'] ) || !is_array( $blob['data'] ) || + !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] ) + ) { + $this->logger->warning( "Session $info: Bad data structure" ); + return false; + } + + $data = $blob['data']; + $metadata = $blob['metadata']; + + // Sanity check: metadata must be an array and must contain certain + // keys, if it's saved at all + if ( !array_key_exists( 'userId', $metadata ) || + !array_key_exists( 'userName', $metadata ) || + !array_key_exists( 'userToken', $metadata ) || + !array_key_exists( 'provider', $metadata ) + ) { + $this->logger->warning( "Session $info: Bad metadata" ); + return false; + } + + // First, load the provider from metadata, or validate it against the metadata. + $provider = $info->getProvider(); + if ( $provider === null ) { + $newParams['provider'] = $provider = $this->getProvider( $metadata['provider'] ); + if ( !$provider ) { + $this->logger->warning( "Session $info: Unknown provider, " . $metadata['provider'] ); + return false; + } + } elseif ( $metadata['provider'] !== (string)$provider ) { + $this->logger->warning( "Session $info: Wrong provider, " . + $metadata['provider'] . ' !== ' . $provider ); + return false; + } + + // Load provider metadata from metadata, or validate it against the metadata + $providerMetadata = $info->getProviderMetadata(); + if ( isset( $metadata['providerMetadata'] ) ) { + if ( $providerMetadata === null ) { + $newParams['metadata'] = $metadata['providerMetadata']; + } else { + try { + $newProviderMetadata = $provider->mergeMetadata( + $metadata['providerMetadata'], $providerMetadata + ); + if ( $newProviderMetadata !== $providerMetadata ) { + $newParams['metadata'] = $newProviderMetadata; + } + } catch ( \UnexpectedValueException $ex ) { + $this->logger->warning( "Session $info: Metadata merge failed: " . $ex->getMessage() ); + return false; + } + } + } + + // Next, load the user from metadata, or validate it against the metadata. + $userInfo = $info->getUserInfo(); + if ( !$userInfo ) { + // For loading, id is preferred to name. + try { + if ( $metadata['userId'] ) { + $userInfo = UserInfo::newFromId( $metadata['userId'] ); + } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case + $userInfo = UserInfo::newFromName( $metadata['userName'] ); + } else { + $userInfo = UserInfo::newAnonymous(); + } + } catch ( \InvalidArgumentException $ex ) { + $this->logger->error( "Session $info: " . $ex->getMessage() ); + return false; + } + $newParams['userInfo'] = $userInfo; + } else { + // User validation passes if user ID matches, or if there + // is no saved ID and the names match. + if ( $metadata['userId'] ) { + if ( $metadata['userId'] !== $userInfo->getId() ) { + $this->logger->warning( "Session $info: User ID mismatch, " . + $metadata['userId'] . ' !== ' . $userInfo->getId() ); + return false; + } + + // If the user was renamed, probably best to fail here. + if ( $metadata['userName'] !== null && + $userInfo->getName() !== $metadata['userName'] + ) { + $this->logger->warning( "Session $info: User ID matched but name didn't (rename?), " . + $metadata['userName'] . ' !== ' . $userInfo->getName() ); + return false; + } + + } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case + if ( $metadata['userName'] !== $userInfo->getName() ) { + $this->logger->warning( "Session $info: User name mismatch, " . + $metadata['userName'] . ' !== ' . $userInfo->getName() ); + return false; + } + } elseif ( !$userInfo->isAnon() ) { + // Metadata specifies an anonymous user, but the passed-in + // user isn't anonymous. + $this->logger->warning( + "Session $info: Metadata has an anonymous user, " . + 'but a non-anon user was provided' + ); + return false; + } + } + + // And if we have a token in the metadata, it must match the loaded/provided user. + if ( $metadata['userToken'] !== null && + $userInfo->getToken() !== $metadata['userToken'] + ) { + $this->logger->warning( "Session $info: User token mismatch" ); + return false; + } + if ( !$userInfo->isVerified() ) { + $newParams['userInfo'] = $userInfo->verified(); + } + + if ( !empty( $metadata['remember'] ) && !$info->wasRemembered() ) { + $newParams['remembered'] = true; + } + if ( !empty( $metadata['forceHTTPS'] ) && !$info->forceHTTPS() ) { + $newParams['forceHTTPS'] = true; + } + + if ( !$info->isIdSafe() ) { + $newParams['idIsSafe'] = true; + } + } else { + // No metadata, so we can't load the provider if one wasn't given. + if ( $info->getProvider() === null ) { + $this->logger->warning( "Session $info: Null provider and no metadata" ); + return false; + } + + // If no user was provided and no metadata, it must be anon. + if ( !$info->getUserInfo() ) { + if ( $info->getProvider()->canChangeUser() ) { + $newParams['userInfo'] = UserInfo::newAnonymous(); + } else { + $this->logger->info( + "Session $info: No user provided and provider cannot set user" + ); + return false; + } + } elseif ( !$info->getUserInfo()->isVerified() ) { + $this->logger->warning( + "Session $info: Unverified user provided and no metadata to auth it" + ); + return false; + } + + $data = false; + $metadata = false; + + if ( !$info->getProvider()->persistsSessionId() && !$info->isIdSafe() ) { + // The ID doesn't come from the user, so it should be safe + // (and if not, nothing we can do about it anyway) + $newParams['idIsSafe'] = true; + } + } + + // Construct the replacement SessionInfo, if necessary + if ( $newParams ) { + $newParams['copyFrom'] = $info; + $info = new SessionInfo( $info->getPriority(), $newParams ); + } + + // Allow the provider to check the loaded SessionInfo + $providerMetadata = $info->getProviderMetadata(); + if ( !$info->getProvider()->refreshSessionInfo( $info, $request, $providerMetadata ) ) { + return false; + } + if ( $providerMetadata !== $info->getProviderMetadata() ) { + $info = new SessionInfo( $info->getPriority(), array( + 'metadata' => $providerMetadata, + 'copyFrom' => $info, + ) ); + } + + // Give hooks a chance to abort. Combined with the SessionMetadata + // hook, this can allow for tying a session to an IP address or the + // like. + $reason = 'Hook aborted'; + if ( !\Hooks::run( + 'SessionCheckInfo', + array( &$reason, $info, $request, $metadata, $data ) + ) ) { + $this->logger->warning( "Session $info: $reason" ); + return false; + } + + return true; + } + + /** + * Create a session corresponding to the passed SessionInfo + * @private For use by a SessionProvider that needs to specially create its + * own session. + * @param SessionInfo $info + * @param WebRequest $request + * @return Session + */ + public function getSessionFromInfo( SessionInfo $info, WebRequest $request ) { + $id = $info->getId(); + + if ( !isset( $this->allSessionBackends[$id] ) ) { + if ( !isset( $this->allSessionIds[$id] ) ) { + $this->allSessionIds[$id] = new SessionId( $id ); + } + $backend = new SessionBackend( + $this->allSessionIds[$id], + $info, + $this->store, + $this->logger, + $this->config->get( 'ObjectCacheSessionExpiry' ) + ); + $this->allSessionBackends[$id] = $backend; + $delay = $backend->delaySave(); + } else { + $backend = $this->allSessionBackends[$id]; + $delay = $backend->delaySave(); + if ( $info->wasPersisted() ) { + $backend->persist(); + } + if ( $info->wasRemembered() ) { + $backend->setRememberUser( true ); + } + } + + $request->setSessionId( $backend->getSessionId() ); + $session = $backend->getSession( $request ); + + if ( !$info->isIdSafe() ) { + $session->resetId(); + } + + \ScopedCallback::consume( $delay ); + return $session; + } + + /** + * Deregister a SessionBackend + * @private For use from \\MediaWiki\\Session\\SessionBackend only + * @param SessionBackend $backend + */ + public function deregisterSessionBackend( SessionBackend $backend ) { + $id = $backend->getId(); + if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) || + $this->allSessionBackends[$id] !== $backend || + $this->allSessionIds[$id] !== $backend->getSessionId() + ) { + throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' ); + } + + unset( $this->allSessionBackends[$id] ); + // Explicitly do not unset $this->allSessionIds[$id] + } + + /** + * Change a SessionBackend's ID + * @private For use from \\MediaWiki\\Session\\SessionBackend only + * @param SessionBackend $backend + */ + public function changeBackendId( SessionBackend $backend ) { + $sessionId = $backend->getSessionId(); + $oldId = (string)$sessionId; + if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) || + $this->allSessionBackends[$oldId] !== $backend || + $this->allSessionIds[$oldId] !== $sessionId + ) { + throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' ); + } + + $newId = $this->generateSessionId(); + + unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] ); + $sessionId->setId( $newId ); + $this->allSessionBackends[$newId] = $backend; + $this->allSessionIds[$newId] = $sessionId; + } + + /** + * Generate a new random session ID + * @return string + */ + public function generateSessionId() { + do { + $id = wfBaseConvert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 ); + $key = wfMemcKey( 'MWSession', $id ); + } while ( isset( $this->allSessionIds[$id] ) || is_array( $this->store->get( $key ) ) ); + return $id; + } + + /** + * Call setters on a PHPSessionHandler + * @private Use PhpSessionHandler::install() + * @param PHPSessionHandler $handler + */ + public function setupPHPSessionHandler( PHPSessionHandler $handler ) { + $handler->setManager( $this, $this->store, $this->logger ); + } + + /** + * Reset the internal caching for unit testing + */ + public static function resetCache() { + if ( !defined( 'MW_PHPUNIT_TEST' ) ) { + // @codeCoverageIgnoreStart + throw new MWException( __METHOD__ . ' may only be called from unit tests!' ); + // @codeCoverageIgnoreEnd + } + + self::$globalSession = null; + self::$globalSessionRequest = null; + } + + /**@}*/ + +} diff --git a/includes/session/SessionManagerInterface.php b/includes/session/SessionManagerInterface.php new file mode 100644 index 0000000000..67d6f5d7d8 --- /dev/null +++ b/includes/session/SessionManagerInterface.php @@ -0,0 +1,109 @@ +getSession() instead. It's more + * efficient and doesn't break FauxRequests or sessions that were changed + * by $this->getSessionById() or $this->getEmptySession(). + * @param WebRequest $request Any existing associated session will be reset + * to the session corresponding to the data in the request itself. + * @return Session + * @throws \\OverflowException if there are multiple sessions tied for top + * priority in the request. Exception has a property "sessionInfos" + * holding the SessionInfo objects for the sessions involved. + */ + public function getSessionForRequest( WebRequest $request ); + + /** + * Fetch a session by ID + * @param string $id + * @param bool $noEmpty Don't return an empty session + * @param WebRequest|null $request Corresponding request. Any existing + * session associated with this WebRequest object will be overwritten. + * @return Session|null + */ + public function getSessionById( $id, $noEmpty = false, WebRequest $request = null ); + + /** + * Fetch a new, empty session + * + * The first provider configured that is able to provide an empty session + * will be used. + * + * @param WebRequest|null $request Corresponding request. Any existing + * session associated with this WebRequest object will be overwritten. + * @return Session + */ + public function getEmptySession( WebRequest $request = null ); + + /** + * Return the HTTP headers that need varying on. + * + * The return value is such that someone could theoretically do this: + * @code + * foreach ( $provider->getVaryHeaders() as $header => $options ) { + * $outputPage->addVaryHeader( $header, $options ); + * } + * @endcode + * + * @return array + */ + public function getVaryHeaders(); + + /** + * Return the list of cookies that need varying on. + * @return string[] + */ + public function getVaryCookies(); + +} diff --git a/includes/session/SessionProvider.php b/includes/session/SessionProvider.php new file mode 100644 index 0000000000..18cc04c0eb --- /dev/null +++ b/includes/session/SessionProvider.php @@ -0,0 +1,473 @@ +priority = SessionInfo::MIN_PRIORITY + 10; + } + + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + /** + * Set configuration + * @param Config $config + */ + public function setConfig( Config $config ) { + $this->config = $config; + } + + /** + * Set the session manager + * @param SessionManager $manager + */ + public function setManager( SessionManager $manager ) { + $this->manager = $manager; + } + + /** + * Get the session manager + * @return SessionManager + */ + public function getManager() { + return $this->manager; + } + + /** + * Provide session info for a request + * + * If no session exists for the request, return null. Otherwise return an + * SessionInfo object identifying the session. + * + * If multiple SessionProviders provide sessions, the one with highest + * priority wins. In case of a tie, an exception is thrown. + * SessionProviders are encouraged to make priorities user-configurable + * unless only max-priority makes sense. + * + * @warning This will be called early in the MediaWiki setup process, + * before $wgUser, $wgLang, $wgOut, $wgParser, $wgTitle, and corresponding + * pieces of the main RequestContext are set up! If you try to use these, + * things *will* break. + * @note The SessionProvider must not attempt to auto-create users. + * MediaWiki will do this later (when it's safe) if the chosen session has + * a user with a valid name but no ID. + * @protected For use by \\MediaWiki\\Session\\SessionManager only + * @param WebRequest $request + * @return SessionInfo|null + */ + abstract public function provideSessionInfo( WebRequest $request ); + + /** + * Provide session info for a new, empty session + * + * Return null if such a session cannot be created. This base + * implementation assumes that it only makes sense if a session ID can be + * persisted and changing users is allowed. + * + * @protected For use by \\MediaWiki\\Session\\SessionManager only + * @param string|null $id ID to force for the new session + * @return SessionInfo|null + * If non-null, must return true for $info->isIdSafe(); pass true for + * $data['idIsSafe'] to ensure this. + */ + public function newSessionInfo( $id = null ) { + if ( $this->canChangeUser() && $this->persistsSessionId() ) { + return new SessionInfo( $this->priority, array( + 'id' => $id, + 'provider' => $this, + 'persisted' => false, + 'idIsSafe' => true, + ) ); + } + return null; + } + + /** + * Merge saved session provider metadata + * + * The default implementation checks that anything in both arrays is + * identical, then returns $providedMetadata. + * + * @protected For use by \\MediaWiki\\Session\\SessionManager only + * @param array $savedMetadata Saved provider metadata + * @param array $providedMetadata Provided provider metadata + * @return array Resulting metadata + * @throws \UnexpectedValueException If the metadata cannot be merged + */ + public function mergeMetadata( array $savedMetadata, array $providedMetadata ) { + foreach ( $providedMetadata as $k => $v ) { + if ( array_key_exists( $k, $savedMetadata ) && $savedMetadata[$k] !== $v ) { + throw new \UnexpectedValueException( "Key \"$k\" changed" ); + } + } + return $providedMetadata; + } + + /** + * Validate a loaded SessionInfo and refresh provider metadata + * + * This is similar in purpose to the 'SessionCheckInfo' hook, and also + * allows for updating the provider metadata. On failure, the provider is + * expected to write an appropriate message to its logger. + * + * @protected For use by \\MediaWiki\\Session\\SessionManager only + * @param SessionInfo $info + * @param WebRequest $request + * @param array|null &$metadata Provider metadata, may be altered. + * @return bool Return false to reject the SessionInfo after all. + */ + public function refreshSessionInfo( SessionInfo $info, WebRequest $request, &$metadata ) { + return true; + } + + /** + * Indicate whether self::persistSession() can save arbitrary session IDs + * + * If false, any session passed to self::persistSession() will have an ID + * that was originally provided by self::provideSessionInfo(). + * + * If true, the provider may be passed sessions with arbitrary session IDs, + * and will be expected to manipulate the request in such a way that future + * requests will cause self::provideSessionInfo() to provide a SessionInfo + * with that ID. + * + * For example, a session provider for OAuth would function by matching the + * OAuth headers to a particular user, and then would use self::hashToSessionId() + * to turn the user and OAuth client ID (and maybe also the user token and + * client secret) into a session ID, and therefore can't easily assign that + * user+client a different ID. Similarly, a session provider for SSL client + * certificates would function by matching the certificate to a particular + * user, and then would use self::hashToSessionId() to turn the user and + * certificate fingerprint into a session ID, and therefore can't easily + * assign a different ID either. On the other hand, a provider that saves + * the session ID into a cookie can easily just set the cookie to a + * different value. + * + * @protected For use by \\MediaWiki\\Session\\SessionBackend only + * @return bool + */ + abstract public function persistsSessionId(); + + /** + * Indicate whether the user associated with the request can be changed + * + * If false, any session passed to self::persistSession() will have a user + * that was originally provided by self::provideSessionInfo(). Further, + * self::provideSessionInfo() may only provide sessions that have a user + * already set. + * + * If true, the provider may be passed sessions with arbitrary users, and + * will be expected to manipulate the request in such a way that future + * requests will cause self::provideSessionInfo() to provide a SessionInfo + * with that ID. This can be as simple as not passing any 'userInfo' into + * SessionInfo's constructor, in which case SessionInfo will load the user + * from the saved session's metadata. + * + * For example, a session provider for OAuth or SSL client certificates + * would function by matching the OAuth headers or certificate to a + * particular user, and thus would return false here since it can't + * arbitrarily assign those OAuth credentials or that certificate to a + * different user. A session provider that shoves information into cookies, + * on the other hand, could easily do so. + * + * @protected For use by \\MediaWiki\\Session\\SessionBackend only + * @return bool + */ + abstract public function canChangeUser(); + + /** + * Notification that the session ID was reset + * + * No need to persist here, persistSession() will be called if appropriate. + * + * @protected For use by \\MediaWiki\\Session\\SessionBackend only + * @param SessionBackend $session Session to persist + * @param string $oldId Old session ID + * @codeCoverageIgnore + */ + public function sessionIdWasReset( SessionBackend $session, $oldId ) { + } + + /** + * Persist a session into a request/response + * + * For example, you might set cookies for the session's ID, user ID, user + * name, and user token on the passed request. + * + * To correctly persist a user independently of the session ID, the + * provider should persist both the user ID (or name, but preferably the + * ID) and the user token. When reading the data from the request, it + * should construct a User object from the ID/name and then verify that the + * User object's token matches the token included in the request. Should + * the tokens not match, an anonymous user *must* be passed to + * SessionInfo::__construct(). + * + * When persisting a user independently of the session ID, + * $session->shouldRememberUser() should be checked first. If this returns + * false, the user token *must not* be saved to cookies. The user name + * and/or ID may be persisted, and should be used to construct an + * unverified UserInfo to pass to SessionInfo::__construct(). + * + * A backend that cannot persist sesison ID or user info should implement + * this as a no-op. + * + * @protected For use by \\MediaWiki\\Session\\SessionBackend only + * @param SessionBackend $session Session to persist + * @param WebRequest $request Request into which to persist the session + */ + abstract public function persistSession( SessionBackend $session, WebRequest $request ); + + /** + * Remove any persisted session from a request/response + * + * For example, blank and expire any cookies set by self::persistSession(). + * + * A backend that cannot persist sesison ID or user info should implement + * this as a no-op. + * + * @protected For use by \\MediaWiki\\Session\\SessionManager only + * @param WebRequest $request Request from which to remove any session data + */ + abstract public function unpersistSession( WebRequest $request ); + + /** + * Prevent future sessions for the user + * + * If the provider is capable of returning a SessionInfo with a verified + * UserInfo for the named user in some manner other than by validating + * against $user->getToken(), steps must be taken to prevent that from + * occurring in the future. This might add the username to a blacklist, or + * it might just delete whatever authentication credentials would allow + * such a session in the first place (e.g. remove all OAuth grants or + * delete record of the SSL client certificate). + * + * The intention is that the named account will never again be usable for + * normal login (i.e. there is no way to undo the prevention of access). + * + * Note that the passed user name might not exist locally (i.e. + * User::idFromName( $username ) === 0); the name should still be + * prevented, if applicable. + * + * @protected For use by \\MediaWiki\\Session\\SessionManager only + * @param string $username + */ + public function preventSessionsForUser( $username ) { + if ( !$this->canChangeUser() ) { + throw new \BadMethodCallException( + __METHOD__ . ' must be implmented when canChangeUser() is false' + ); + } + } + + /** + * Return the HTTP headers that need varying on. + * + * The return value is such that someone could theoretically do this: + * @code + * foreach ( $provider->getVaryHeaders() as $header => $options ) { + * $outputPage->addVaryHeader( $header, $options ); + * } + * @endcode + * + * @protected For use by \\MediaWiki\\Session\\SessionManager only + * @return array + */ + public function getVaryHeaders() { + return array(); + } + + /** + * Return the list of cookies that need varying on. + * @protected For use by \\MediaWiki\\Session\\SessionManager only + * @return string[] + */ + public function getVaryCookies() { + return array(); + } + + /** + * Get a suggested username for the login form + * @protected For use by \\MediaWiki\\Session\\SessionBackend only + * @param WebRequest $request + * @return string|null + */ + public function suggestLoginUsername( WebRequest $request ) { + return null; + } + + /** + * @note Only override this if it makes sense to instantiate multiple + * instances of the provider. Value returned must be unique across + * configured providers. If you override this, you'll likely need to + * override self::describeMessage() as well. + * @return string + */ + public function __toString() { + return get_class( $this ); + } + + /** + * Return a Message identifying this session type + * + * This default implementation takes the class name, lowercases it, + * replaces backslashes with dashes, and prefixes 'sessionprovider-' to + * determine the message key. For example, MediaWiki\\Session\\CookieSessionProvider + * produces 'sessionprovider-mediawiki-session-cookiesessionprovider'. + * + * @note If self::__toString() is overridden, this will likely need to be + * overridden as well. + * @warning This will be called early during MediaWiki startup. Do not + * use $wgUser, $wgLang, $wgOut, $wgParser, or their equivalents via + * RequestContext from this method! + * @return Message + */ + protected function describeMessage() { + return wfMessage( + 'sessionprovider-' . str_replace( '\\', '-', strtolower( get_class( $this ) ) ) + ); + } + + public function describe( Language $lang ) { + $msg = $this->describeMessage(); + $msg->inLanguage( $lang ); + if ( $msg->isDisabled() ) { + $msg = wfMessage( 'sessionprovider-generic', (string)$this )->inLanguage( $lang ); + } + return $msg->plain(); + } + + public function whyNoSession() { + return null; + } + + /** + * Hash data as a session ID + * + * Generally this will only be used when self::persistsSessionId() is false and + * the provider has to base the session ID on the verified user's identity + * or other static data. + * + * @param string $data + * @param string|null $key Defaults to $this->config->get( 'SecretKey' ) + * @return string + */ + final protected function hashToSessionId( $data, $key = null ) { + if ( !is_string( $data ) ) { + throw new \InvalidArgumentException( + '$data must be a string, ' . gettype( $data ) . ' was passed' + ); + } + if ( $key !== null && !is_string( $key ) ) { + throw new \InvalidArgumentException( + '$key must be a string or null, ' . gettype( $key ) . ' was passed' + ); + } + + $hash = \MWCryptHash::hmac( "$this\n$data", $key ?: $this->config->get( 'SecretKey' ), false ); + if ( strlen( $hash ) < 32 ) { + // Should never happen, even md5 is 128 bits + // @codeCoverageIgnoreStart + throw new \UnexpectedValueException( 'Hash fuction returned less than 128 bits' ); + // @codeCoverageIgnoreEnd + } + if ( strlen( $hash ) >= 40 ) { + $hash = wfBaseConvert( $hash, 16, 32, 32 ); + } + return substr( $hash, -32 ); + } + +} diff --git a/includes/session/SessionProviderInterface.php b/includes/session/SessionProviderInterface.php new file mode 100644 index 0000000000..02ae23d551 --- /dev/null +++ b/includes/session/SessionProviderInterface.php @@ -0,0 +1,54 @@ +isAnon() && !User::isUsableName( $user->getName() ) ) { + $this->verified = true; + $this->user = null; + } else { + $this->verified = $verified; + $this->user = $user; + } + } + + /** + * Create an instance for an anonymous (i.e. not logged in) user + * + * Logged-out users are always "verified". + * + * @return UserInfo + */ + public static function newAnonymous() { + return new self( null, true ); + } + + /** + * Create an instance for a logged-in user by ID + * @param int $id User ID + * @param bool $verified True if the user is verified + * @return UserInfo + */ + public static function newFromId( $id, $verified = false ) { + $user = User::newFromId( $id ); + + // Ensure the ID actually exists + $user->load(); + if ( $user->isAnon() ) { + throw new \InvalidArgumentException( 'Invalid ID' ); + } + + return new self( $user, $verified ); + } + + /** + * Create an instance for a logged-in user by name + * @param string $name User name (need not exist locally) + * @param bool $verified True if the user is verified + * @return UserInfo + */ + public static function newFromName( $name, $verified = false ) { + $user = User::newFromName( $name, 'usable' ); + if ( !$user ) { + throw new \InvalidArgumentException( 'Invalid user name' ); + } + return new self( $user, $verified ); + } + + /** + * Create an instance from an existing User object + * @param User $user (need not exist locally) + * @param bool $verified True if the user is verified + * @return UserInfo + */ + public static function newFromUser( User $user, $verified = false ) { + return new self( $user, $verified ); + } + + /** + * Return whether this is an anonymous user + * @return bool + */ + public function isAnon() { + return $this->user === null; + } + + /** + * Return whether this represents a verified user + * @return bool + */ + public function isVerified() { + return $this->verified; + } + + /** + * Return the user ID + * @note Do not use this to test for anonymous users! + * @return int + */ + public function getId() { + return $this->user === null ? 0 : $this->user->getId(); + } + + /** + * Return the user name + * @return string|null + */ + public function getName() { + return $this->user === null ? null : $this->user->getName(); + } + + /** + * Return the user token + * @return string|null + */ + public function getToken() { + return $this->user === null || $this->user->getId() === 0 ? null : $this->user->getToken( true ); + } + + /** + * Return a User object + * @return User + */ + public function getUser() { + return $this->user === null ? new User : $this->user; + } + + /** + * Return a verified version of this object + * @return UserInfo + */ + public function verified() { + return $this->verified ? $this : new self( $this->user, true ); + } + + public function __toString() { + if ( $this->user === null ) { + return ''; + } + return '<' . + ( $this->verified ? '+' : '-' ) . ':' . + $this->getId() . ':' . $this->getName() . + '>'; + } + +} diff --git a/includes/specials/SpecialUserlogin.php b/includes/specials/SpecialUserlogin.php index fec1e3a5db..620b55da15 100644 --- a/includes/specials/SpecialUserlogin.php +++ b/includes/specials/SpecialUserlogin.php @@ -21,6 +21,7 @@ * @ingroup SpecialPage */ use MediaWiki\Logger\LoggerFactory; +use MediaWiki\Session\SessionManager; /** * Implements Special:UserLogin @@ -263,9 +264,9 @@ class LoginForm extends SpecialPage { * @param string|null $subPage */ public function execute( $subPage ) { - if ( session_id() == '' ) { - wfSetupSession(); - } + // Make sure session is persisted + $session = MediaWiki\Session\SessionManager::getGlobalSession(); + $session->persist(); $this->load(); @@ -276,6 +277,17 @@ class LoginForm extends SpecialPage { } $this->setHeaders(); + // Make sure it's possible to log in + if ( $this->mType !== 'signup' && !$session->canSetUser() ) { + throw new ErrorPageError( + 'cannotloginnow-title', + 'cannotloginnow-text', + array( + $session->getProvider()->describe( RequestContext::getMain()->getLanguage() ) + ) + ); + } + /** * In the case where the user is already logged in, and was redirected to * the login form from a page that requires login, do not show the login @@ -1376,7 +1388,7 @@ class LoginForm extends SpecialPage { if ( $user->isLoggedIn() ) { $this->mUsername = $user->getName(); } else { - $this->mUsername = $this->getRequest()->getCookie( 'UserName' ); + $this->mUsername = $this->getRequest()->getSession()->suggestLoginUsername(); } } @@ -1552,7 +1564,8 @@ class LoginForm extends SpecialPage { function hasSessionCookie() { global $wgDisableCookieCheck; - return $wgDisableCookieCheck ? true : $this->getRequest()->checkSessionCookie(); + return $wgDisableCookieCheck || + SessionManager::singleton()->getPersistedSessionId( $this->getRequest() ) !== null; } /** @@ -1571,7 +1584,7 @@ class LoginForm extends SpecialPage { public static function setLoginToken() { global $wgRequest; // Generate a token directly instead of using $user->getEditToken() - // because the latter reuses $_SESSION['wsEditToken'] + // because the latter reuses wsEditToken in the session $wgRequest->setSessionData( 'wsLoginToken', MWCryptRand::generateHex( 32 ) ); } @@ -1617,7 +1630,7 @@ class LoginForm extends SpecialPage { $wgCookieSecure = false; } - wfResetSessionID(); + MediaWiki\Session\SessionManager::getGlobalSession()->resetId(); } /** diff --git a/includes/specials/SpecialUserlogout.php b/includes/specials/SpecialUserlogout.php index 080dc11943..b79bf0926b 100644 --- a/includes/specials/SpecialUserlogout.php +++ b/includes/specials/SpecialUserlogout.php @@ -44,6 +44,18 @@ class SpecialUserlogout extends UnlistedSpecialPage { $this->setHeaders(); $this->outputHeader(); + // Make sure it's possible to log out + $session = MediaWiki\Session\SessionManager::getGlobalSession(); + if ( !$session->canSetUser() ) { + throw new ErrorPageError( + 'cannotlogoutnow-title', + 'cannotlogoutnow-text', + array( + $session->getProvider()->describe( RequestContext::getMain()->getLanguage() ) + ) + ); + } + $user = $this->getUser(); $oldName = $user->getName(); $user->logout(); diff --git a/includes/upload/UploadFromUrl.php b/includes/upload/UploadFromUrl.php index f897a796b9..23a4962699 100644 --- a/includes/upload/UploadFromUrl.php +++ b/includes/upload/UploadFromUrl.php @@ -390,7 +390,7 @@ class UploadFromUrl extends UploadBase { 'userName' => $user->getName(), 'leaveMessage' => $this->mAsync == 'async-leavemessage', 'ignoreWarnings' => $this->mIgnoreWarnings, - 'sessionId' => session_id(), + 'sessionId' => MediaWiki\Session\SessionManager::getGlobalSession()->getId(), 'sessionKey' => $sessionKey, ) ); $job->initializeSessionData(); diff --git a/includes/user/User.php b/includes/user/User.php index b406bac680..fd0d612e6d 100644 --- a/includes/user/User.php +++ b/includes/user/User.php @@ -20,6 +20,8 @@ * @file */ +use MediaWiki\Session\SessionManager; + /** * String Some punctuation to prevent editing from broken text-mangling proxies. * @ingroup Constants @@ -99,6 +101,7 @@ class User implements IDBAccessObject { 'apihighlimits', 'applychangetags', 'autoconfirmed', + 'autocreateaccount', 'autopatrol', 'bigdelete', 'block', @@ -227,7 +230,7 @@ class User implements IDBAccessObject { * - 'defaults' anonymous user initialised from class defaults * - 'name' initialise from mName * - 'id' initialise from mId - * - 'session' log in from cookies or session if possible + * - 'session' log in from session if possible * * Use the User::newFrom*() family of functions to set this. */ @@ -311,14 +314,26 @@ class User implements IDBAccessObject { * @param integer $flags User::READ_* constant bitfield */ public function load( $flags = self::READ_NORMAL ) { + global $wgFullyInitialised; + if ( $this->mLoadedItems === true ) { return; } // Set it now to avoid infinite recursion in accessors + $oldLoadedItems = $this->mLoadedItems; $this->mLoadedItems = true; $this->queryFlagsUsed = $flags; + // If this is called too early, things are likely to break. + if ( $this->mFrom === 'session' && empty( $wgFullyInitialised ) ) { + \MediaWiki\Logger\LoggerFactory::getInstance( 'session' ) + ->warning( 'User::loadFromSession called before the end of Setup.php' ); + $this->loadDefaults(); + $this->mLoadedItems = $oldLoadedItems; + return; + } + switch ( $this->mFrom ) { case 'defaults': $this->loadDefaults(); @@ -541,8 +556,8 @@ class User implements IDBAccessObject { } /** - * Create a new user object using data from session or cookies. If the - * login credentials are invalid, the result is an anonymous user. + * Create a new user object using data from session. If the login + * credentials are invalid, the result is an anonymous user. * * @param WebRequest|null $request Object to use; $wgRequest will be used if omitted. * @return User @@ -662,6 +677,8 @@ class User implements IDBAccessObject { $user->saveSettings(); } + SessionManager::singleton()->preventSessionsForUser( $user->getName() ); + return $user; } @@ -1069,8 +1086,9 @@ class User implements IDBAccessObject { $this->mOptionOverrides = null; $this->mOptionsLoaded = false; - $loggedOut = $this->getRequest()->getCookie( 'LoggedOut' ); - if ( $loggedOut !== null ) { + $request = $this->getRequest(); + $loggedOut = $request ? $request->getSession()->getLoggedOutTimestamp() : 0; + if ( $loggedOut !== 0 ) { $this->mTouched = wfTimestamp( TS_MW, $loggedOut ); } else { $this->mTouched = '1'; # Allow any pages to be cached @@ -1115,84 +1133,32 @@ class User implements IDBAccessObject { } /** - * Load user data from the session or login cookie. + * Load user data from the session. * * @return bool True if the user is logged in, false otherwise. */ private function loadFromSession() { + // Deprecated hook $result = null; - Hooks::run( 'UserLoadFromSession', array( $this, &$result ) ); + Hooks::run( 'UserLoadFromSession', array( $this, &$result ), '1.27' ); if ( $result !== null ) { return $result; } - $request = $this->getRequest(); - - $cookieId = $request->getCookie( 'UserID' ); - $sessId = $request->getSessionData( 'wsUserID' ); - - if ( $cookieId !== null ) { - $sId = intval( $cookieId ); - if ( $sessId !== null && $cookieId != $sessId ) { - wfDebugLog( 'loginSessions', "Session user ID ($sessId) and - cookie user ID ($sId) don't match!" ); - return false; - } - $request->setSessionData( 'wsUserID', $sId ); - } elseif ( $sessId !== null && $sessId != 0 ) { - $sId = $sessId; - } else { - return false; - } - - if ( $request->getSessionData( 'wsUserName' ) !== null ) { - $sName = $request->getSessionData( 'wsUserName' ); - } elseif ( $request->getCookie( 'UserName' ) !== null ) { - $sName = $request->getCookie( 'UserName' ); - $request->setSessionData( 'wsUserName', $sName ); - } else { - return false; - } - - $proposedUser = User::newFromId( $sId ); - if ( !$proposedUser->isLoggedIn() ) { - // Not a valid ID - return false; - } - - global $wgBlockDisablesLogin; - if ( $wgBlockDisablesLogin && $proposedUser->isBlocked() ) { - // User blocked and we've disabled blocked user logins - return false; - } - - if ( $request->getSessionData( 'wsToken' ) ) { - $passwordCorrect = - ( $proposedUser->getToken( false ) === $request->getSessionData( 'wsToken' ) ); - $from = 'session'; - } elseif ( $request->getCookie( 'Token' ) ) { - # Get the token from DB/cache and clean it up to remove garbage padding. - # This deals with historical problems with bugs and the default column value. - $token = rtrim( $proposedUser->getToken( false ) ); // correct token - // Make comparison in constant time (bug 61346) - $passwordCorrect = strlen( $token ) - && hash_equals( $token, $request->getCookie( 'Token' ) ); - $from = 'cookie'; - } else { - // No session or persistent login cookie - return false; - } - - if ( ( $sName === $proposedUser->getName() ) && $passwordCorrect ) { - $this->loadFromUserObject( $proposedUser ); - $request->setSessionData( 'wsToken', $this->mToken ); - wfDebug( "User: logged in from $from\n" ); + // MediaWiki\Session\Session already did the necessary authentication of the user + // returned here, so just use it if applicable. + $session = $this->getRequest()->getSession(); + $user = $session->getUser(); + if ( $user->isLoggedIn() ) { + $this->loadFromUserObject( $user ); + // Other code expects these to be set in the session, so set them. + $session->set( 'wsUserID', $this->getId() ); + $session->set( 'wsUserName', $this->getName() ); + $session->set( 'wsToken', $this->mToken ); return true; - } else { - // Invalid credentials - wfDebug( "User: can't log in from $from, invalid credentials\n" ); - return false; } + + return false; } /** @@ -3502,6 +3468,7 @@ class User implements IDBAccessObject { /** * Set a cookie on the user's client. Wrapper for * WebResponse::setCookie + * @deprecated since 1.27 * @param string $name Name of the cookie to set * @param string $value Value to set * @param int $exp Expiration time, as a UNIX time value; @@ -3517,6 +3484,7 @@ class User implements IDBAccessObject { protected function setCookie( $name, $value, $exp = 0, $secure = null, $params = array(), $request = null ) { + wfDeprecated( __METHOD__, '1.27' ); if ( $request === null ) { $request = $this->getRequest(); } @@ -3526,6 +3494,7 @@ class User implements IDBAccessObject { /** * Clear a cookie on the user's client + * @deprecated since 1.27 * @param string $name Name of the cookie to clear * @param bool $secure * true: Force setting the secure attribute when setting the cookie @@ -3534,6 +3503,7 @@ class User implements IDBAccessObject { * @param array $params Array of options sent passed to WebResponse::setcookie() */ protected function clearCookie( $name, $secure = null, $params = array() ) { + wfDeprecated( __METHOD__, '1.27' ); $this->setCookie( $name, '', time() - 86400, $secure, $params ); } @@ -3544,6 +3514,7 @@ class User implements IDBAccessObject { * * @see User::setCookie * + * @deprecated since 1.27 * @param string $name Name of the cookie to set * @param string $value Value to set * @param bool $secure @@ -3554,6 +3525,8 @@ class User implements IDBAccessObject { protected function setExtendedLoginCookie( $name, $value, $secure ) { global $wgExtendedLoginCookieExpiration, $wgCookieExpiration; + wfDeprecated( __METHOD__, '1.27' ); + $exp = time(); $exp += $wgExtendedLoginCookieExpiration !== null ? $wgExtendedLoginCookieExpiration @@ -3563,7 +3536,7 @@ class User implements IDBAccessObject { } /** - * Set the default cookies for this session on the user's client. + * Persist this user's session (e.g. set cookies) * * @param WebRequest|null $request WebRequest object to use; $wgRequest will be used if null * is passed. @@ -3571,72 +3544,36 @@ class User implements IDBAccessObject { * @param bool $rememberMe Whether to add a Token cookie for elongated sessions */ public function setCookies( $request = null, $secure = null, $rememberMe = false ) { - global $wgExtendedLoginCookies; - - if ( $request === null ) { - $request = $this->getRequest(); - } - $this->load(); if ( 0 == $this->mId ) { return; } - if ( !$this->mToken ) { - // When token is empty or NULL generate a new one and then save it to the database - // This allows a wiki to re-secure itself after a leak of it's user table or $wgSecretKey - // Simply by setting every cell in the user_token column to NULL and letting them be - // regenerated as users log back into the wiki. - $this->setToken(); - if ( !wfReadOnly() ) { - $this->saveSettings(); - } - } - $session = array( - 'wsUserID' => $this->mId, - 'wsToken' => $this->mToken, - 'wsUserName' => $this->getName() - ); - $cookies = array( - 'UserID' => $this->mId, - 'UserName' => $this->getName(), - ); - if ( $rememberMe ) { - $cookies['Token'] = $this->mToken; - } else { - $cookies['Token'] = false; - } - Hooks::run( 'UserSetCookies', array( $this, &$session, &$cookies ) ); - - foreach ( $session as $name => $value ) { - $request->setSessionData( $name, $value ); + $session = $this->getRequest()->getSession(); + if ( $request && $session->getRequest() !== $request ) { + $session = $session->sessionWithRequest( $request ); } - foreach ( $cookies as $name => $value ) { - if ( $value === false ) { - $this->clearCookie( $name ); - } elseif ( $rememberMe && in_array( $name, $wgExtendedLoginCookies ) ) { - $this->setExtendedLoginCookie( $name, $value, $secure ); - } else { - $this->setCookie( $name, $value, 0, $secure, array(), $request ); + $delay = $session->delaySave(); + + if ( !$session->getUser()->equals( $this ) ) { + if ( !$session->canSetUser() ) { + \MediaWiki\Logger\LoggerFactory::getInstance( 'session' ) + ->warning( __METHOD__ . + ": Cannot save user \"$this\" to a user \"{$session->getUser()}\"'s immutable session" + ); + return; } + $session->setUser( $this ); } - /** - * If wpStickHTTPS was selected, also set an insecure cookie that - * will cause the site to redirect the user to HTTPS, if they access - * it over HTTP. Bug 29898. Use an un-prefixed cookie, so it's the same - * as the one set by centralauth (bug 53538). Also set it to session, or - * standard time setting, based on if rememberme was set. - */ - if ( $request->getCheck( 'wpStickHTTPS' ) || $this->requiresHTTPS() ) { - $this->setCookie( - 'forceHTTPS', - 'true', - $rememberMe ? 0 : null, - false, - array( 'prefix' => '' ) // no prefix - ); + $session->setRememberUser( $rememberMe ); + if ( $secure !== null ) { + $session->setForceHTTPS( $secure ); } + + $session->persist(); + + ScopedCallback::consume( $delay ); } /** @@ -3649,20 +3586,29 @@ class User implements IDBAccessObject { } /** - * Clear the user's cookies and session, and reset the instance cache. + * Clear the user's session, and reset the instance cache. * @see logout() */ public function doLogout() { - $this->clearInstanceCache( 'defaults' ); - - $this->getRequest()->setSessionData( 'wsUserID', 0 ); - - $this->clearCookie( 'UserID' ); - $this->clearCookie( 'Token' ); - $this->clearCookie( 'forceHTTPS', false, array( 'prefix' => '' ) ); - - // Remember when user logged out, to prevent seeing cached pages - $this->setCookie( 'LoggedOut', time(), time() + 86400 ); + $session = $this->getRequest()->getSession(); + if ( !$session->canSetUser() ) { + \MediaWiki\Logger\LoggerFactory::getInstance( 'session' ) + ->warning( __METHOD__ . ": Cannot log out of an immutable session" ); + } elseif ( !$session->getUser()->equals( $this ) ) { + \MediaWiki\Logger\LoggerFactory::getInstance( 'session' ) + ->warning( __METHOD__ . + ": Cannot log user \"$this\" out of a user \"{$session->getUser()}\"'s session" + ); + // But we still may as well make this user object anon + $this->clearInstanceCache( 'defaults' ); + } else { + $this->clearInstanceCache( 'defaults' ); + $delay = $session->delaySave(); + $session->setLoggedOutTimestamp( time() ); + $session->setUser( new User ); + $session->set( 'wsUserID', 0 ); // Other code expects this + ScopedCallback::consume( $delay ); + } } /** diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 88c5d07f87..2279b9affe 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -389,6 +389,8 @@ "virus-scanfailed": "scan failed (code $1)", "virus-unknownscanner": "unknown antivirus:", "logouttext": "You are now logged out.\n\nNote that some pages may continue to be displayed as if you were still logged in, until you clear your browser cache.", + "cannotlogoutnow-title": "Cannot log out now", + "cannotlogoutnow-text": "Logging out is not possible when using $1.", "welcomeuser": "Welcome, $1!", "welcomecreation-msg": "Your account has been created.\nYou can change your {{SITENAME}} [[Special:Preferences|preferences]] if you wish.", "yourname": "Username:", @@ -406,6 +408,8 @@ "remembermypassword": "Remember my login on this browser (for a maximum of $1 {{PLURAL:$1|day|days}})", "userlogin-remembermypassword": "Keep me logged in", "userlogin-signwithsecure": "Use secure connection", + "cannotloginnow-title": "Cannot log in now", + "cannotloginnow-text": "Logging in is not possible when using $1.", "yourdomainname": "Your domain:", "password-change-forbidden": "You cannot change passwords on this wiki.", "externaldberror": "There was either an authentication database error or you are not allowed to update your external account.", @@ -1112,6 +1116,7 @@ "right-createpage": "Create pages (which are not discussion pages)", "right-createtalk": "Create discussion pages", "right-createaccount": "Create new user accounts", + "right-autocreateaccount": "Automatically log in with an external user account", "right-minoredit": "Mark edits as minor", "right-move": "Move pages", "right-move-subpages": "Move pages with their subpages", @@ -1190,6 +1195,7 @@ "action-createpage": "create pages", "action-createtalk": "create discussion pages", "action-createaccount": "create this user account", + "action-autocreateaccount": "automatically create this external user account", "action-history": "view the history of this page", "action-minoredit": "mark this edit as minor", "action-move": "move this page", @@ -3924,5 +3930,9 @@ "mw-widgets-dateinput-placeholder-month": "YYYY-MM", "mw-widgets-titleinput-description-new-page": "page does not exist yet", "mw-widgets-titleinput-description-redirect": "redirect to $1", - "api-error-blacklisted": "Please choose a different, descriptive title." + "api-error-blacklisted": "Please choose a different, descriptive title.", + "sessionmanager-tie": "Cannot combine multiple request authentication types: $1.", + "sessionprovider-generic": "$1 sessions", + "sessionprovider-mediawiki-session-cookiesessionprovider": "cookie-based sessions", + "sessionprovider-nocookies": "Cookies may be disabled. Ensure you have cookies enabled and start again." } diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 092d14e0b5..11b11db117 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -564,6 +564,8 @@ "virus-scanfailed": "Used as error message. \"scan\" stands for \"virus scan\". Parameters:\n* $1 - exit code of virus scanner", "virus-unknownscanner": "Used as error message. This message is followed by the virus scanner name.", "logouttext": "Log out message. Parameters:\n* $1 - (Unused) an URL to [[Special:Userlogin]] containing returnto and returntoquery parameters", + "cannotlogoutnow-title": "Error page title shown when logging out is not possible.", + "cannotlogoutnow-text": "Error page text shown when logging out is not possible. Parameters:\n* $1 - Session type in use that makes it not possible to log out, from a message like {{msg-mw|sessionprovider-mediawiki-session-cookiesessionprovider}}.", "welcomeuser": "Text for a welcome heading that users see after registering a user account.\n\nParameters:\n* $1 - the username of the new user. See [[phab:T44215]]", "welcomecreation-msg": "A welcome message users see after registering a user account, following a welcomeuser heading.\n\nParameters:\n* $1 - (Unused) the username of the new user.\n\nReplaces [[MediaWiki:welcomecreation|welcomecreation]] in 1.21wmf5, see [[phab:T44215]]", "yourname": "Since 1.22 no longer used in core, but used by some extensions.\n{{Identical|Username}}", @@ -581,6 +583,8 @@ "remembermypassword": "Used as checkbox label on [[Special:ChangePassword]]. Parameters:\n* $1 - number of days\n{{Identical|Remember my login on this computer}}", "userlogin-remembermypassword": "The text for a check box in [[Special:UserLogin]].", "userlogin-signwithsecure": "Text of link to HTTPS login form.\n\nSee example: [[Special:UserLogin]]", + "cannotloginnow-title": "Error page title shown when logging in is not possible.", + "cannotloginnow-text": "Error page text shown when logging in is not possible. Parameters:\n* $1 - Session type in use that makes it not possible to log in, from a message like {{msg-mw|sessionprovider-mediawiki-session-cookiesessionprovider}}.", "yourdomainname": "Used as label for listbox.", "password-change-forbidden": "Error message shown when an external authentication source does not allow the password to be changed.", "externaldberror": "This message is thrown when a valid attempt to change the wiki password for a user fails because of a database error or an error from an external system.", @@ -1287,6 +1291,7 @@ "right-createpage": "{{doc-right|createpage}}\nBasic right to create pages. The right to edit discussion/talk pages is {{msg-mw|right-createtalk}}.", "right-createtalk": "{{doc-right|createtalk}}\nBasic right to create discussion/talk pages. The right to edit other pages is {{msg-mw|right-createpage}}.", "right-createaccount": "{{doc-right|createaccount}}\nThe right to [[Special:CreateAccount|create a user account]].", + "right-autocreateaccount": "{{doc-right|autocreateaccount}}\nThe right to automatically create an account from an external source (e.g. CentralAuth).", "right-minoredit": "{{doc-right|minoredit}}\nThe right to use the \"This is a minor edit\" checkbox. See {{msg-mw|minoredit}} for the message used for that checkbox.", "right-move": "{{doc-right|move}}\nThe right to move any page that is not protected from moving.\n{{Identical|Move page}}", "right-move-subpages": "{{doc-right|move-subpages}}", @@ -1365,6 +1370,7 @@ "action-createpage": "{{Doc-action|createpage}}\n{{Identical|Create page}}", "action-createtalk": "{{Doc-action|createtalk}}", "action-createaccount": "{{Doc-action|createaccount}}", + "action-autocreateaccount": "{{Doc-action|autocreateaccount}}", "action-history": "{{Doc-action|history}}", "action-minoredit": "{{Doc-action|minoredit}}", "action-move": "{{Doc-action|move}}", @@ -4099,5 +4105,9 @@ "mw-widgets-dateinput-placeholder-month": "Placeholder displayed in a date input field when it's empty, representing a date format with 4 digits for year and 2 digits for month, separated with hyphens (without a day). This should be uppercase, if possible, and must not include any additional explanations. If there is no good way to translate it, make this message blank.", "mw-widgets-titleinput-description-new-page": "Description label for a new page in the title input widget.", "mw-widgets-titleinput-description-redirect": "Description label for a redirect in the title input widget.", - "api-error-blacklisted": "Used as error message.\n\nFollowed by the link {{msg-mw|Mwe-upwiz-feedback-blacklist-info-prompt}}." + "api-error-blacklisted": "Used as error message.\n\nFollowed by the link {{msg-mw|Mwe-upwiz-feedback-blacklist-info-prompt}}.", + "sessionmanager-tie": "Used as an error message when multiple session sources are tied in priority.\n\nParameters:\n* $1 - List of dession type descriptions, from messages like {{msg-mw|sessionprovider-mediawiki-session-cookiesessionprovider}}.", + "sessionprovider-generic": "Used to create a generic session type description when one isn't provided via the proper message. Should be phrased to make sense when added to a message such as {{msg-mw|cannotloginnow-text}}.\n\nParameters:\n* $1 - PHP classname.", + "sessionprovider-mediawiki-session-cookiesessionprovider": "Description of the sessions provided by the CookieSessionProvider class, which use HTTP cookies. Should be phrased to make sense when added to a message such as {{msg-mw|cannotloginnow-text}}.", + "sessionprovider-nocookies": "Used to inform the user that sessions may be missing due to lack of cookies." } diff --git a/tests/TestsAutoLoader.php b/tests/TestsAutoLoader.php index 1c4851544d..f4ddfb25ed 100644 --- a/tests/TestsAutoLoader.php +++ b/tests/TestsAutoLoader.php @@ -49,6 +49,7 @@ $wgAutoloadClasses += array( # tests/phpunit/includes 'TestingAccessWrapper' => "$testDir/phpunit/includes/TestingAccessWrapper.php", + 'TestLogger' => "$testDir/phpunit/includes/TestLogger.php", # tests/phpunit/includes/api 'ApiFormatTestBase' => "$testDir/phpunit/includes/api/format/ApiFormatTestBase.php", @@ -94,6 +95,10 @@ $wgAutoloadClasses += array( 'ResourceLoaderImageModuleTestable' => "$testDir/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php", + # tests/phpunit/includes/session + 'MediaWiki\\Session\\TestBagOStuff' => "$testDir/phpunit/includes/session/TestBagOStuff.php", + 'MediaWiki\\Session\\TestUtils' => "$testDir/phpunit/includes/session/TestUtils.php", + # tests/phpunit/includes/specials 'SpecialPageTestBase' => "$testDir/phpunit/includes/specials/SpecialPageTestBase.php", @@ -118,6 +123,9 @@ $wgAutoloadClasses += array( 'MockSvgHandler' => "$testDir/phpunit/mocks/media/MockSvgHandler.php", 'MockDjVuHandler' => "$testDir/phpunit/mocks/media/MockDjVuHandler.php", 'MockWebRequest' => "$testDir/phpunit/mocks/MockWebRequest.php", + 'MediaWiki\\Session\\DummySessionBackend' + => "$testDir/phpunit/mocks/session/DummySessionBackend.php", + 'DummySessionProvider' => "$testDir/phpunit/mocks/session/DummySessionProvider.php", # tests/parser 'NewParserTest' => "$testDir/phpunit/includes/parser/NewParserTest.php", diff --git a/tests/phpunit/MediaWikiTestCase.php b/tests/phpunit/MediaWikiTestCase.php index fc2f743e12..861e3bd864 100644 --- a/tests/phpunit/MediaWikiTestCase.php +++ b/tests/phpunit/MediaWikiTestCase.php @@ -221,6 +221,8 @@ abstract class MediaWikiTestCase extends PHPUnit_Framework_TestCase { } protected function tearDown() { + global $wgRequest; + $status = ob_get_status(); if ( isset( $status['name'] ) && $status['name'] === 'MediaWikiTestCase::wfResetOutputBuffersBarrier' @@ -252,6 +254,12 @@ abstract class MediaWikiTestCase extends PHPUnit_Framework_TestCase { $this->mwGlobals = array(); RequestContext::resetMain(); MediaHandler::resetCache(); + if ( session_id() !== '' ) { + session_write_close(); + session_id( '' ); + } + $wgRequest = new FauxRequest(); + MediaWiki\Session\SessionManager::resetCache(); $phpErrorLevel = intval( ini_get( 'error_reporting' ) ); @@ -509,6 +517,13 @@ abstract class MediaWikiTestCase extends PHPUnit_Framework_TestCase { false, $user ); + + // doEditContent() probably started the session via + // User::loadFromSession(). Close it now. + if ( session_id() !== '' ) { + session_write_close(); + session_id( '' ); + } } } diff --git a/tests/phpunit/includes/TestLogger.php b/tests/phpunit/includes/TestLogger.php new file mode 100644 index 0000000000..7099c3acea --- /dev/null +++ b/tests/phpunit/includes/TestLogger.php @@ -0,0 +1,105 @@ + + * + * 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 + * @author Brad Jorsch + */ + +use Psr\Log\LogLevel; + +/** + * A logger that may be configured to either buffer logs or to print them to + * the output where PHPUnit will complain about them. + * + * @since 1.27 + */ +class TestLogger extends \Psr\Log\AbstractLogger { + private $collect = false; + private $buffer = array(); + private $filter = null; + + /** + * @param bool $collect Whether to collect logs + * @param callable $filter Filter logs before collecting/printing. Signature is + * string|null function ( string $message, string $level ); + */ + public function __construct( $collect = false, $filter = null ) { + $this->collect = $collect; + $this->filter = $filter; + } + + /** + * Set the "collect" flag + * @param bool $collect + */ + public function setCollect( $collect ) { + $this->collect = $collect; + } + + /** + * Return the collected logs + * @return array Array of array( string $level, string $message ) + */ + public function getBuffer() { + return $this->buffer; + } + + /** + * Clear the collected log buffer + */ + public function clearBuffer() { + $this->buffer = array(); + } + + public function log( $level, $message, array $context = array() ) { + $message = trim( $message ); + + if ( $this->filter ) { + $message = call_user_func( $this->filter, $message, $level ); + if ( $message === null ) { + return; + } + } + + if ( $this->collect ) { + $this->buffer[] = array( $level, $message ); + } else { + switch ( $level ) { + case LogLevel::DEBUG: + case LogLevel::INFO: + case LogLevel::NOTICE: + trigger_error( "LOG[$level]: $message", E_USER_NOTICE ); + break; + + case LogLevel::WARNING: + trigger_error( "LOG[$level]: $message", E_USER_WARNING ); + break; + + case LogLevel::ERROR: + case LogLevel::CRITICAL: + case LogLevel::ALERT: + case LogLevel::EMERGENCY: + trigger_error( "LOG[$level]: $message", E_USER_ERROR ); + break; + } + } + } +} diff --git a/tests/phpunit/includes/api/ApiMainTest.php b/tests/phpunit/includes/api/ApiMainTest.php index aef48158a0..f02f7dfdad 100644 --- a/tests/phpunit/includes/api/ApiMainTest.php +++ b/tests/phpunit/includes/api/ApiMainTest.php @@ -97,7 +97,8 @@ class ApiMainTest extends ApiTestCase { $request->setHeaders( $headers ); $request->response()->statusHeader( 200 ); // Why doesn't it default? - $api = new ApiMain( $request ); + $context = $this->apiContext->newTestContext( $request, null ); + $api = new ApiMain( $context ); $priv = TestingAccessWrapper::newFromObject( $api ); $priv->mInternalMode = false; diff --git a/tests/phpunit/includes/api/ApiTestCase.php b/tests/phpunit/includes/api/ApiTestCase.php index 01113a6412..25ffcb7a92 100644 --- a/tests/phpunit/includes/api/ApiTestCase.php +++ b/tests/phpunit/includes/api/ApiTestCase.php @@ -47,11 +47,7 @@ abstract class ApiTestCase extends MediaWikiLangTestCase { protected function tearDown() { // Avoid leaking session over tests - if ( session_id() != '' ) { - global $wgUser; - $wgUser->logout(); - session_destroy(); - } + MediaWiki\Session\SessionManager::getGlobalSession()->clear(); parent::tearDown(); } diff --git a/tests/phpunit/includes/api/ApiTestCaseUpload.php b/tests/phpunit/includes/api/ApiTestCaseUpload.php index 87f794c1bf..b6ae641566 100644 --- a/tests/phpunit/includes/api/ApiTestCaseUpload.php +++ b/tests/phpunit/includes/api/ApiTestCaseUpload.php @@ -15,8 +15,6 @@ abstract class ApiTestCaseUpload extends ApiTestCase { 'wgEnableAPI' => true, ) ); - wfSetupSession(); - $this->clearFakeUploads(); } diff --git a/tests/phpunit/includes/context/RequestContextTest.php b/tests/phpunit/includes/context/RequestContextTest.php index a9e5be2493..25969e6bb8 100644 --- a/tests/phpunit/includes/context/RequestContextTest.php +++ b/tests/phpunit/includes/context/RequestContextTest.php @@ -37,6 +37,14 @@ class RequestContextTest extends MediaWikiTestCase { * @covers RequestContext::importScopedSession */ public function testImportScopedSession() { + // Make sure session handling is started + if ( !MediaWiki\Session\PHPSessionHandler::isInstalled() ) { + MediaWiki\Session\PHPSessionHandler::install( + MediaWiki\Session\SessionManager::singleton() + ); + } + $oldSessionId = session_id(); + $context = RequestContext::getMain(); $oInfo = $context->exportSession(); @@ -76,7 +84,16 @@ class RequestContextTest extends MediaWikiTestCase { $context->getRequest()->getAllHeaders(), "Correct context headers." ); - $this->assertEquals( $sinfo['sessionId'], session_id(), "Correct context session ID." ); + $this->assertEquals( + $sinfo['sessionId'], + MediaWiki\Session\SessionManager::getGlobalSession()->getId(), + "Correct context session ID." + ); + if ( \MediaWiki\Session\PhpSessionHandler::isEnabled() ) { + $this->assertEquals( $sinfo['sessionId'], session_id(), "Correct context session ID." ); + } else { + $this->assertEquals( $oldSessionId, session_id(), "Unchanged PHP session ID." ); + } $this->assertEquals( true, $context->getUser()->isLoggedIn(), "Correct context user." ); $this->assertEquals( $sinfo['userId'], $context->getUser()->getId(), "Correct context user ID." ); $this->assertEquals( diff --git a/tests/phpunit/includes/session/CookieSessionProviderTest.php b/tests/phpunit/includes/session/CookieSessionProviderTest.php new file mode 100644 index 0000000000..a73bf7c098 --- /dev/null +++ b/tests/phpunit/includes/session/CookieSessionProviderTest.php @@ -0,0 +1,726 @@ + 'CookiePrefix', + 'CookiePath' => 'CookiePath', + 'CookieDomain' => 'CookieDomain', + 'CookieSecure' => true, + 'CookieHttpOnly' => true, + 'SessionName' => false, + 'ExtendedLoginCookies' => array( 'UserID', 'Token' ), + 'ExtendedLoginCookieExpiration' => $wgCookieExpiration * 2, + ) ); + } + + public function testConstructor() { + try { + new CookieSessionProvider(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'MediaWiki\\Session\\CookieSessionProvider::__construct: priority must be specified', + $ex->getMessage() + ); + } + + try { + new CookieSessionProvider( array( 'priority' => 'foo' ) ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority', + $ex->getMessage() + ); + } + try { + new CookieSessionProvider( array( 'priority' => SessionInfo::MIN_PRIORITY - 1 ) ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority', + $ex->getMessage() + ); + } + try { + new CookieSessionProvider( array( 'priority' => SessionInfo::MAX_PRIORITY + 1 ) ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority', + $ex->getMessage() + ); + } + + try { + new CookieSessionProvider( array( 'priority' => 1, 'cookieOptions' => null ) ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'MediaWiki\\Session\\CookieSessionProvider::__construct: cookieOptions must be an array', + $ex->getMessage() + ); + } + + $config = $this->getConfig(); + $p = \TestingAccessWrapper::newFromObject( + new CookieSessionProvider( array( 'priority' => 1 ) ) + ); + $p->setLogger( new \TestLogger() ); + $p->setConfig( $config ); + $this->assertEquals( 1, $p->priority ); + $this->assertEquals( array( + 'callUserSetCookiesHook' => false, + 'sessionName' => 'CookiePrefix_session', + ), $p->params ); + $this->assertEquals( array( + 'prefix' => 'CookiePrefix', + 'path' => 'CookiePath', + 'domain' => 'CookieDomain', + 'secure' => true, + 'httpOnly' => true, + ), $p->cookieOptions ); + + $config->set( 'SessionName', 'SessionName' ); + $p = \TestingAccessWrapper::newFromObject( + new CookieSessionProvider( array( 'priority' => 3 ) ) + ); + $p->setLogger( new \TestLogger() ); + $p->setConfig( $config ); + $this->assertEquals( 3, $p->priority ); + $this->assertEquals( array( + 'callUserSetCookiesHook' => false, + 'sessionName' => 'SessionName', + ), $p->params ); + $this->assertEquals( array( + 'prefix' => 'CookiePrefix', + 'path' => 'CookiePath', + 'domain' => 'CookieDomain', + 'secure' => true, + 'httpOnly' => true, + ), $p->cookieOptions ); + + $p = \TestingAccessWrapper::newFromObject( new CookieSessionProvider( array( + 'priority' => 10, + 'callUserSetCookiesHook' => true, + 'cookieOptions' => array( + 'prefix' => 'XPrefix', + 'path' => 'XPath', + 'domain' => 'XDomain', + 'secure' => 'XSecure', + 'httpOnly' => 'XHttpOnly', + ), + 'sessionName' => 'XSession', + ) ) ); + $p->setLogger( new \TestLogger() ); + $p->setConfig( $config ); + $this->assertEquals( 10, $p->priority ); + $this->assertEquals( array( + 'callUserSetCookiesHook' => true, + 'sessionName' => 'XSession', + ), $p->params ); + $this->assertEquals( array( + 'prefix' => 'XPrefix', + 'path' => 'XPath', + 'domain' => 'XDomain', + 'secure' => 'XSecure', + 'httpOnly' => 'XHttpOnly', + ), $p->cookieOptions ); + } + + public function testBasics() { + $provider = new CookieSessionProvider( array( 'priority' => 10 ) ); + + $this->assertTrue( $provider->persistsSessionID() ); + $this->assertTrue( $provider->canChangeUser() ); + + $msg = $provider->whyNoSession(); + $this->assertInstanceOf( 'Message', $msg ); + $this->assertSame( 'sessionprovider-nocookies', $msg->getKey() ); + } + + public function testProvideSessionInfo() { + $params = array( + 'priority' => 20, + 'sessionName' => 'session', + 'cookieOptions' => array( 'prefix' => 'x' ), + ); + $provider = new CookieSessionProvider( $params ); + $provider->setLogger( new \TestLogger() ); + $provider->setConfig( $this->getConfig() ); + $provider->setManager( new SessionManager() ); + + $user = User::newFromName( 'UTSysop' ); + $id = $user->getId(); + $name = $user->getName(); + $token = $user->getToken( true ); + + $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + // No data + $request = new \FauxRequest(); + $info = $provider->provideSessionInfo( $request ); + $this->assertNull( $info ); + + // Session key only + $request = new \FauxRequest(); + $request->setCookies( array( + 'session' => $sessionId, + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNotNull( $info ); + $this->assertSame( $params['priority'], $info->getPriority() ); + $this->assertSame( $sessionId, $info->getId() ); + $this->assertNull( $info->getUserInfo() ); + $this->assertFalse( $info->forceHTTPS() ); + + // User, no session key + $request = new \FauxRequest(); + $request->setCookies( array( + 'xUserID' => $id, + 'xToken' => $token, + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNotNull( $info ); + $this->assertSame( $params['priority'], $info->getPriority() ); + $this->assertNotSame( $sessionId, $info->getId() ); + $this->assertNotNull( $info->getUserInfo() ); + $this->assertSame( $id, $info->getUserInfo()->getId() ); + $this->assertSame( $name, $info->getUserInfo()->getName() ); + $this->assertFalse( $info->forceHTTPS() ); + + // User and session key + $request = new \FauxRequest(); + $request->setCookies( array( + 'session' => $sessionId, + 'xUserID' => $id, + 'xToken' => $token, + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNotNull( $info ); + $this->assertSame( $params['priority'], $info->getPriority() ); + $this->assertSame( $sessionId, $info->getId() ); + $this->assertNotNull( $info->getUserInfo() ); + $this->assertSame( $id, $info->getUserInfo()->getId() ); + $this->assertSame( $name, $info->getUserInfo()->getName() ); + $this->assertFalse( $info->forceHTTPS() ); + + // User with bad token + $request = new \FauxRequest(); + $request->setCookies( array( + 'session' => $sessionId, + 'xUserID' => $id, + 'xToken' => 'BADTOKEN', + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNull( $info ); + + // User id with no token + $request = new \FauxRequest(); + $request->setCookies( array( + 'session' => $sessionId, + 'xUserID' => $id, + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNotNull( $info ); + $this->assertSame( $params['priority'], $info->getPriority() ); + $this->assertSame( $sessionId, $info->getId() ); + $this->assertNotNull( $info->getUserInfo() ); + $this->assertFalse( $info->getUserInfo()->isVerified() ); + $this->assertSame( $id, $info->getUserInfo()->getId() ); + $this->assertSame( $name, $info->getUserInfo()->getName() ); + $this->assertFalse( $info->forceHTTPS() ); + + $request = new \FauxRequest(); + $request->setCookies( array( + 'xUserID' => $id, + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNull( $info ); + + // User and session key, with forceHTTPS flag + $request = new \FauxRequest(); + $request->setCookies( array( + 'session' => $sessionId, + 'xUserID' => $id, + 'xToken' => $token, + 'forceHTTPS' => true, + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNotNull( $info ); + $this->assertSame( $params['priority'], $info->getPriority() ); + $this->assertSame( $sessionId, $info->getId() ); + $this->assertNotNull( $info->getUserInfo() ); + $this->assertSame( $id, $info->getUserInfo()->getId() ); + $this->assertSame( $name, $info->getUserInfo()->getName() ); + $this->assertTrue( $info->forceHTTPS() ); + + // Invalid user id + $request = new \FauxRequest(); + $request->setCookies( array( + 'session' => $sessionId, + 'xUserID' => '-1', + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNull( $info ); + + // User id with matching name + $request = new \FauxRequest(); + $request->setCookies( array( + 'session' => $sessionId, + 'xUserID' => $id, + 'xUserName' => $name, + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNotNull( $info ); + $this->assertSame( $params['priority'], $info->getPriority() ); + $this->assertSame( $sessionId, $info->getId() ); + $this->assertNotNull( $info->getUserInfo() ); + $this->assertFalse( $info->getUserInfo()->isVerified() ); + $this->assertSame( $id, $info->getUserInfo()->getId() ); + $this->assertSame( $name, $info->getUserInfo()->getName() ); + $this->assertFalse( $info->forceHTTPS() ); + + // User id with wrong name + $request = new \FauxRequest(); + $request->setCookies( array( + 'session' => $sessionId, + 'xUserID' => $id, + 'xUserName' => 'Wrong', + ), '' ); + $info = $provider->provideSessionInfo( $request ); + $this->assertNull( $info ); + } + + public function testGetVaryCookies() { + $provider = new CookieSessionProvider( array( + 'priority' => 1, + 'sessionName' => 'MySessionName', + 'cookieOptions' => array( 'prefix' => 'MyCookiePrefix' ), + ) ); + $this->assertArrayEquals( array( + 'MyCookiePrefixToken', + 'MyCookiePrefixLoggedOut', + 'MySessionName', + 'forceHTTPS', + ), $provider->getVaryCookies() ); + } + + public function testSuggestLoginUsername() { + $provider = new CookieSessionProvider( array( + 'priority' => 1, + 'sessionName' => 'MySessionName', + 'cookieOptions' => array( 'prefix' => 'x' ), + ) ); + + $request = new \FauxRequest(); + $this->assertEquals( null, $provider->suggestLoginUsername( $request ) ); + + $request->setCookies( array( + 'xUserName' => 'Example', + ), '' ); + $this->assertEquals( 'Example', $provider->suggestLoginUsername( $request ) ); + } + + public function testPersistSession() { + $this->setMwGlobals( array( 'wgCookieExpiration' => 100 ) ); + + $provider = new CookieSessionProvider( array( + 'priority' => 1, + 'sessionName' => 'MySessionName', + 'callUserSetCookiesHook' => false, + 'cookieOptions' => array( 'prefix' => 'x' ), + ) ); + $config = $this->getConfig(); + $provider->setLogger( new \TestLogger() ); + $provider->setConfig( $config ); + $provider->setManager( SessionManager::singleton() ); + + $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $store = new \HashBagOStuff(); + $user = User::newFromName( 'UTSysop' ); + $anon = new User; + + $backend = new SessionBackend( + new SessionId( $sessionId ), + new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $sessionId, + 'persisted' => true, + 'idIsSafe' => true, + ) ), + $store, + new \Psr\Log\NullLogger(), + 10 + ); + \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false; + + $mock = $this->getMock( 'stdClass', array( 'onUserSetCookies' ) ); + $mock->expects( $this->never() )->method( 'onUserSetCookies' ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'UserSetCookies' => array( $mock ) ) ); + + // Anonymous user + $backend->setUser( $anon ); + $backend->setRememberUser( true ); + $backend->setForceHTTPS( false ); + $request = new \FauxRequest(); + $provider->persistSession( $backend, $request ); + $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) ); + $this->assertSame( '', $request->response()->getCookie( 'xUserID' ) ); + $this->assertSame( null, $request->response()->getCookie( 'xUserName' ) ); + $this->assertSame( '', $request->response()->getCookie( 'xToken' ) ); + $this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) ); + $this->assertSame( array(), $backend->getData() ); + + // Logged-in user, no remember + $backend->setUser( $user ); + $backend->setRememberUser( false ); + $backend->setForceHTTPS( false ); + $request = new \FauxRequest(); + $provider->persistSession( $backend, $request ); + $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) ); + $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) ); + $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) ); + $this->assertSame( '', $request->response()->getCookie( 'xToken' ) ); + $this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) ); + $this->assertSame( array(), $backend->getData() ); + + // Logged-in user, remember + $backend->setUser( $user ); + $backend->setRememberUser( true ); + $backend->setForceHTTPS( true ); + $request = new \FauxRequest(); + $time = time(); + $provider->persistSession( $backend, $request ); + $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) ); + $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) ); + $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) ); + $this->assertSame( $user->getToken(), $request->response()->getCookie( 'xToken' ) ); + $this->assertSame( 'true', $request->response()->getCookie( 'forceHTTPS' ) ); + $this->assertSame( array(), $backend->getData() ); + } + + /** + * @dataProvider provideCookieData + * @param bool $secure + * @param bool $remember + */ + public function testCookieData( $secure, $remember ) { + $this->setMwGlobals( array( + 'wgCookieExpiration' => 100, + 'wgSecureLogin' => false, + ) ); + + $provider = new CookieSessionProvider( array( + 'priority' => 1, + 'sessionName' => 'MySessionName', + 'callUserSetCookiesHook' => false, + 'cookieOptions' => array( 'prefix' => 'x' ), + ) ); + $config = $this->getConfig(); + $config->set( 'CookieSecure', false ); + $provider->setLogger( new \TestLogger() ); + $provider->setConfig( $config ); + $provider->setManager( SessionManager::singleton() ); + + $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $user = User::newFromName( 'UTSysop' ); + $this->assertFalse( $user->requiresHTTPS(), 'sanity check' ); + + $backend = new SessionBackend( + new SessionId( $sessionId ), + new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $sessionId, + 'persisted' => true, + 'idIsSafe' => true, + ) ), + new \EmptyBagOStuff(), + new \Psr\Log\NullLogger(), + 10 + ); + \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false; + $backend->setUser( $user ); + $backend->setRememberUser( $remember ); + $backend->setForceHTTPS( $secure ); + $request = new \FauxRequest(); + $time = time(); + $provider->persistSession( $backend, $request ); + + $defaults = array( + 'expire' => (int)100, + 'path' => $config->get( 'CookiePath' ), + 'domain' => $config->get( 'CookieDomain' ), + 'secure' => $secure, + 'httpOnly' => $config->get( 'CookieHttpOnly' ), + 'raw' => false, + ); + $extendedExpiry = $config->get( 'ExtendedLoginCookieExpiration' ); + $extendedExpiry = (int)( $extendedExpiry === null ? 0 : $extendedExpiry ); + $this->assertEquals( array( 'UserID', 'Token' ), $config->get( 'ExtendedLoginCookies' ), + 'sanity check' ); + $expect = array( + 'MySessionName' => array( + 'value' => (string)$sessionId, + 'expire' => 0, + ) + $defaults, + 'xUserID' => array( + 'value' => (string)$user->getId(), + 'expire' => $extendedExpiry, + ) + $defaults, + 'xUserName' => array( + 'value' => $user->getName(), + ) + $defaults, + 'xToken' => array( + 'value' => $remember ? $user->getToken() : '', + 'expire' => $remember ? $extendedExpiry : -31536000, + ) + $defaults, + 'forceHTTPS' => !$secure ? null : array( + 'value' => 'true', + 'secure' => false, + 'expire' => $remember ? $defaults['expire'] : null, + ) + $defaults, + ); + foreach ( $expect as $key => $value ) { + $actual = $request->response()->getCookieData( $key ); + if ( $actual && $actual['expire'] > 0 ) { + // Round expiry so we don't randomly fail if the seconds ticked during the test. + $actual['expire'] = round( $actual['expire'] - $time, -2 ); + } + $this->assertEquals( $value, $actual, "Cookie $key" ); + } + } + + public static function provideCookieData() { + return array( + array( false, false ), + array( false, true ), + array( true, false ), + array( true, true ), + ); + } + + protected function getSentRequest() { + $sentResponse = $this->getMock( 'FauxResponse', array( 'headersSent', 'setCookie', 'header' ) ); + $sentResponse->expects( $this->any() )->method( 'headersSent' ) + ->will( $this->returnValue( true ) ); + $sentResponse->expects( $this->never() )->method( 'setCookie' ); + $sentResponse->expects( $this->never() )->method( 'header' ); + + $sentRequest = $this->getMock( 'FauxRequest', array( 'response' ) ); + $sentRequest->expects( $this->any() )->method( 'response' ) + ->will( $this->returnValue( $sentResponse ) ); + return $sentRequest; + } + + public function testPersistSessionWithHook() { + $that = $this; + + $provider = new CookieSessionProvider( array( + 'priority' => 1, + 'sessionName' => 'MySessionName', + 'callUserSetCookiesHook' => true, + 'cookieOptions' => array( 'prefix' => 'x' ), + ) ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( $this->getConfig() ); + $provider->setManager( SessionManager::singleton() ); + + $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $store = new \HashBagOStuff(); + $user = User::newFromName( 'UTSysop' ); + $anon = new User; + + $backend = new SessionBackend( + new SessionId( $sessionId ), + new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $sessionId, + 'persisted' => true, + 'idIsSafe' => true, + ) ), + $store, + new \Psr\Log\NullLogger(), + 10 + ); + \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false; + + // Anonymous user + $mock = $this->getMock( 'stdClass', array( 'onUserSetCookies' ) ); + $mock->expects( $this->never() )->method( 'onUserSetCookies' ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'UserSetCookies' => array( $mock ) ) ); + $backend->setUser( $anon ); + $backend->setRememberUser( true ); + $backend->setForceHTTPS( false ); + $request = new \FauxRequest(); + $provider->persistSession( $backend, $request ); + $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) ); + $this->assertSame( '', $request->response()->getCookie( 'xUserID' ) ); + $this->assertSame( null, $request->response()->getCookie( 'xUserName' ) ); + $this->assertSame( '', $request->response()->getCookie( 'xToken' ) ); + $this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) ); + $this->assertSame( array(), $backend->getData() ); + + $provider->persistSession( $backend, $this->getSentRequest() ); + + // Logged-in user, no remember + $mock = $this->getMock( __CLASS__, array( 'onUserSetCookies' ) ); + $mock->expects( $this->once() )->method( 'onUserSetCookies' ) + ->will( $this->returnCallback( function ( $u, &$sessionData, &$cookies ) use ( $that, $user ) { + $that->assertSame( $user, $u ); + $that->assertEquals( array( + 'wsUserID' => $user->getId(), + 'wsUserName' => $user->getName(), + 'wsToken' => $user->getToken(), + ), $sessionData ); + $that->assertEquals( array( + 'UserID' => $user->getId(), + 'UserName' => $user->getName(), + 'Token' => false, + ), $cookies ); + + $sessionData['foo'] = 'foo!'; + $cookies['bar'] = 'bar!'; + return true; + } ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'UserSetCookies' => array( $mock ) ) ); + $backend->setUser( $user ); + $backend->setRememberUser( false ); + $backend->setForceHTTPS( false ); + $backend->setLoggedOutTimestamp( $loggedOut = time() ); + $request = new \FauxRequest(); + $provider->persistSession( $backend, $request ); + $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) ); + $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) ); + $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) ); + $this->assertSame( '', $request->response()->getCookie( 'xToken' ) ); + $this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) ); + $this->assertSame( 'bar!', $request->response()->getCookie( 'xbar' ) ); + $this->assertSame( (string)$loggedOut, $request->response()->getCookie( 'xLoggedOut' ) ); + $this->assertEquals( array( + 'wsUserID' => $user->getId(), + 'wsUserName' => $user->getName(), + 'wsToken' => $user->getToken(), + 'foo' => 'foo!', + ), $backend->getData() ); + + $provider->persistSession( $backend, $this->getSentRequest() ); + + // Logged-in user, remember + $mock = $this->getMock( __CLASS__, array( 'onUserSetCookies' ) ); + $mock->expects( $this->once() )->method( 'onUserSetCookies' ) + ->will( $this->returnCallback( function ( $u, &$sessionData, &$cookies ) use ( $that, $user ) { + $that->assertSame( $user, $u ); + $that->assertEquals( array( + 'wsUserID' => $user->getId(), + 'wsUserName' => $user->getName(), + 'wsToken' => $user->getToken(), + ), $sessionData ); + $that->assertEquals( array( + 'UserID' => $user->getId(), + 'UserName' => $user->getName(), + 'Token' => $user->getToken(), + ), $cookies ); + + $sessionData['foo'] = 'foo 2!'; + $cookies['bar'] = 'bar 2!'; + return true; + } ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'UserSetCookies' => array( $mock ) ) ); + $backend->setUser( $user ); + $backend->setRememberUser( true ); + $backend->setForceHTTPS( true ); + $backend->setLoggedOutTimestamp( 0 ); + $request = new \FauxRequest(); + $provider->persistSession( $backend, $request ); + $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) ); + $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) ); + $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) ); + $this->assertSame( $user->getToken(), $request->response()->getCookie( 'xToken' ) ); + $this->assertSame( 'true', $request->response()->getCookie( 'forceHTTPS' ) ); + $this->assertSame( 'bar 2!', $request->response()->getCookie( 'xbar' ) ); + $this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) ); + $this->assertEquals( array( + 'wsUserID' => $user->getId(), + 'wsUserName' => $user->getName(), + 'wsToken' => $user->getToken(), + 'foo' => 'foo 2!', + ), $backend->getData() ); + + $provider->persistSession( $backend, $this->getSentRequest() ); + } + + public function testUnpersistSession() { + $provider = new CookieSessionProvider( array( + 'priority' => 1, + 'sessionName' => 'MySessionName', + 'cookieOptions' => array( 'prefix' => 'x' ), + ) ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( $this->getConfig() ); + $provider->setManager( SessionManager::singleton() ); + + $request = new \FauxRequest(); + $provider->unpersistSession( $request ); + $this->assertSame( '', $request->response()->getCookie( 'MySessionName' ) ); + $this->assertSame( '', $request->response()->getCookie( 'xUserID' ) ); + $this->assertSame( null, $request->response()->getCookie( 'xUserName' ) ); + $this->assertSame( '', $request->response()->getCookie( 'xToken' ) ); + $this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) ); + + $provider->unpersistSession( $this->getSentRequest() ); + } + + public function testSetLoggedOutCookie() { + $provider = \TestingAccessWrapper::newFromObject( new CookieSessionProvider( array( + 'priority' => 1, + 'sessionName' => 'MySessionName', + 'cookieOptions' => array( 'prefix' => 'x' ), + ) ) ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $provider->setConfig( $this->getConfig() ); + $provider->setManager( SessionManager::singleton() ); + + $t1 = time(); + $t2 = time() - 86400 * 2; + + // Set it + $request = new \FauxRequest(); + $provider->setLoggedOutCookie( $t1, $request ); + $this->assertSame( (string)$t1, $request->response()->getCookie( 'xLoggedOut' ) ); + + // Too old + $request = new \FauxRequest(); + $provider->setLoggedOutCookie( $t2, $request ); + $this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) ); + + // Don't reset if it's already set + $request = new \FauxRequest(); + $request->setCookies( array( + 'xLoggedOut' => $t1, + ), '' ); + $provider->setLoggedOutCookie( $t1, $request ); + $this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) ); + } + + /** + * To be mocked for hooks, since PHPUnit can't otherwise mock methods that + * take references. + */ + public function onUserSetCookies( $user, &$sessionData, &$cookies ) { + } + +} diff --git a/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php b/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php new file mode 100644 index 0000000000..e06dfd5555 --- /dev/null +++ b/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php @@ -0,0 +1,301 @@ +set( 'CookiePrefix', 'wgCookiePrefix' ); + + $params = array( + 'sessionCookieName' => $name, + 'sessionCookieOptions' => array(), + ); + if ( $prefix !== null ) { + $params['sessionCookieOptions']['prefix'] = $prefix; + } + + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' ) + ->setConstructorArgs( array( $params ) ) + ->getMockForAbstractClass(); + $provider->setLogger( new \TestLogger() ); + $provider->setConfig( $config ); + $provider->setManager( new SessionManager() ); + + return $provider; + } + + public function testConstructor() { + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' ) + ->getMockForAbstractClass(); + $priv = \TestingAccessWrapper::newFromObject( $provider ); + $this->assertNull( $priv->sessionCookieName ); + $this->assertSame( array(), $priv->sessionCookieOptions ); + + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' ) + ->setConstructorArgs( array( array( + 'sessionCookieName' => 'Foo', + 'sessionCookieOptions' => array( 'Bar' ), + ) ) ) + ->getMockForAbstractClass(); + $priv = \TestingAccessWrapper::newFromObject( $provider ); + $this->assertSame( 'Foo', $priv->sessionCookieName ); + $this->assertSame( array( 'Bar' ), $priv->sessionCookieOptions ); + + try { + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' ) + ->setConstructorArgs( array( array( + 'sessionCookieName' => false, + ) ) ) + ->getMockForAbstractClass(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'sessionCookieName must be a string', + $ex->getMessage() + ); + } + + try { + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' ) + ->setConstructorArgs( array( array( + 'sessionCookieOptions' => 'x', + ) ) ) + ->getMockForAbstractClass(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'sessionCookieOptions must be an array', + $ex->getMessage() + ); + } + } + + public function testBasics() { + $provider = $this->getProvider( null ); + $this->assertFalse( $provider->persistsSessionID() ); + $this->assertFalse( $provider->canChangeUser() ); + + $provider = $this->getProvider( 'Foo' ); + $this->assertTrue( $provider->persistsSessionID() ); + $this->assertFalse( $provider->canChangeUser() ); + + $msg = $provider->whyNoSession(); + $this->assertInstanceOf( 'Message', $msg ); + $this->assertSame( 'sessionprovider-nocookies', $msg->getKey() ); + } + + public function testGetVaryCookies() { + $provider = $this->getProvider( null ); + $this->assertSame( array(), $provider->getVaryCookies() ); + + $provider = $this->getProvider( 'Foo' ); + $this->assertSame( array( 'wgCookiePrefixFoo' ), $provider->getVaryCookies() ); + + $provider = $this->getProvider( 'Foo', 'Bar' ); + $this->assertSame( array( 'BarFoo' ), $provider->getVaryCookies() ); + + $provider = $this->getProvider( 'Foo', '' ); + $this->assertSame( array( 'Foo' ), $provider->getVaryCookies() ); + } + + public function testGetSessionIdFromCookie() { + $this->setMwGlobals( 'wgCookiePrefix', 'wgCookiePrefix' ); + $request = new \FauxRequest(); + $request->setCookies( array( + '' => 'empty---------------------------', + 'Foo' => 'foo-----------------------------', + 'wgCookiePrefixFoo' => 'wgfoo---------------------------', + 'BarFoo' => 'foobar--------------------------', + 'bad' => 'bad', + ), '' ); + + $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( null ) ); + try { + $provider->getSessionIdFromCookie( $request ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( + 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie::getSessionIdFromCookie ' . + 'may not be called when $this->sessionCookieName === null', + $ex->getMessage() + ); + } + + $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo' ) ); + $this->assertSame( + 'wgfoo---------------------------', + $provider->getSessionIdFromCookie( $request ) + ); + + $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo', 'Bar' ) ); + $this->assertSame( + 'foobar--------------------------', + $provider->getSessionIdFromCookie( $request ) + ); + + $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo', '' ) ); + $this->assertSame( + 'foo-----------------------------', + $provider->getSessionIdFromCookie( $request ) + ); + + $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'bad', '' ) ); + $this->assertSame( null, $provider->getSessionIdFromCookie( $request ) ); + + $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'none', '' ) ); + $this->assertSame( null, $provider->getSessionIdFromCookie( $request ) ); + } + + protected function getSentRequest() { + $sentResponse = $this->getMock( 'FauxResponse', array( 'headersSent', 'setCookie', 'header' ) ); + $sentResponse->expects( $this->any() )->method( 'headersSent' ) + ->will( $this->returnValue( true ) ); + $sentResponse->expects( $this->never() )->method( 'setCookie' ); + $sentResponse->expects( $this->never() )->method( 'header' ); + + $sentRequest = $this->getMock( 'FauxRequest', array( 'response' ) ); + $sentRequest->expects( $this->any() )->method( 'response' ) + ->will( $this->returnValue( $sentResponse ) ); + return $sentRequest; + } + + /** + * @dataProvider providePersistSession + * @param bool $secure + * @param bool $remember + */ + public function testPersistSession( $secure, $remember ) { + $this->setMwGlobals( array( + 'wgCookieExpiration' => 100, + 'wgSecureLogin' => false, + ) ); + + $provider = $this->getProvider( 'session' ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $priv = \TestingAccessWrapper::newFromObject( $provider ); + $priv->sessionCookieOptions = array( + 'prefix' => 'x', + 'path' => 'CookiePath', + 'domain' => 'CookieDomain', + 'secure' => false, + 'httpOnly' => true, + ); + + $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $user = User::newFromName( 'UTSysop' ); + $this->assertFalse( $user->requiresHTTPS(), 'sanity check' ); + + $backend = new SessionBackend( + new SessionId( $sessionId ), + new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $sessionId, + 'persisted' => true, + 'userInfo' => UserInfo::newFromUser( $user, true ), + 'idIsSafe' => true, + ) ), + new \EmptyBagOStuff(), + new \Psr\Log\NullLogger(), + 10 + ); + \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false; + $backend->setRememberUser( $remember ); + $backend->setForceHTTPS( $secure ); + + // No cookie + $priv->sessionCookieName = null; + $request = new \FauxRequest(); + $provider->persistSession( $backend, $request ); + $this->assertSame( array(), $request->response()->getCookies() ); + + // Cookie + $priv->sessionCookieName = 'session'; + $request = new \FauxRequest(); + $time = time(); + $provider->persistSession( $backend, $request ); + + $cookie = $request->response()->getCookieData( 'xsession' ); + $this->assertInternalType( 'array', $cookie ); + if ( isset( $cookie['expire'] ) && $cookie['expire'] > 0 ) { + // Round expiry so we don't randomly fail if the seconds ticked during the test. + $cookie['expire'] = round( $cookie['expire'] - $time, -2 ); + } + $this->assertEquals( array( + 'value' => $sessionId, + 'expire' => null, + 'path' => 'CookiePath', + 'domain' => 'CookieDomain', + 'secure' => $secure, + 'httpOnly' => true, + 'raw' => false, + ), $cookie ); + + $cookie = $request->response()->getCookieData( 'forceHTTPS' ); + if ( $secure ) { + $this->assertInternalType( 'array', $cookie ); + if ( isset( $cookie['expire'] ) && $cookie['expire'] > 0 ) { + // Round expiry so we don't randomly fail if the seconds ticked during the test. + $cookie['expire'] = round( $cookie['expire'] - $time, -2 ); + } + $this->assertEquals( array( + 'value' => 'true', + 'expire' => $remember ? 100 : null, + 'path' => 'CookiePath', + 'domain' => 'CookieDomain', + 'secure' => false, + 'httpOnly' => true, + 'raw' => false, + ), $cookie ); + } else { + $this->assertNull( $cookie ); + } + + // Headers sent + $request = $this->getSentRequest(); + $provider->persistSession( $backend, $request ); + $this->assertSame( array(), $request->response()->getCookies() ); + } + + public static function providePersistSession() { + return array( + array( false, false ), + array( false, true ), + array( true, false ), + array( true, true ), + ); + } + + public function testUnpersistSession() { + $provider = $this->getProvider( 'session', '' ); + $provider->setLogger( new \Psr\Log\NullLogger() ); + $priv = \TestingAccessWrapper::newFromObject( $provider ); + + // No cookie + $priv->sessionCookieName = null; + $request = new \FauxRequest(); + $provider->unpersistSession( $request ); + $this->assertSame( null, $request->response()->getCookie( 'session', '' ) ); + + // Cookie + $priv->sessionCookieName = 'session'; + $request = new \FauxRequest(); + $provider->unpersistSession( $request ); + $this->assertSame( '', $request->response()->getCookie( 'session', '' ) ); + + // Headers sent + $request = $this->getSentRequest(); + $provider->unpersistSession( $request ); + $this->assertSame( null, $request->response()->getCookie( 'session', '' ) ); + } + +} diff --git a/tests/phpunit/includes/session/PHPSessionHandlerTest.php b/tests/phpunit/includes/session/PHPSessionHandlerTest.php new file mode 100644 index 0000000000..c18b82130a --- /dev/null +++ b/tests/phpunit/includes/session/PHPSessionHandlerTest.php @@ -0,0 +1,353 @@ +setAccessible( true ); + if ( $rProp->getValue() ) { + $old = \TestingAccessWrapper::newFromObject( $rProp->getValue() ); + $oldManager = $old->manager; + $oldStore = $old->store; + $oldLogger = $old->logger; + $reset[] = new \ScopedCallback( + array( 'MediaWiki\\Session\\PHPSessionHandler', 'install' ), + array( $oldManager, $oldStore, $oldLogger ) + ); + } + + return $reset; + } + + public function testEnableFlags() { + $handler = \TestingAccessWrapper::newFromObject( + $this->getMockBuilder( 'MediaWiki\\Session\\PHPSessionHandler' ) + ->setMethods( null ) + ->disableOriginalConstructor() + ->getMock() + ); + + $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' ); + $rProp->setAccessible( true ); + $reset = new \ScopedCallback( array( $rProp, 'setValue' ), array( $rProp->getValue() ) ); + $rProp->setValue( $handler ); + + $handler->setEnableFlags( 'enable' ); + $this->assertTrue( $handler->enable ); + $this->assertFalse( $handler->warn ); + $this->assertTrue( PHPSessionHandler::isEnabled() ); + + $handler->setEnableFlags( 'warn' ); + $this->assertTrue( $handler->enable ); + $this->assertTrue( $handler->warn ); + $this->assertTrue( PHPSessionHandler::isEnabled() ); + + $handler->setEnableFlags( 'disable' ); + $this->assertFalse( $handler->enable ); + $this->assertFalse( PHPSessionHandler::isEnabled() ); + + $rProp->setValue( null ); + $this->assertFalse( PHPSessionHandler::isEnabled() ); + } + + public function testInstall() { + $reset = $this->getResetter( $rProp ); + $rProp->setValue( null ); + + session_write_close(); + ini_set( 'session.use_cookies', 1 ); + ini_set( 'session.use_trans_sid', 1 ); + + $store = new \HashBagOStuff(); + $logger = new \TestLogger(); + $manager = new SessionManager( array( + 'store' => $store, + 'logger' => $logger, + ) ); + + $this->assertFalse( PHPSessionHandler::isInstalled() ); + PHPSessionHandler::install( $manager ); + $this->assertTrue( PHPSessionHandler::isInstalled() ); + + $this->assertFalse( wfIniGetBool( 'session.use_cookies' ) ); + $this->assertFalse( wfIniGetBool( 'session.use_trans_sid' ) ); + + $this->assertNotNull( $rProp->getValue() ); + $priv = \TestingAccessWrapper::newFromObject( $rProp->getValue() ); + $this->assertSame( $manager, $priv->manager ); + $this->assertSame( $store, $priv->store ); + $this->assertSame( $logger, $priv->logger ); + } + + /** + * @dataProvider provideHandlers + * @param string $handler php serialize_handler to use + */ + public function testSessionHandling( $handler ) { + $this->hideDeprecated( '$_SESSION' ); + $reset[] = $this->getResetter( $rProp ); + + $this->setMwGlobals( array( + 'wgSessionProviders' => array( array( 'class' => 'DummySessionProvider' ) ), + 'wgObjectCacheSessionExpiry' => 2, + ) ); + + $store = new \HashBagOStuff(); + $logger = new \TestLogger( true, function ( $m ) { + return preg_match( '/^SessionBackend a{32} /', $m ) ? null : $m; + } ); + $manager = new SessionManager( array( + 'store' => $store, + 'logger' => $logger, + ) ); + PHPSessionHandler::install( $manager ); + $wrap = \TestingAccessWrapper::newFromObject( $rProp->getValue() ); + $reset[] = new \ScopedCallback( + array( $wrap, 'setEnableFlags' ), + array( $wrap->enable ? $wrap->warn ? 'warn' : 'enable' : 'disable' ) + ); + $wrap->setEnableFlags( 'warn' ); + + \MediaWiki\suppressWarnings(); + ini_set( 'session.serialize_handler', $handler ); + \MediaWiki\restoreWarnings(); + if ( ini_get( 'session.serialize_handler' ) !== $handler ) { + $this->markTestSkipped( "Cannot set session.serialize_handler to \"$handler\"" ); + } + + // Session IDs for testing + $sessionA = str_repeat( 'a', 32 ); + $sessionB = str_repeat( 'b', 32 ); + $sessionC = str_repeat( 'c', 32 ); + + // Set up garbage data in the session + $_SESSION['AuthenticationSessionTest'] = 'bogus'; + + session_id( $sessionA ); + session_start(); + $this->assertSame( array(), $_SESSION ); + $this->assertSame( $sessionA, session_id() ); + + // Set some data in the session so we can see if it works. + $rand = mt_rand(); + $_SESSION['AuthenticationSessionTest'] = $rand; + $expect = array( 'AuthenticationSessionTest' => $rand ); + session_write_close(); + $this->assertSame( array( + array( LogLevel::WARNING, 'Something wrote to $_SESSION!' ), + ), $logger->getBuffer() ); + + // Screw up $_SESSION so we can tell the difference between "this + // worked" and "this did nothing" + $_SESSION['AuthenticationSessionTest'] = 'bogus'; + + // Re-open the session and see that data was actually reloaded + session_start(); + $this->assertSame( $expect, $_SESSION ); + + // Make sure session_reset() works too. + if ( function_exists( 'session_reset' ) ) { + $_SESSION['AuthenticationSessionTest'] = 'bogus'; + session_reset(); + $this->assertSame( $expect, $_SESSION ); + } + + // Test expiry + session_write_close(); + ini_set( 'session.gc_divisor', 1 ); + ini_set( 'session.gc_probability', 1 ); + sleep( 3 ); + session_start(); + $this->assertSame( array(), $_SESSION ); + + // Re-fill the session, then test that session_destroy() works. + $_SESSION['AuthenticationSessionTest'] = $rand; + session_write_close(); + session_start(); + $this->assertSame( $expect, $_SESSION ); + session_destroy(); + session_id( $sessionA ); + session_start(); + $this->assertSame( array(), $_SESSION ); + session_write_close(); + + // Test that our session handler won't clone someone else's session + session_id( $sessionB ); + session_start(); + $this->assertSame( $sessionB, session_id() ); + $_SESSION['id'] = 'B'; + session_write_close(); + + session_id( $sessionC ); + session_start(); + $this->assertSame( array(), $_SESSION ); + $_SESSION['id'] = 'C'; + session_write_close(); + + session_id( $sessionB ); + session_start(); + $this->assertSame( array( 'id' => 'B' ), $_SESSION ); + session_write_close(); + + session_id( $sessionC ); + session_start(); + $this->assertSame( array( 'id' => 'C' ), $_SESSION ); + session_destroy(); + + session_id( $sessionB ); + session_start(); + $this->assertSame( array( 'id' => 'B' ), $_SESSION ); + + // Test merging between Session and $_SESSION + session_write_close(); + + $session = $manager->getEmptySession(); + $session->set( 'Unchanged', 'setup' ); + $session->set( 'Changed in $_SESSION', 'setup' ); + $session->set( 'Changed in Session', 'setup' ); + $session->set( 'Changed in both', 'setup' ); + $session->set( 'Deleted in Session', 'setup' ); + $session->set( 'Deleted in $_SESSION', 'setup' ); + $session->set( 'Deleted in both', 'setup' ); + $session->set( 'Deleted in Session, changed in $_SESSION', 'setup' ); + $session->set( 'Deleted in $_SESSION, changed in Session', 'setup' ); + $session->persist(); + $session->save(); + + session_id( $session->getId() ); + session_start(); + $session->set( 'Added in Session', 'Session' ); + $session->set( 'Added in both', 'Session' ); + $session->set( 'Changed in Session', 'Session' ); + $session->set( 'Changed in both', 'Session' ); + $session->set( 'Deleted in $_SESSION, changed in Session', 'Session' ); + $session->remove( 'Deleted in Session' ); + $session->remove( 'Deleted in both' ); + $session->remove( 'Deleted in Session, changed in $_SESSION' ); + $session->save(); + $_SESSION['Added in $_SESSION'] = '$_SESSION'; + $_SESSION['Added in both'] = '$_SESSION'; + $_SESSION['Changed in $_SESSION'] = '$_SESSION'; + $_SESSION['Changed in both'] = '$_SESSION'; + $_SESSION['Deleted in Session, changed in $_SESSION'] = '$_SESSION'; + unset( $_SESSION['Deleted in $_SESSION'] ); + unset( $_SESSION['Deleted in both'] ); + unset( $_SESSION['Deleted in $_SESSION, changed in Session'] ); + session_write_close(); + + $this->assertEquals( array( + 'Added in Session' => 'Session', + 'Added in $_SESSION' => '$_SESSION', + 'Added in both' => 'Session', + 'Unchanged' => 'setup', + 'Changed in Session' => 'Session', + 'Changed in $_SESSION' => '$_SESSION', + 'Changed in both' => 'Session', + 'Deleted in Session, changed in $_SESSION' => '$_SESSION', + 'Deleted in $_SESSION, changed in Session' => 'Session', + ), iterator_to_array( $session ) ); + + $session->clear(); + $session->set( 42, 'forty-two' ); + $session->set( 'forty-two', 42 ); + $session->set( 'wrong', 43 ); + $session->persist(); + $session->save(); + + session_start(); + $this->assertArrayHasKey( 'forty-two', $_SESSION ); + $this->assertSame( 42, $_SESSION['forty-two'] ); + $this->assertArrayHasKey( 'wrong', $_SESSION ); + unset( $_SESSION['wrong'] ); + session_write_close(); + + $this->assertEquals( array( + 42 => 'forty-two', + 'forty-two' => 42, + ), iterator_to_array( $session ) ); + } + + public static function provideHandlers() { + return array( + array( 'php' ), + array( 'php_binary' ), + array( 'php_serialize' ), + ); + } + + /** + * @dataProvider provideDisabled + * @expectedException BadMethodCallException + * @expectedExceptionMessage Attempt to use PHP session management + */ + public function testDisabled( $method, $args ) { + $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' ); + $rProp->setAccessible( true ); + $handler = $this->getMockBuilder( 'MediaWiki\\Session\\PHPSessionHandler' ) + ->setMethods( null ) + ->disableOriginalConstructor() + ->getMock(); + \TestingAccessWrapper::newFromObject( $handler )->setEnableFlags( 'disable' ); + $oldValue = $rProp->getValue(); + $rProp->setValue( $handler ); + $reset = new \ScopedCallback( array( $rProp, 'setValue' ), array( $oldValue ) ); + + call_user_func_array( array( $handler, $method ), $args ); + } + + public static function provideDisabled() { + return array( + array( 'open', array( '', '' ) ), + array( 'read', array( '' ) ), + array( 'write', array( '', '' ) ), + array( 'destroy', array( '' ) ), + ); + } + + /** + * @dataProvider provideWrongInstance + * @expectedException UnexpectedValueException + * @expectedExceptionMessageRegExp /: Wrong instance called!$/ + */ + public function testWrongInstance( $method, $args ) { + $handler = $this->getMockBuilder( 'MediaWiki\\Session\\PHPSessionHandler' ) + ->setMethods( null ) + ->disableOriginalConstructor() + ->getMock(); + \TestingAccessWrapper::newFromObject( $handler )->setEnableFlags( 'enable' ); + + call_user_func_array( array( $handler, $method ), $args ); + } + + public static function provideWrongInstance() { + return array( + array( 'open', array( '', '' ) ), + array( 'close', array() ), + array( 'read', array( '' ) ), + array( 'write', array( '', '' ) ), + array( 'destroy', array( '' ) ), + array( 'gc', array( 0 ) ), + ); + } + +} diff --git a/tests/phpunit/includes/session/SessionBackendTest.php b/tests/phpunit/includes/session/SessionBackendTest.php new file mode 100644 index 0000000000..d64c998fa6 --- /dev/null +++ b/tests/phpunit/includes/session/SessionBackendTest.php @@ -0,0 +1,746 @@ +config ) { + $this->config = new \HashConfig(); + $this->manager = null; + } + if ( !$this->store ) { + $this->store = new TestBagOStuff(); + $this->manager = null; + } + + $logger = new \Psr\Log\NullLogger(); + if ( !$this->manager ) { + $this->manager = new SessionManager( array( + 'store' => $this->store, + 'logger' => $logger, + 'config' => $this->config, + ) ); + } + + if ( !$this->provider ) { + $this->provider = new \DummySessionProvider(); + } + $this->provider->setLogger( $logger ); + $this->provider->setConfig( $this->config ); + $this->provider->setManager( $this->manager ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $this->provider, + 'id' => self::SESSIONID, + 'persisted' => true, + 'userInfo' => UserInfo::newFromUser( $user ?: new User, true ), + 'idIsSafe' => true, + ) ); + $id = new SessionId( $info->getId() ); + + $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 ); + $priv = \TestingAccessWrapper::newFromObject( $backend ); + $priv->persist = false; + $priv->requests = array( 100 => new \FauxRequest() ); + $priv->usePhpSessionHandling = false; + + $manager = \TestingAccessWrapper::newFromObject( $this->manager ); + $manager->allSessionBackends = array( $backend->getId() => $backend ); + $manager->allSessionIds = array( $backend->getId() => $id ); + $manager->sessionProviders = array( (string)$this->provider => $this->provider ); + + return $backend; + } + + public function testConstructor() { + // Set variables + $this->getBackend(); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $this->provider, + 'id' => self::SESSIONID, + 'persisted' => true, + 'userInfo' => UserInfo::newFromName( 'UTSysop', false ), + 'idIsSafe' => true, + ) ); + $id = new SessionId( $info->getId() ); + $logger = new \Psr\Log\NullLogger(); + try { + new SessionBackend( $id, $info, $this->store, $logger, 10 ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + "Refusing to create session for unverified user {$info->getUserInfo()}", + $ex->getMessage() + ); + } + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => self::SESSIONID, + 'userInfo' => UserInfo::newFromName( 'UTSysop', true ), + 'idIsSafe' => true, + ) ); + $id = new SessionId( $info->getId() ); + try { + new SessionBackend( $id, $info, $this->store, $logger, 10 ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Cannot create session without a provider', $ex->getMessage() ); + } + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $this->provider, + 'id' => self::SESSIONID, + 'persisted' => true, + 'userInfo' => UserInfo::newFromName( 'UTSysop', true ), + 'idIsSafe' => true, + ) ); + $id = new SessionId( '!' . $info->getId() ); + try { + new SessionBackend( $id, $info, $this->store, $logger, 10 ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'SessionId and SessionInfo don\'t match', + $ex->getMessage() + ); + } + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $this->provider, + 'id' => self::SESSIONID, + 'persisted' => true, + 'userInfo' => UserInfo::newFromName( 'UTSysop', true ), + 'idIsSafe' => true, + ) ); + $id = new SessionId( $info->getId() ); + $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 ); + $this->assertSame( self::SESSIONID, $backend->getId() ); + $this->assertSame( $id, $backend->getSessionId() ); + $this->assertSame( $this->provider, $backend->getProvider() ); + $this->assertInstanceOf( 'User', $backend->getUser() ); + $this->assertSame( 'UTSysop', $backend->getUser()->getName() ); + $this->assertSame( $info->wasPersisted(), $backend->isPersistent() ); + $this->assertSame( $info->wasRemembered(), $backend->shouldRememberUser() ); + $this->assertSame( $info->forceHTTPS(), $backend->shouldForceHTTPS() ); + + $expire = time() + 100; + $this->store->setSessionMeta( self::SESSIONID, array( 'expires' => $expire ), 2 ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $this->provider, + 'id' => self::SESSIONID, + 'persisted' => true, + 'forceHTTPS' => true, + 'metadata' => array( 'foo' ), + 'idIsSafe' => true, + ) ); + $id = new SessionId( $info->getId() ); + $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 ); + $this->assertSame( self::SESSIONID, $backend->getId() ); + $this->assertSame( $id, $backend->getSessionId() ); + $this->assertSame( $this->provider, $backend->getProvider() ); + $this->assertInstanceOf( 'User', $backend->getUser() ); + $this->assertTrue( $backend->getUser()->isAnon() ); + $this->assertSame( $info->wasPersisted(), $backend->isPersistent() ); + $this->assertSame( $info->wasRemembered(), $backend->shouldRememberUser() ); + $this->assertSame( $info->forceHTTPS(), $backend->shouldForceHTTPS() ); + $this->assertSame( $expire, \TestingAccessWrapper::newFromObject( $backend )->expires ); + $this->assertSame( array( 'foo' ), $backend->getProviderMetadata() ); + } + + public function testSessionStuff() { + $backend = $this->getBackend(); + $priv = \TestingAccessWrapper::newFromObject( $backend ); + $priv->requests = array(); // Remove dummy session + + $manager = \TestingAccessWrapper::newFromObject( $this->manager ); + + $request1 = new \FauxRequest(); + $session1 = $backend->getSession( $request1 ); + $request2 = new \FauxRequest(); + $session2 = $backend->getSession( $request2 ); + + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session1 ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session2 ); + $this->assertSame( 2, count( $priv->requests ) ); + + $index = \TestingAccessWrapper::newFromObject( $session1 )->index; + + $this->assertSame( $request1, $backend->getRequest( $index ) ); + $this->assertSame( null, $backend->suggestLoginUsername( $index ) ); + $request1->setCookie( 'UserName', 'Example' ); + $this->assertSame( 'Example', $backend->suggestLoginUsername( $index ) ); + + $session1 = null; + $this->assertSame( 1, count( $priv->requests ) ); + $this->assertArrayHasKey( $backend->getId(), $manager->allSessionBackends ); + $this->assertSame( $backend, $manager->allSessionBackends[$backend->getId()] ); + try { + $backend->getRequest( $index ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid session index', $ex->getMessage() ); + } + try { + $backend->suggestLoginUsername( $index ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid session index', $ex->getMessage() ); + } + + $session2 = null; + $this->assertSame( 0, count( $priv->requests ) ); + $this->assertArrayNotHasKey( $backend->getId(), $manager->allSessionBackends ); + $this->assertArrayHasKey( $backend->getId(), $manager->allSessionIds ); + } + + public function testResetId() { + $id = session_id(); + + $builder = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( array( 'persistsSessionId', 'sessionIdWasReset' ) ); + + $this->provider = $builder->getMock(); + $this->provider->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( false ) ); + $this->provider->expects( $this->never() )->method( 'sessionIdWasReset' ); + $backend = $this->getBackend( User::newFromName( 'UTSysop' ) ); + $manager = \TestingAccessWrapper::newFromObject( $this->manager ); + $sessionId = $backend->getSessionId(); + $backend->resetId(); + $this->assertSame( self::SESSIONID, $backend->getId() ); + $this->assertSame( $backend->getId(), $sessionId->getId() ); + $this->assertSame( $id, session_id() ); + $this->assertSame( $backend, $manager->allSessionBackends[self::SESSIONID] ); + + $this->provider = $builder->getMock(); + $this->provider->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( true ) ); + $backend = $this->getBackend(); + $this->provider->expects( $this->once() )->method( 'sessionIdWasReset' ) + ->with( $this->identicalTo( $backend ), $this->identicalTo( self::SESSIONID ) ); + $manager = \TestingAccessWrapper::newFromObject( $this->manager ); + $sessionId = $backend->getSessionId(); + $backend->resetId(); + $this->assertNotEquals( self::SESSIONID, $backend->getId() ); + $this->assertSame( $backend->getId(), $sessionId->getId() ); + $this->assertInternalType( 'array', $this->store->getSession( $backend->getId() ) ); + $this->assertFalse( $this->store->getSession( self::SESSIONID ) ); + $this->assertSame( $id, session_id() ); + $this->assertArrayNotHasKey( self::SESSIONID, $manager->allSessionBackends ); + $this->assertArrayHasKey( $backend->getId(), $manager->allSessionBackends ); + $this->assertSame( $backend, $manager->allSessionBackends[$backend->getId()] ); + } + + public function testPersist() { + $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) ); + $this->provider->expects( $this->once() )->method( 'persistSession' ); + $backend = $this->getBackend(); + $this->assertFalse( $backend->isPersistent(), 'sanity check' ); + $backend->save(); // This one shouldn't call $provider->persistSession() + + $backend->persist(); + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + + $this->provider = null; + $backend = $this->getBackend(); + $wrap = \TestingAccessWrapper::newFromObject( $backend ); + $wrap->persist = true; + $wrap->expires = 0; + $backend->persist(); + $this->assertNotEquals( 0, $wrap->expires ); + } + + public function testRememberUser() { + $backend = $this->getBackend(); + + $remembered = $backend->shouldRememberUser(); + $backend->setRememberUser( !$remembered ); + $this->assertNotEquals( $remembered, $backend->shouldRememberUser() ); + $backend->setRememberUser( $remembered ); + $this->assertEquals( $remembered, $backend->shouldRememberUser() ); + } + + public function testForceHTTPS() { + $backend = $this->getBackend(); + + $force = $backend->shouldForceHTTPS(); + $backend->setForceHTTPS( !$force ); + $this->assertNotEquals( $force, $backend->shouldForceHTTPS() ); + $backend->setForceHTTPS( $force ); + $this->assertEquals( $force, $backend->shouldForceHTTPS() ); + } + + public function testLoggedOutTimestamp() { + $backend = $this->getBackend(); + + $backend->setLoggedOutTimestamp( 42 ); + $this->assertSame( 42, $backend->getLoggedOutTimestamp() ); + $backend->setLoggedOutTimestamp( '123' ); + $this->assertSame( 123, $backend->getLoggedOutTimestamp() ); + } + + public function testSetUser() { + $user = User::newFromName( 'UTSysop' ); + + $this->provider = $this->getMock( 'DummySessionProvider', array( 'canChangeUser' ) ); + $this->provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( false ) ); + $backend = $this->getBackend(); + $this->assertFalse( $backend->canSetUser() ); + try { + $backend->setUser( $user ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( + 'Cannot set user on this session; check $session->canSetUser() first', + $ex->getMessage() + ); + } + $this->assertNotSame( $user, $backend->getUser() ); + + $this->provider = null; + $backend = $this->getBackend(); + $this->assertTrue( $backend->canSetUser() ); + $this->assertNotSame( $user, $backend->getUser(), 'sanity check' ); + $backend->setUser( $user ); + $this->assertSame( $user, $backend->getUser() ); + } + + public function testDirty() { + $backend = $this->getBackend(); + $priv = \TestingAccessWrapper::newFromObject( $backend ); + $priv->dataDirty = false; + $backend->dirty(); + $this->assertTrue( $priv->dataDirty ); + } + + public function testGetData() { + $backend = $this->getBackend(); + $data = $backend->getData(); + $this->assertSame( array(), $data ); + $this->assertTrue( \TestingAccessWrapper::newFromObject( $backend )->dataDirty ); + $data['???'] = '!!!'; + $this->assertSame( array( '???' => '!!!' ), $data ); + + $testData = array( 'foo' => 'foo!', 'bar', array( 'baz', null ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend(); + $this->assertSame( $testData, $backend->getData() ); + $this->assertFalse( \TestingAccessWrapper::newFromObject( $backend )->dataDirty ); + } + + public function testAddData() { + $backend = $this->getBackend(); + $priv = \TestingAccessWrapper::newFromObject( $backend ); + + $priv->data = array( 'foo' => 1 ); + $priv->dataDirty = false; + $backend->addData( array( 'foo' => 1 ) ); + $this->assertSame( array( 'foo' => 1 ), $priv->data ); + $this->assertFalse( $priv->dataDirty ); + + $priv->data = array( 'foo' => 1 ); + $priv->dataDirty = false; + $backend->addData( array( 'foo' => '1' ) ); + $this->assertSame( array( 'foo' => '1' ), $priv->data ); + $this->assertTrue( $priv->dataDirty ); + + $priv->data = array( 'foo' => 1 ); + $priv->dataDirty = false; + $backend->addData( array( 'bar' => 2 ) ); + $this->assertSame( array( 'foo' => 1, 'bar' => 2 ), $priv->data ); + $this->assertTrue( $priv->dataDirty ); + } + + public function testDelaySave() { + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $backend = $this->getBackend(); + $priv = \TestingAccessWrapper::newFromObject( $backend ); + $priv->persist = true; + + // Saves happen normally when no delay is in effect + $this->onSessionMetadataCalled = false; + $priv->metaDirty = true; + $backend->save(); + $this->assertTrue( $this->onSessionMetadataCalled, 'sanity check' ); + + $this->onSessionMetadataCalled = false; + $priv->metaDirty = true; + $priv->autosave(); + $this->assertTrue( $this->onSessionMetadataCalled, 'sanity check' ); + + $delay = $backend->delaySave(); + + // Autosave doesn't happen when no delay is in effect + $this->onSessionMetadataCalled = false; + $priv->metaDirty = true; + $priv->autosave(); + $this->assertFalse( $this->onSessionMetadataCalled ); + + // Save still does happen when no delay is in effect + $priv->save(); + $this->assertTrue( $this->onSessionMetadataCalled ); + + // Save happens when delay is consumed + $this->onSessionMetadataCalled = false; + $priv->metaDirty = true; + \ScopedCallback::consume( $delay ); + $this->assertTrue( $this->onSessionMetadataCalled ); + + // Test multiple delays + $delay1 = $backend->delaySave(); + $delay2 = $backend->delaySave(); + $delay3 = $backend->delaySave(); + $this->onSessionMetadataCalled = false; + $priv->metaDirty = true; + $priv->autosave(); + $this->assertFalse( $this->onSessionMetadataCalled ); + \ScopedCallback::consume( $delay3 ); + $this->assertFalse( $this->onSessionMetadataCalled ); + \ScopedCallback::consume( $delay1 ); + $this->assertFalse( $this->onSessionMetadataCalled ); + \ScopedCallback::consume( $delay2 ); + $this->assertTrue( $this->onSessionMetadataCalled ); + } + + public function testSave() { + $user = User::newFromName( 'UTSysop' ); + $this->store = new TestBagOStuff(); + $testData = array( 'foo' => 'foo!', 'bar', array( 'baz', null ) ); + + $neverHook = $this->getMock( __CLASS__, array( 'onSessionMetadata' ) ); + $neverHook->expects( $this->never() )->method( 'onSessionMetadata' ); + + $neverProvider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) ); + $neverProvider->expects( $this->never() )->method( 'persistSession' ); + + // Not persistent or dirty + $this->provider = $neverProvider; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $neverHook ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + $this->assertFalse( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false; + $backend->save(); + $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' ); + + // Not persistent, but dirty + $this->provider = $neverProvider; + $this->onSessionMetadataCalled = false; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + $this->assertFalse( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = true; + $backend->save(); + $this->assertTrue( $this->onSessionMetadataCalled ); + $blob = $this->store->getSession( self::SESSIONID ); + $this->assertInternalType( 'array', $blob ); + $this->assertArrayHasKey( 'metadata', $blob ); + $metadata = $blob['metadata']; + $this->assertInternalType( 'array', $metadata ); + $this->assertArrayHasKey( '???', $metadata ); + $this->assertSame( '!!!', $metadata['???'] ); + + // Persistent, not dirty + $this->provider = $neverProvider; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $neverHook ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + \TestingAccessWrapper::newFromObject( $backend )->persist = true; + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false; + $backend->save(); + $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' ); + + $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) ); + $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $neverHook ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + \TestingAccessWrapper::newFromObject( $backend )->persist = true; + \TestingAccessWrapper::newFromObject( $backend )->forcePersist = true; + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false; + $backend->save(); + $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' ); + + // Persistent and dirty + $this->provider = $neverProvider; + $this->onSessionMetadataCalled = false; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + \TestingAccessWrapper::newFromObject( $backend )->persist = true; + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = true; + $backend->save(); + $this->assertTrue( $this->onSessionMetadataCalled ); + $blob = $this->store->getSession( self::SESSIONID ); + $this->assertInternalType( 'array', $blob ); + $this->assertArrayHasKey( 'metadata', $blob ); + $metadata = $blob['metadata']; + $this->assertInternalType( 'array', $metadata ); + $this->assertArrayHasKey( '???', $metadata ); + $this->assertSame( '!!!', $metadata['???'] ); + + $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) ); + $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' ); + $this->onSessionMetadataCalled = false; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + \TestingAccessWrapper::newFromObject( $backend )->persist = true; + \TestingAccessWrapper::newFromObject( $backend )->forcePersist = true; + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = true; + $backend->save(); + $this->assertTrue( $this->onSessionMetadataCalled ); + $blob = $this->store->getSession( self::SESSIONID ); + $this->assertInternalType( 'array', $blob ); + $this->assertArrayHasKey( 'metadata', $blob ); + $metadata = $blob['metadata']; + $this->assertInternalType( 'array', $metadata ); + $this->assertArrayHasKey( '???', $metadata ); + $this->assertSame( '!!!', $metadata['???'] ); + + $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) ); + $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' ); + $this->onSessionMetadataCalled = false; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + \TestingAccessWrapper::newFromObject( $backend )->persist = true; + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = true; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false; + $backend->save(); + $this->assertTrue( $this->onSessionMetadataCalled ); + $blob = $this->store->getSession( self::SESSIONID ); + $this->assertInternalType( 'array', $blob ); + $this->assertArrayHasKey( 'metadata', $blob ); + $metadata = $blob['metadata']; + $this->assertInternalType( 'array', $metadata ); + $this->assertArrayHasKey( '???', $metadata ); + $this->assertSame( '!!!', $metadata['???'] ); + + // Not marked dirty, but dirty data + $this->provider = $neverProvider; + $this->onSessionMetadataCalled = false; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + \TestingAccessWrapper::newFromObject( $backend )->persist = true; + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false; + \TestingAccessWrapper::newFromObject( $backend )->dataHash = 'Doesn\'t match'; + $backend->save(); + $this->assertTrue( $this->onSessionMetadataCalled ); + $blob = $this->store->getSession( self::SESSIONID ); + $this->assertInternalType( 'array', $blob ); + $this->assertArrayHasKey( 'metadata', $blob ); + $metadata = $blob['metadata']; + $this->assertInternalType( 'array', $metadata ); + $this->assertArrayHasKey( '???', $metadata ); + $this->assertSame( '!!!', $metadata['???'] ); + + // Bad hook + $this->provider = null; + $mockHook = $this->getMock( __CLASS__, array( 'onSessionMetadata' ) ); + $mockHook->expects( $this->any() )->method( 'onSessionMetadata' ) + ->will( $this->returnCallback( + function ( SessionBackend $backend, array &$metadata, array $requests ) { + $metadata['userId']++; + } + ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $mockHook ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $backend->dirty(); + try { + $backend->save(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'SessionMetadata hook changed metadata key "userId"', + $ex->getMessage() + ); + } + + // SessionManager::preventSessionsForUser + \TestingAccessWrapper::newFromObject( $this->manager )->preventUsers = array( + $user->getName() => true, + ); + $this->provider = $neverProvider; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $neverHook ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + \TestingAccessWrapper::newFromObject( $backend )->persist = true; + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + \TestingAccessWrapper::newFromObject( $backend )->metaDirty = true; + \TestingAccessWrapper::newFromObject( $backend )->dataDirty = true; + $backend->save(); + $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' ); + } + + public function testRenew() { + $user = User::newFromName( 'UTSysop' ); + $this->store = new TestBagOStuff(); + $testData = array( 'foo' => 'foo!', 'bar', array( 'baz', null ) ); + + // Not persistent + $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) ); + $this->provider->expects( $this->never() )->method( 'persistSession' ); + $this->onSessionMetadataCalled = false; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + $wrap = \TestingAccessWrapper::newFromObject( $backend ); + $this->assertFalse( $backend->isPersistent(), 'sanity check' ); + $wrap->metaDirty = false; + $wrap->dataDirty = false; + $wrap->forcePersist = false; + $wrap->expires = 0; + $backend->renew(); + $this->assertTrue( $this->onSessionMetadataCalled ); + $blob = $this->store->getSession( self::SESSIONID ); + $this->assertInternalType( 'array', $blob ); + $this->assertArrayHasKey( 'metadata', $blob ); + $metadata = $blob['metadata']; + $this->assertInternalType( 'array', $metadata ); + $this->assertArrayHasKey( '???', $metadata ); + $this->assertSame( '!!!', $metadata['???'] ); + $this->assertNotEquals( 0, $wrap->expires ); + + // Persistent + $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) ); + $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' ); + $this->onSessionMetadataCalled = false; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + $wrap = \TestingAccessWrapper::newFromObject( $backend ); + $wrap->persist = true; + $this->assertTrue( $backend->isPersistent(), 'sanity check' ); + $wrap->metaDirty = false; + $wrap->dataDirty = false; + $wrap->forcePersist = false; + $wrap->expires = 0; + $backend->renew(); + $this->assertTrue( $this->onSessionMetadataCalled ); + $blob = $this->store->getSession( self::SESSIONID ); + $this->assertInternalType( 'array', $blob ); + $this->assertArrayHasKey( 'metadata', $blob ); + $metadata = $blob['metadata']; + $this->assertInternalType( 'array', $metadata ); + $this->assertArrayHasKey( '???', $metadata ); + $this->assertSame( '!!!', $metadata['???'] ); + $this->assertNotEquals( 0, $wrap->expires ); + + // Not persistent, not expiring + $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) ); + $this->provider->expects( $this->never() )->method( 'persistSession' ); + $this->onSessionMetadataCalled = false; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) ); + $this->store->setSessionData( self::SESSIONID, $testData ); + $backend = $this->getBackend( $user ); + $this->store->deleteSession( self::SESSIONID ); + $wrap = \TestingAccessWrapper::newFromObject( $backend ); + $this->assertFalse( $backend->isPersistent(), 'sanity check' ); + $wrap->metaDirty = false; + $wrap->dataDirty = false; + $wrap->forcePersist = false; + $expires = time() + $wrap->lifetime + 100; + $wrap->expires = $expires; + $backend->renew(); + $this->assertFalse( $this->onSessionMetadataCalled ); + $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' ); + $this->assertEquals( $expires, $wrap->expires ); + } + + public function onSessionMetadata( SessionBackend $backend, array &$metadata, array $requests ) { + $this->onSessionMetadataCalled = true; + $metadata['???'] = '!!!'; + } + + public function testResetIdOfGlobalSession() { + if ( !PHPSessionHandler::isInstalled() ) { + PHPSessionHandler::install( SessionManager::singleton() ); + } + if ( !PHPSessionHandler::isEnabled() ) { + $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' ); + $rProp->setAccessible( true ); + $handler = \TestingAccessWrapper::newFromObject( $rProp->getValue() ); + $resetHandler = new \ScopedCallback( function () use ( $handler ) { + session_write_close(); + $handler->enable = false; + } ); + $handler->enable = true; + } + + $backend = $this->getBackend( User::newFromName( 'UTSysop' ) ); + \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = true; + + TestUtils::setSessionManagerSingleton( $this->manager ); + + $manager = \TestingAccessWrapper::newFromObject( $this->manager ); + $request = \RequestContext::getMain()->getRequest(); + $manager->globalSession = $backend->getSession( $request ); + $manager->globalSessionRequest = $request; + + session_id( self::SESSIONID ); + \MediaWiki\quietCall( 'session_start' ); + $backend->resetId(); + $this->assertNotEquals( self::SESSIONID, $backend->getId() ); + $this->assertSame( $backend->getId(), session_id() ); + session_write_close(); + + session_id( '' ); + $this->assertNotSame( $backend->getId(), session_id(), 'sanity check' ); + $backend->persist(); + $this->assertSame( $backend->getId(), session_id() ); + session_write_close(); + } + +} diff --git a/tests/phpunit/includes/session/SessionIdTest.php b/tests/phpunit/includes/session/SessionIdTest.php new file mode 100644 index 0000000000..2b06d971a6 --- /dev/null +++ b/tests/phpunit/includes/session/SessionIdTest.php @@ -0,0 +1,22 @@ +assertSame( 'foo', $id->getId() ); + $this->assertSame( 'foo', (string)$id ); + $id->setId( 'bar' ); + $this->assertSame( 'bar', $id->getId() ); + $this->assertSame( 'bar', (string)$id ); + } + +} diff --git a/tests/phpunit/includes/session/SessionInfoTest.php b/tests/phpunit/includes/session/SessionInfoTest.php new file mode 100644 index 0000000000..b411f3c48d --- /dev/null +++ b/tests/phpunit/includes/session/SessionInfoTest.php @@ -0,0 +1,328 @@ +fail( 'Expected exception not thrown', 'priority < min' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid priority', $ex->getMessage(), 'priority < min' ); + } + + try { + new SessionInfo( SessionInfo::MAX_PRIORITY + 1, array() ); + $this->fail( 'Expected exception not thrown', 'priority > max' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid priority', $ex->getMessage(), 'priority > max' ); + } + + try { + new SessionInfo( SessionInfo::MIN_PRIORITY, array( 'id' => 'ABC?' ) ); + $this->fail( 'Expected exception not thrown', 'bad session ID' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid session ID', $ex->getMessage(), 'bad session ID' ); + } + + try { + new SessionInfo( SessionInfo::MIN_PRIORITY, array( 'userInfo' => new \stdClass ) ); + $this->fail( 'Expected exception not thrown', 'bad userInfo' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid userInfo', $ex->getMessage(), 'bad userInfo' ); + } + + try { + new SessionInfo( SessionInfo::MIN_PRIORITY, array() ); + $this->fail( 'Expected exception not thrown', 'no provider, no id' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Must supply an ID when no provider is given', $ex->getMessage(), + 'no provider, no id' ); + } + + try { + new SessionInfo( SessionInfo::MIN_PRIORITY, array( 'copyFrom' => new \stdClass ) ); + $this->fail( 'Expected exception not thrown', 'bad copyFrom' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid copyFrom', $ex->getMessage(), + 'bad copyFrom' ); + } + + $manager = new SessionManager(); + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' ) + ->setMethods( array( 'persistsSessionId', 'canChangeUser', '__toString' ) ) + ->getMockForAbstractClass(); + $provider->setManager( $manager ); + $provider->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( true ) ); + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( true ) ); + $provider->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'Mock' ) ); + + $provider2 = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' ) + ->setMethods( array( 'persistsSessionId', 'canChangeUser', '__toString' ) ) + ->getMockForAbstractClass(); + $provider2->setManager( $manager ); + $provider2->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( true ) ); + $provider2->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( true ) ); + $provider2->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'Mock2' ) ); + + try { + new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'userInfo' => $anonInfo, + 'metadata' => 'foo', + ) ); + $this->fail( 'Expected exception not thrown', 'bad metadata' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid metadata', $ex->getMessage(), 'bad metadata' ); + } + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'userInfo' => $anonInfo + ) ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertNotNull( $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $anonInfo, $info->getUserInfo() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'userInfo' => $unverifiedUserInfo, + 'metadata' => array( 'Foo' ), + ) ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertNotNull( $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertSame( array( 'Foo' ), $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'userInfo' => $userInfo + ) ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertNotNull( $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $userInfo, $info->getUserInfo() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertTrue( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $id = $manager->generateSessionId(); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'id' => $id, + 'persisted' => true, + 'userInfo' => $anonInfo + ) ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertSame( $id, $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $anonInfo, $info->getUserInfo() ); + $this->assertFalse( $info->isIdSafe() ); + $this->assertTrue( $info->wasPersisted() ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertSame( $id, $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $userInfo, $info->getUserInfo() ); + $this->assertFalse( $info->isIdSafe() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertTrue( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'id' => $id, + 'persisted' => true, + 'userInfo' => $userInfo, + 'metadata' => array( 'Foo' ), + ) ); + $this->assertSame( $id, $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $userInfo, $info->getUserInfo() ); + $this->assertFalse( $info->isIdSafe() ); + $this->assertTrue( $info->wasPersisted() ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'id' => $id, + 'remembered' => true, + 'userInfo' => $userInfo, + ) ); + $this->assertFalse( $info->wasRemembered(), 'no provider' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'id' => $id, + 'remembered' => true, + ) ); + $this->assertFalse( $info->wasRemembered(), 'no user' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'id' => $id, + 'remembered' => true, + 'userInfo' => $anonInfo, + ) ); + $this->assertFalse( $info->wasRemembered(), 'anonymous user' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'id' => $id, + 'remembered' => true, + 'userInfo' => $unverifiedUserInfo, + ) ); + $this->assertFalse( $info->wasRemembered(), 'unverified user' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'provider' => $provider, + 'id' => $id, + 'remembered' => false, + 'userInfo' => $userInfo, + ) ); + $this->assertFalse( $info->wasRemembered(), 'specific override' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array( + 'id' => $id, + 'idIsSafe' => true, + ) ); + $this->assertSame( $id, $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertTrue( $info->isIdSafe() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => $id, + 'forceHTTPS' => 1, + ) ); + $this->assertTrue( $info->forceHTTPS() ); + + $fromInfo = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => $id . 'A', + 'provider' => $provider, + 'userInfo' => $userInfo, + 'idIsSafe' => true, + 'persisted' => true, + 'remembered' => true, + 'forceHTTPS' => true, + 'metadata' => array( 'foo!' ), + ) ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 4, array( + 'copyFrom' => $fromInfo, + ) ); + $this->assertSame( $id . 'A', $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 4, $info->getPriority() ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertSame( $userInfo, $info->getUserInfo() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertTrue( $info->wasPersisted() ); + $this->assertTrue( $info->wasRemembered() ); + $this->assertTrue( $info->forceHTTPS() ); + $this->assertSame( array( 'foo!' ), $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 4, array( + 'id' => $id . 'X', + 'provider' => $provider2, + 'userInfo' => $unverifiedUserInfo, + 'idIsSafe' => false, + 'persisted' => false, + 'remembered' => false, + 'forceHTTPS' => false, + 'metadata' => null, + 'copyFrom' => $fromInfo, + ) ); + $this->assertSame( $id . 'X', $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 4, $info->getPriority() ); + $this->assertSame( $provider2, $info->getProvider() ); + $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() ); + $this->assertFalse( $info->isIdSafe() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => $id, + ) ); + $this->assertSame( + '[' . SessionInfo::MIN_PRIORITY . "]null$id", + (string)$info, + 'toString' + ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'persisted' => true, + 'userInfo' => $userInfo + ) ); + $this->assertSame( + '[' . SessionInfo::MIN_PRIORITY . "]Mock<+:{$userInfo->getId()}:UTSysop>$id", + (string)$info, + 'toString' + ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'persisted' => true, + 'userInfo' => $unverifiedUserInfo + ) ); + $this->assertSame( + '[' . SessionInfo::MIN_PRIORITY . "]Mock<-:{$userInfo->getId()}:UTSysop>$id", + (string)$info, + 'toString' + ); + } + + public function testCompare() { + $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array( 'id' => $id ) ); + $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, array( 'id' => $id ) ); + + $this->assertTrue( SessionInfo::compare( $info1, $info2 ) < 0, '<' ); + $this->assertTrue( SessionInfo::compare( $info2, $info1 ) > 0, '>' ); + $this->assertTrue( SessionInfo::compare( $info1, $info1 ) === 0, '==' ); + } +} diff --git a/tests/phpunit/includes/session/SessionManagerTest.php b/tests/phpunit/includes/session/SessionManagerTest.php new file mode 100644 index 0000000000..dc217cda83 --- /dev/null +++ b/tests/phpunit/includes/session/SessionManagerTest.php @@ -0,0 +1,1683 @@ +config = new \HashConfig( array( + 'LanguageCode' => 'en', + 'SessionCacheType' => 'testSessionStore', + 'ObjectCacheSessionExpiry' => 100, + 'SessionProviders' => array( + array( 'class' => 'DummySessionProvider' ), + ) + ) ); + $this->logger = new \TestLogger( false, function ( $m ) { + return substr( $m, 0, 15 ) === 'SessionBackend ' ? null : $m; + } ); + $this->store = new TestBagOStuff(); + + return new SessionManager( array( + 'config' => $this->config, + 'logger' => $this->logger, + 'store' => $this->store, + ) ); + } + + protected function objectCacheDef( $object ) { + return array( 'factory' => function () use ( $object ) { + return $object; + } ); + } + + public function testSingleton() { + $reset = TestUtils::setSessionManagerSingleton( null ); + + $singleton = SessionManager::singleton(); + $this->assertInstanceOf( 'MediaWiki\\Session\\SessionManager', $singleton ); + $this->assertSame( $singleton, SessionManager::singleton() ); + } + + public function testGetGlobalSession() { + $context = \RequestContext::getMain(); + + if ( !PHPSessionHandler::isInstalled() ) { + PHPSessionHandler::install( SessionManager::singleton() ); + } + $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' ); + $rProp->setAccessible( true ); + $handler = \TestingAccessWrapper::newFromObject( $rProp->getValue() ); + $oldEnable = $handler->enable; + $reset[] = new \ScopedCallback( function () use ( $handler, $oldEnable ) { + if ( $handler->enable ) { + session_write_close(); + } + $handler->enable = $oldEnable; + } ); + $reset[] = TestUtils::setSessionManagerSingleton( $this->getManager() ); + + $handler->enable = true; + $request = new \FauxRequest(); + $context->setRequest( $request ); + $id = $request->getSession()->getId(); + + session_id( '' ); + $session = SessionManager::getGlobalSession(); + $this->assertSame( $id, $session->getId() ); + + session_id( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' ); + $session = SessionManager::getGlobalSession(); + $this->assertSame( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $session->getId() ); + $this->assertSame( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $request->getSession()->getId() ); + + session_write_close(); + $handler->enable = false; + $request = new \FauxRequest(); + $context->setRequest( $request ); + $id = $request->getSession()->getId(); + + session_id( '' ); + $session = SessionManager::getGlobalSession(); + $this->assertSame( $id, $session->getId() ); + + session_id( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' ); + $session = SessionManager::getGlobalSession(); + $this->assertSame( $id, $session->getId() ); + $this->assertSame( $id, $request->getSession()->getId() ); + } + + public function testConstructor() { + $manager = \TestingAccessWrapper::newFromObject( $this->getManager() ); + $this->assertSame( $this->config, $manager->config ); + $this->assertSame( $this->logger, $manager->logger ); + $this->assertSame( $this->store, $manager->store ); + + $manager = \TestingAccessWrapper::newFromObject( new SessionManager() ); + $this->assertSame( \RequestContext::getMain()->getConfig(), $manager->config ); + + $manager = \TestingAccessWrapper::newFromObject( new SessionManager( array( + 'config' => $this->config, + ) ) ); + $this->assertSame( \ObjectCache::$instances['testSessionStore'], $manager->store ); + + foreach ( array( + 'config' => '$options[\'config\'] must be an instance of Config', + 'logger' => '$options[\'logger\'] must be an instance of LoggerInterface', + 'store' => '$options[\'store\'] must be an instance of BagOStuff', + ) as $key => $error ) { + try { + new SessionManager( array( $key => new \stdClass ) ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( $error, $ex->getMessage() ); + } + } + } + + public function testGetSessionForRequest() { + $manager = $this->getManager(); + $request = new \FauxRequest(); + + $id1 = ''; + $id2 = ''; + $idEmpty = 'empty-session-------------------'; + + $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( + array( 'provideSessionInfo', 'newSessionInfo', '__toString', 'describe' ) + ); + + $provider1 = $providerBuilder->getMock(); + $provider1->expects( $this->any() )->method( 'provideSessionInfo' ) + ->with( $this->identicalTo( $request ) ) + ->will( $this->returnCallback( function ( $request ) { + return $request->info1; + } ) ); + $provider1->expects( $this->any() )->method( 'newSessionInfo' ) + ->will( $this->returnCallback( function () use ( $idEmpty, $provider1 ) { + return new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider1, + 'id' => $idEmpty, + 'persisted' => true, + 'idIsSafe' => true, + ) ); + } ) ); + $provider1->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'Provider1' ) ); + $provider1->expects( $this->any() )->method( 'describe' ) + ->will( $this->returnValue( '#1 sessions' ) ); + + $provider2 = $providerBuilder->getMock(); + $provider2->expects( $this->any() )->method( 'provideSessionInfo' ) + ->with( $this->identicalTo( $request ) ) + ->will( $this->returnCallback( function ( $request ) { + return $request->info2; + } ) ); + $provider2->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'Provider2' ) ); + $provider2->expects( $this->any() )->method( 'describe' ) + ->will( $this->returnValue( '#2 sessions' ) ); + + $this->config->set( 'SessionProviders', array( + $this->objectCacheDef( $provider1 ), + $this->objectCacheDef( $provider2 ), + ) ); + + // No provider returns info + $request->info1 = null; + $request->info2 = null; + $session = $manager->getSessionForRequest( $request ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( $idEmpty, $session->getId() ); + $this->assertNull( $manager->getPersistedSessionId( $request ) ); + + // Both providers return info, picks best one + $request->info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array( + 'provider' => $provider1, + 'id' => ( $id1 = $manager->generateSessionId() ), + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, array( + 'provider' => $provider2, + 'id' => ( $id2 = $manager->generateSessionId() ), + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $session = $manager->getSessionForRequest( $request ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( $id2, $session->getId() ); + $this->assertSame( $id2, $manager->getPersistedSessionId( $request ) ); + + $request->info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, array( + 'provider' => $provider1, + 'id' => ( $id1 = $manager->generateSessionId() ), + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array( + 'provider' => $provider2, + 'id' => ( $id2 = $manager->generateSessionId() ), + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $session = $manager->getSessionForRequest( $request ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( $id1, $session->getId() ); + $this->assertSame( $id1, $manager->getPersistedSessionId( $request ) ); + + // Tied priorities + $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, array( + 'provider' => $provider1, + 'id' => ( $id1 = $manager->generateSessionId() ), + 'persisted' => true, + 'userInfo' => UserInfo::newAnonymous(), + 'idIsSafe' => true, + ) ); + $request->info2 = new SessionInfo( SessionInfo::MAX_PRIORITY, array( + 'provider' => $provider2, + 'id' => ( $id2 = $manager->generateSessionId() ), + 'persisted' => true, + 'userInfo' => UserInfo::newAnonymous(), + 'idIsSafe' => true, + ) ); + try { + $manager->getSessionForRequest( $request ); + $this->fail( 'Expcected exception not thrown' ); + } catch ( \OverFlowException $ex ) { + $this->assertStringStartsWith( + 'Multiple sessions for this request tied for top priority: ', + $ex->getMessage() + ); + $this->assertCount( 2, $ex->sessionInfos ); + $this->assertContains( $request->info1, $ex->sessionInfos ); + $this->assertContains( $request->info2, $ex->sessionInfos ); + } + try { + $manager->getPersistedSessionId( $request ); + $this->fail( 'Expcected exception not thrown' ); + } catch ( \OverFlowException $ex ) { + $this->assertStringStartsWith( + 'Multiple sessions for this request tied for top priority: ', + $ex->getMessage() + ); + $this->assertCount( 2, $ex->sessionInfos ); + $this->assertContains( $request->info1, $ex->sessionInfos ); + $this->assertContains( $request->info2, $ex->sessionInfos ); + } + + // Bad provider + $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, array( + 'provider' => $provider2, + 'id' => ( $id1 = $manager->generateSessionId() ), + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $request->info2 = null; + try { + $manager->getSessionForRequest( $request ); + $this->fail( 'Expcected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'Provider1 returned session info for a different provider: ' . $request->info1, + $ex->getMessage() + ); + } + try { + $manager->getPersistedSessionId( $request ); + $this->fail( 'Expcected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'Provider1 returned session info for a different provider: ' . $request->info1, + $ex->getMessage() + ); + } + + // Unusable session info + $this->logger->setCollect( true ); + $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, array( + 'provider' => $provider1, + 'id' => ( $id1 = $manager->generateSessionId() ), + 'persisted' => true, + 'userInfo' => UserInfo::newFromName( 'UTSysop', false ), + 'idIsSafe' => true, + ) ); + $request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider2, + 'id' => ( $id2 = $manager->generateSessionId() ), + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $session = $manager->getSessionForRequest( $request ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( $id2, $session->getId() ); + $this->assertSame( $id2, $manager->getPersistedSessionId( $request ) ); + $this->logger->setCollect( false ); + + // Unpersisted session ID + $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, array( + 'provider' => $provider1, + 'id' => ( $id1 = $manager->generateSessionId() ), + 'persisted' => false, + 'userInfo' => UserInfo::newFromName( 'UTSysop', true ), + 'idIsSafe' => true, + ) ); + $request->info2 = null; + $session = $manager->getSessionForRequest( $request ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( $id1, $session->getId() ); + $session->persist(); + $this->assertTrue( $session->isPersistent(), 'sanity check' ); + $this->assertNull( $manager->getPersistedSessionId( $request ) ); + } + + public function testGetSessionById() { + $manager = $this->getManager(); + + try { + $manager->getSessionById( 'bad' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid session ID', $ex->getMessage() ); + } + + // Unknown session ID + $id = $manager->generateSessionId(); + $session = $manager->getSessionById( $id ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( $id, $session->getId() ); + + $id = $manager->generateSessionId(); + $this->assertNull( $manager->getSessionById( $id, true ) ); + + // Known but unloadable session ID + $this->logger->setCollect( true ); + $id = $manager->generateSessionId(); + $this->store->setRawSession( $id, array( 'metadata' => array( + 'provider' => 'DummySessionProvider', + 'userId' => 0, + 'userName' => null, + 'userToken' => null, + ) ) ); + + try { + $manager->getSessionById( $id ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'Can neither load the session nor create an empty session', + $ex->getMessage() + ); + } + + $this->assertNull( $manager->getSessionById( $id, true ) ); + $this->logger->setCollect( false ); + + // Known session ID + $this->store->setSession( $id, array() ); + $session = $manager->getSessionById( $id ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( $id, $session->getId() ); + } + + public function testGetEmptySession() { + $manager = $this->getManager(); + $pmanager = \TestingAccessWrapper::newFromObject( $manager ); + $request = new \FauxRequest(); + + $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( array( 'provideSessionInfo', 'newSessionInfo', '__toString' ) ); + + $expectId = null; + $info1 = null; + $info2 = null; + + $provider1 = $providerBuilder->getMock(); + $provider1->expects( $this->any() )->method( 'provideSessionInfo' ) + ->will( $this->returnValue( null ) ); + $provider1->expects( $this->any() )->method( 'newSessionInfo' ) + ->with( $this->callback( function ( $id ) use ( &$expectId ) { + return $id === $expectId; + } ) ) + ->will( $this->returnCallback( function () use ( &$info1 ) { + return $info1; + } ) ); + $provider1->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockProvider1' ) ); + + $provider2 = $providerBuilder->getMock(); + $provider2->expects( $this->any() )->method( 'provideSessionInfo' ) + ->will( $this->returnValue( null ) ); + $provider2->expects( $this->any() )->method( 'newSessionInfo' ) + ->with( $this->callback( function ( $id ) use ( &$expectId ) { + return $id === $expectId; + } ) ) + ->will( $this->returnCallback( function () use ( &$info2 ) { + return $info2; + } ) ); + $provider1->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockProvider2' ) ); + + $this->config->set( 'SessionProviders', array( + $this->objectCacheDef( $provider1 ), + $this->objectCacheDef( $provider2 ), + ) ); + + // No info + $expectId = null; + $info1 = null; + $info2 = null; + try { + $manager->getEmptySession(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'No provider could provide an empty session!', + $ex->getMessage() + ); + } + + // Info + $expectId = null; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider1, + 'id' => 'empty---------------------------', + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $info2 = null; + $session = $manager->getEmptySession(); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( 'empty---------------------------', $session->getId() ); + + // Info, explicitly + $expectId = 'expected------------------------'; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider1, + 'id' => $expectId, + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $info2 = null; + $session = $pmanager->getEmptySessionInternal( null, $expectId ); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( $expectId, $session->getId() ); + + // Wrong ID + $expectId = 'expected-----------------------2'; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider1, + 'id' => "un$expectId", + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $info2 = null; + try { + $pmanager->getEmptySessionInternal( null, $expectId ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'MockProvider1 returned empty session info with a wrong id: ' . + "un$expectId != $expectId", + $ex->getMessage() + ); + } + + // Unsafe ID + $expectId = 'expected-----------------------2'; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider1, + 'id' => $expectId, + 'persisted' => true, + ) ); + $info2 = null; + try { + $pmanager->getEmptySessionInternal( null, $expectId ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'MockProvider1 returned empty session info with id flagged unsafe', + $ex->getMessage() + ); + } + + // Wrong provider + $expectId = null; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider2, + 'id' => 'empty---------------------------', + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $info2 = null; + try { + $manager->getEmptySession(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'MockProvider1 returned an empty session info for a different provider: ' . $info1, + $ex->getMessage() + ); + } + + // Highest priority wins + $expectId = null; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array( + 'provider' => $provider1, + 'id' => 'empty1--------------------------', + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider2, + 'id' => 'empty2--------------------------', + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $session = $manager->getEmptySession(); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( 'empty1--------------------------', $session->getId() ); + + $expectId = null; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array( + 'provider' => $provider1, + 'id' => 'empty1--------------------------', + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, array( + 'provider' => $provider2, + 'id' => 'empty2--------------------------', + 'persisted' => true, + 'idIsSafe' => true, + ) ); + $session = $manager->getEmptySession(); + $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session ); + $this->assertSame( 'empty2--------------------------', $session->getId() ); + + // Tied priorities throw an exception + $expectId = null; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider1, + 'id' => 'empty1--------------------------', + 'persisted' => true, + 'userInfo' => UserInfo::newAnonymous(), + 'idIsSafe' => true, + ) ); + $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider2, + 'id' => 'empty2--------------------------', + 'persisted' => true, + 'userInfo' => UserInfo::newAnonymous(), + 'idIsSafe' => true, + ) ); + try { + $manager->getEmptySession(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertStringStartsWith( + 'Multiple empty sessions tied for top priority: ', + $ex->getMessage() + ); + } + + // Bad id + try { + $pmanager->getEmptySessionInternal( null, 'bad' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid session ID', $ex->getMessage() ); + } + + // Session already exists + $expectId = 'expected-----------------------3'; + $this->store->setSessionMeta( $expectId, array( + 'provider' => 'MockProvider2', + 'userId' => 0, + 'userName' => null, + 'userToken' => null, + ) ); + try { + $pmanager->getEmptySessionInternal( null, $expectId ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Session ID already exists', $ex->getMessage() ); + } + } + + public function testGetVaryHeaders() { + $manager = $this->getManager(); + + $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( array( 'getVaryHeaders', '__toString' ) ); + + $provider1 = $providerBuilder->getMock(); + $provider1->expects( $this->once() )->method( 'getVaryHeaders' ) + ->will( $this->returnValue( array( + 'Foo' => null, + 'Bar' => array( 'X', 'Bar1' ), + 'Quux' => null, + ) ) ); + $provider1->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockProvider1' ) ); + + $provider2 = $providerBuilder->getMock(); + $provider2->expects( $this->once() )->method( 'getVaryHeaders' ) + ->will( $this->returnValue( array( + 'Baz' => null, + 'Bar' => array( 'X', 'Bar2' ), + 'Quux' => array( 'Quux' ), + ) ) ); + $provider2->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockProvider2' ) ); + + $this->config->set( 'SessionProviders', array( + $this->objectCacheDef( $provider1 ), + $this->objectCacheDef( $provider2 ), + ) ); + + $expect = array( + 'Foo' => array(), + 'Bar' => array( 'X', 'Bar1', 3 => 'Bar2' ), + 'Quux' => array( 'Quux' ), + 'Baz' => array(), + 'Quux' => array( 'Quux' ), + ); + + $this->assertEquals( $expect, $manager->getVaryHeaders() ); + + // Again, to ensure it's cached + $this->assertEquals( $expect, $manager->getVaryHeaders() ); + } + + public function testGetVaryCookies() { + $manager = $this->getManager(); + + $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( array( 'getVaryCookies', '__toString' ) ); + + $provider1 = $providerBuilder->getMock(); + $provider1->expects( $this->once() )->method( 'getVaryCookies' ) + ->will( $this->returnValue( array( 'Foo', 'Bar' ) ) ); + $provider1->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockProvider1' ) ); + + $provider2 = $providerBuilder->getMock(); + $provider2->expects( $this->once() )->method( 'getVaryCookies' ) + ->will( $this->returnValue( array( 'Foo', 'Baz' ) ) ); + $provider2->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockProvider2' ) ); + + $this->config->set( 'SessionProviders', array( + $this->objectCacheDef( $provider1 ), + $this->objectCacheDef( $provider2 ), + ) ); + + $expect = array( 'Foo', 'Bar', 'Baz' ); + + $this->assertEquals( $expect, $manager->getVaryCookies() ); + + // Again, to ensure it's cached + $this->assertEquals( $expect, $manager->getVaryCookies() ); + } + + public function testGetProviders() { + $realManager = $this->getManager(); + $manager = \TestingAccessWrapper::newFromObject( $realManager ); + + $this->config->set( 'SessionProviders', array( + array( 'class' => 'DummySessionProvider' ), + ) ); + $providers = $manager->getProviders(); + $this->assertArrayHasKey( 'DummySessionProvider', $providers ); + $provider = \TestingAccessWrapper::newFromObject( $providers['DummySessionProvider'] ); + $this->assertSame( $manager->logger, $provider->logger ); + $this->assertSame( $manager->config, $provider->config ); + $this->assertSame( $realManager, $provider->getManager() ); + + $this->config->set( 'SessionProviders', array( + array( 'class' => 'DummySessionProvider' ), + array( 'class' => 'DummySessionProvider' ), + ) ); + $manager->sessionProviders = null; + try { + $manager->getProviders(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'Duplicate provider name "DummySessionProvider"', + $ex->getMessage() + ); + } + } + + public function testShutdown() { + $manager = \TestingAccessWrapper::newFromObject( $this->getManager() ); + $manager->setLogger( new \Psr\Log\NullLogger() ); + + $mock = $this->getMock( 'stdClass', array( 'save' ) ); + $mock->expects( $this->once() )->method( 'save' ); + + $manager->allSessionBackends = array( $mock ); + $manager->shutdown(); + } + + public function testGetSessionFromInfo() { + $manager = \TestingAccessWrapper::newFromObject( $this->getManager() ); + $request = new \FauxRequest(); + + $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $manager->getProvider( 'DummySessionProvider' ), + 'id' => $id, + 'persisted' => true, + 'userInfo' => UserInfo::newFromName( 'UTSysop', true ), + 'idIsSafe' => true, + ) ); + \TestingAccessWrapper::newFromObject( $info )->idIsSafe = true; + $session1 = \TestingAccessWrapper::newFromObject( + $manager->getSessionFromInfo( $info, $request ) + ); + $session2 = \TestingAccessWrapper::newFromObject( + $manager->getSessionFromInfo( $info, $request ) + ); + + $this->assertSame( $session1->backend, $session2->backend ); + $this->assertNotEquals( $session1->index, $session2->index ); + $this->assertSame( $session1->getSessionId(), $session2->getSessionId() ); + $this->assertSame( $id, $session1->getId() ); + + \TestingAccessWrapper::newFromObject( $info )->idIsSafe = false; + $session3 = $manager->getSessionFromInfo( $info, $request ); + $this->assertNotSame( $id, $session3->getId() ); + } + + public function testBackendRegistration() { + $manager = $this->getManager(); + + $session = $manager->getSessionForRequest( new \FauxRequest ); + $backend = \TestingAccessWrapper::newFromObject( $session )->backend; + $sessionId = $session->getSessionId(); + $id = (string)$sessionId; + + $this->assertSame( $sessionId, $manager->getSessionById( $id )->getSessionId() ); + + $manager->changeBackendId( $backend ); + $this->assertSame( $sessionId, $session->getSessionId() ); + $this->assertNotEquals( $id, (string)$sessionId ); + $id = (string)$sessionId; + + $this->assertSame( $sessionId, $manager->getSessionById( $id )->getSessionId() ); + + // Destruction of the session here causes the backend to be deregistered + $session = null; + + try { + $manager->changeBackendId( $backend ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'Backend was not registered with this SessionManager', $ex->getMessage() + ); + } + + try { + $manager->deregisterSessionBackend( $backend ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'Backend was not registered with this SessionManager', $ex->getMessage() + ); + } + + $session = $manager->getSessionById( $id ); + $this->assertSame( $sessionId, $session->getSessionId() ); + } + + public function testGenerateSessionId() { + $manager = $this->getManager(); + + $id = $manager->generateSessionId(); + $this->assertTrue( SessionManager::validateSessionId( $id ), "Generated ID: $id" ); + } + + public function testAutoCreateUser() { + global $wgGroupPermissions; + + $that = $this; + + \ObjectCache::$instances[__METHOD__] = new \HashBagOStuff(); + $this->setMwGlobals( array( 'wgMainCacheType' => __METHOD__ ) ); + + $this->stashMwGlobals( array( 'wgGroupPermissions' ) ); + $wgGroupPermissions['*']['createaccount'] = true; + $wgGroupPermissions['*']['autocreateaccount'] = false; + + // Replace the global singleton with one configured for testing + $manager = $this->getManager(); + $reset = TestUtils::setSessionManagerSingleton( $manager ); + + $logger = new \TestLogger( true, function ( $m ) { + if ( substr( $m, 0, 15 ) === 'SessionBackend ' ) { + // Don't care. + return null; + } + $m = str_replace( 'MediaWiki\Session\SessionManager::autoCreateUser: ', '', $m ); + $m = preg_replace( '/ - from: .*$/', ' - from: XXX', $m ); + return $m; + } ); + $manager->setLogger( $logger ); + + $session = SessionManager::getGlobalSession(); + + // Can't create an already-existing user + $user = User::newFromName( 'UTSysop' ); + $id = $user->getId(); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( $id, $user->getId() ); + $this->assertSame( 'UTSysop', $user->getName() ); + $this->assertSame( array(), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Sanity check that creation works at all + $user = User::newFromName( 'UTSessionAutoCreate1' ); + $this->assertSame( 0, $user->getId(), 'sanity check' ); + $this->assertTrue( $manager->autoCreateUser( $user ) ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertSame( 'UTSessionAutoCreate1', $user->getName() ); + $this->assertEquals( + $user->getId(), User::idFromName( 'UTSessionAutoCreate1', User::READ_LATEST ) + ); + $this->assertSame( array( + array( LogLevel::INFO, 'creating new user (UTSessionAutoCreate1) - from: XXX' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Check lack of permissions + $wgGroupPermissions['*']['createaccount'] = false; + $wgGroupPermissions['*']['autocreateaccount'] = false; + $user = User::newFromName( 'UTDoesNotExist' ); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + $session->clear(); + $this->assertSame( array( + array( LogLevel::DEBUG, 'user is blocked from this wiki, blacklisting' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Check other permission + $wgGroupPermissions['*']['createaccount'] = false; + $wgGroupPermissions['*']['autocreateaccount'] = true; + $user = User::newFromName( 'UTSessionAutoCreate2' ); + $this->assertSame( 0, $user->getId(), 'sanity check' ); + $this->assertTrue( $manager->autoCreateUser( $user ) ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertSame( 'UTSessionAutoCreate2', $user->getName() ); + $this->assertEquals( + $user->getId(), User::idFromName( 'UTSessionAutoCreate2', User::READ_LATEST ) + ); + $this->assertSame( array( + array( LogLevel::INFO, 'creating new user (UTSessionAutoCreate2) - from: XXX' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test account-creation block + $anon = new User; + $block = new \Block( array( + 'address' => $anon->getName(), + 'user' => $id, + 'reason' => __METHOD__, + 'expiry' => time() + 100500, + 'createAccount' => true, + ) ); + $block->insert(); + $this->assertInstanceOf( 'Block', $anon->isBlockedFromCreateAccount(), 'sanity check' ); + $reset2 = new \ScopedCallback( array( $block, 'delete' ) ); + $user = User::newFromName( 'UTDoesNotExist' ); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + \ScopedCallback::consume( $reset2 ); + $session->clear(); + $this->assertSame( array( + array( LogLevel::DEBUG, 'user is blocked from this wiki, blacklisting' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Sanity check that creation still works + $user = User::newFromName( 'UTSessionAutoCreate3' ); + $this->assertSame( 0, $user->getId(), 'sanity check' ); + $this->assertTrue( $manager->autoCreateUser( $user ) ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertSame( 'UTSessionAutoCreate3', $user->getName() ); + $this->assertEquals( + $user->getId(), User::idFromName( 'UTSessionAutoCreate3', User::READ_LATEST ) + ); + $this->assertSame( array( + array( LogLevel::INFO, 'creating new user (UTSessionAutoCreate3) - from: XXX' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test prevention by AuthPlugin + global $wgAuth; + $oldWgAuth = $wgAuth; + $mockWgAuth = $this->getMock( 'AuthPlugin', array( 'autoCreate' ) ); + $mockWgAuth->expects( $this->once() )->method( 'autoCreate' ) + ->will( $this->returnValue( false ) ); + $this->setMwGlobals( array( + 'wgAuth' => $mockWgAuth, + ) ); + $user = User::newFromName( 'UTDoesNotExist' ); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + $this->setMwGlobals( array( + 'wgAuth' => $oldWgAuth, + ) ); + $session->clear(); + $this->assertSame( array( + array( LogLevel::DEBUG, 'denied by AuthPlugin' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test prevention by wfReadOnly() + $this->setMwGlobals( array( + 'wgReadOnly' => 'Because', + ) ); + $user = User::newFromName( 'UTDoesNotExist' ); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + $this->setMwGlobals( array( + 'wgReadOnly' => false, + ) ); + $session->clear(); + $this->assertSame( array( + array( LogLevel::DEBUG, 'denied by wfReadOnly()' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test prevention by a previous session + $session->set( 'MWSession::AutoCreateBlacklist', 'test' ); + $user = User::newFromName( 'UTDoesNotExist' ); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + $session->clear(); + $this->assertSame( array( + array( LogLevel::DEBUG, 'blacklisted in session (test)' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test uncreatable name + $user = User::newFromName( 'UTDoesNotExist@' ); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist@', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + $session->clear(); + $this->assertSame( array( + array( LogLevel::DEBUG, 'Invalid username, blacklisting' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test AbortAutoAccount hook + $mock = $this->getMock( __CLASS__, array( 'onAbortAutoAccount' ) ); + $mock->expects( $this->once() )->method( 'onAbortAutoAccount' ) + ->will( $this->returnCallback( function ( User $user, &$msg ) { + $msg = 'No way!'; + return false; + } ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'AbortAutoAccount' => array( $mock ) ) ); + $user = User::newFromName( 'UTDoesNotExist' ); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'AbortAutoAccount' => array() ) ); + $session->clear(); + $this->assertSame( array( + array( LogLevel::DEBUG, 'denied by hook: No way!' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test AbortAutoAccount hook screwing up the name + $mock = $this->getMock( 'stdClass', array( 'onAbortAutoAccount' ) ); + $mock->expects( $this->once() )->method( 'onAbortAutoAccount' ) + ->will( $this->returnCallback( function ( User $user ) { + $user->setName( 'UTDoesNotExistEither' ); + } ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'AbortAutoAccount' => array( $mock ) ) ); + try { + $user = User::newFromName( 'UTDoesNotExist' ); + $manager->autoCreateUser( $user ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( + 'AbortAutoAccount hook tried to change the user name', + $ex->getMessage() + ); + } + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); + $this->assertNotSame( 'UTDoesNotExistEither', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExistEither', User::READ_LATEST ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'AbortAutoAccount' => array() ) ); + $session->clear(); + $this->assertSame( array(), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Test for "exception backoff" + $user = User::newFromName( 'UTDoesNotExist' ); + $cache = \ObjectCache::getLocalClusterInstance(); + $backoffKey = wfMemcKey( 'MWSession', 'autocreate-failed', md5( $user->getName() ) ); + $cache->set( $backoffKey, 1, 60 * 10 ); + $this->assertFalse( $manager->autoCreateUser( $user ) ); + $this->assertSame( 0, $user->getId() ); + $this->assertNotSame( 'UTDoesNotExist', $user->getName() ); + $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) ); + $cache->delete( $backoffKey ); + $session->clear(); + $this->assertSame( array( + array( LogLevel::DEBUG, 'denied by prior creation attempt failures' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Sanity check that creation still works, and test completion hook + $cb = $this->callback( function ( User $user ) use ( $that ) { + $that->assertNotEquals( 0, $user->getId() ); + $that->assertSame( 'UTSessionAutoCreate4', $user->getName() ); + $that->assertEquals( + $user->getId(), User::idFromName( 'UTSessionAutoCreate4', User::READ_LATEST ) + ); + return true; + } ); + $mock = $this->getMock( 'stdClass', + array( 'onAuthPluginAutoCreate', 'onLocalUserCreated' ) ); + $mock->expects( $this->once() )->method( 'onAuthPluginAutoCreate' ) + ->with( $cb ); + $mock->expects( $this->once() )->method( 'onLocalUserCreated' ) + ->with( $cb, $this->identicalTo( true ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( + 'AuthPluginAutoCreate' => array( $mock ), + 'LocalUserCreated' => array( $mock ), + ) ); + $user = User::newFromName( 'UTSessionAutoCreate4' ); + $this->assertSame( 0, $user->getId(), 'sanity check' ); + $this->assertTrue( $manager->autoCreateUser( $user ) ); + $this->assertNotEquals( 0, $user->getId() ); + $this->assertSame( 'UTSessionAutoCreate4', $user->getName() ); + $this->assertEquals( + $user->getId(), + User::idFromName( 'UTSessionAutoCreate4', User::READ_LATEST ) + ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( + 'AuthPluginAutoCreate' => array(), + 'LocalUserCreated' => array(), + ) ); + $this->assertSame( array( + array( LogLevel::INFO, 'creating new user (UTSessionAutoCreate4) - from: XXX' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + } + + public function onAbortAutoAccount( User $user, &$msg ) { + } + + public function testPreventSessionsForUser() { + $manager = $this->getManager(); + + $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' ) + ->setMethods( array( 'preventSessionsForUser', '__toString' ) ); + + $provider1 = $providerBuilder->getMock(); + $provider1->expects( $this->once() )->method( 'preventSessionsForUser' ) + ->with( $this->equalTo( 'UTSysop' ) ); + $provider1->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'MockProvider1' ) ); + + $this->config->set( 'SessionProviders', array( + $this->objectCacheDef( $provider1 ), + ) ); + + $user = User::newFromName( 'UTSysop' ); + $token = $user->getToken( true ); + + $this->assertFalse( $manager->isUserSessionPrevented( 'UTSysop' ) ); + $manager->preventSessionsForUser( 'UTSysop' ); + $this->assertNotEquals( $token, User::newFromName( 'UTSysop' )->getToken() ); + $this->assertTrue( $manager->isUserSessionPrevented( 'UTSysop' ) ); + } + + public function testLoadSessionInfoFromStore() { + $manager = $this->getManager(); + $logger = new \TestLogger( true, function ( $m ) { + return preg_replace( + '/^Session \[\d+\]\w+<(?:null|anon|[+-]:\d+:\w+)>\w+: /', 'Session X: ', $m + ); + } ); + $manager->setLogger( $logger ); + $request = new \FauxRequest(); + + // TestingAccessWrapper can't handle methods with reference arguments, sigh. + $rClass = new \ReflectionClass( $manager ); + $rMethod = $rClass->getMethod( 'loadSessionInfoFromStore' ); + $rMethod->setAccessible( true ); + $loadSessionInfoFromStore = function ( &$info ) use ( $rMethod, $manager, $request ) { + return $rMethod->invokeArgs( $manager, array( &$info, $request ) ); + }; + + $userInfo = UserInfo::newFromName( 'UTSysop', true ); + $unverifiedUserInfo = UserInfo::newFromName( 'UTSysop', false ); + + $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $metadata = array( + 'userId' => $userInfo->getId(), + 'userName' => $userInfo->getName(), + 'userToken' => $userInfo->getToken( true ), + 'provider' => 'Mock', + ); + + $builder = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' ) + ->setMethods( array( '__toString', 'mergeMetadata', 'refreshSessionInfo' ) ); + + $provider = $builder->getMockForAbstractClass(); + $provider->setManager( $manager ); + $provider->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( true ) ); + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( true ) ); + $provider->expects( $this->any() )->method( 'refreshSessionInfo' ) + ->will( $this->returnValue( true ) ); + $provider->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'Mock' ) ); + $provider->expects( $this->any() )->method( 'mergeMetadata' ) + ->will( $this->returnCallback( function ( $a, $b ) { + if ( $b === array( 'Throw' ) ) { + throw new \UnexpectedValueException( 'no merge!' ); + } + return array( 'Merged' ); + } ) ); + + $provider2 = $builder->getMockForAbstractClass(); + $provider2->setManager( $manager ); + $provider2->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( false ) ); + $provider2->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( false ) ); + $provider2->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'Mock2' ) ); + $provider2->expects( $this->any() )->method( 'refreshSessionInfo' ) + ->will( $this->returnCallback( function ( $info, $request, &$metadata ) { + $metadata['changed'] = true; + return true; + } ) ); + + $provider3 = $builder->getMockForAbstractClass(); + $provider3->setManager( $manager ); + $provider3->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( true ) ); + $provider3->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( true ) ); + $provider3->expects( $this->once() )->method( 'refreshSessionInfo' ) + ->will( $this->returnValue( false ) ); + $provider3->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'Mock3' ) ); + + \TestingAccessWrapper::newFromObject( $manager )->sessionProviders = array( + (string)$provider => $provider, + (string)$provider2 => $provider2, + (string)$provider3 => $provider3, + ); + + // No metadata, basic usage + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertFalse( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'userInfo' => $userInfo + ) ); + $this->assertTrue( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider2, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Unverified user, no metadata + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $unverifiedUserInfo + ) ); + $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Unverified user provided and no metadata to auth it' ) + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // No metadata, missing data + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Null provider and no metadata' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertInstanceOf( 'MediaWiki\\Session\\UserInfo', $info->getUserInfo() ); + $this->assertTrue( $info->getUserInfo()->isVerified() ); + $this->assertTrue( $info->getUserInfo()->isAnon() ); + $this->assertFalse( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider2, + 'id' => $id, + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::INFO, 'Session X: No user provided and provider cannot set user' ) + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Incomplete/bad metadata + $this->store->setRawSession( $id, true ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Bad data' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + $this->store->setRawSession( $id, array( 'data' => array() ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Bad data structure' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + $this->store->deleteSession( $id ); + $this->store->setRawSession( $id, array( 'metadata' => $metadata ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Bad data structure' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + $this->store->setRawSession( $id, array( 'metadata' => $metadata, 'data' => true ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Bad data structure' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + $this->store->setRawSession( $id, array( 'metadata' => true, 'data' => array() ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Bad data structure' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + foreach ( $metadata as $key => $dummy ) { + $tmp = $metadata; + unset( $tmp[$key] ); + $this->store->setRawSession( $id, array( 'metadata' => $tmp, 'data' => array() ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Bad metadata' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + } + + // Basic usage with metadata + $this->store->setRawSession( $id, array( 'metadata' => $metadata, 'data' => array() ) ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Mismatched provider + $this->store->setSessionMeta( $id, array( 'provider' => 'Bad' ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Wrong provider, Bad !== Mock' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Unknown provider + $this->store->setSessionMeta( $id, array( 'provider' => 'Bad' ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Unknown provider, Bad' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Fill in provider + $this->store->setSessionMeta( $id, $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Bad user metadata + $this->store->setSessionMeta( $id, array( 'userId' => -1, 'userToken' => null ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::ERROR, 'Session X: Invalid ID' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + $this->store->setSessionMeta( + $id, array( 'userId' => 0, 'userName' => '', 'userToken' => null ) + $metadata + ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::ERROR, 'Session X: Invalid user name' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Mismatched user by ID + $this->store->setSessionMeta( + $id, array( 'userId' => $userInfo->getId() + 1, 'userToken' => null ) + $metadata + ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: User ID mismatch, 2 !== 1' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Mismatched user by name + $this->store->setSessionMeta( + $id, array( 'userId' => 0, 'userName' => 'X', 'userToken' => null ) + $metadata + ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: User name mismatch, X !== UTSysop' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // ID matches, name doesn't + $this->store->setSessionMeta( + $id, array( 'userId' => $userInfo->getId(), 'userName' => 'X', 'userToken' => null ) + $metadata + ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( + LogLevel::WARNING, 'Session X: User ID matched but name didn\'t (rename?), X !== UTSysop' + ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Mismatched anon user + $this->store->setSessionMeta( + $id, array( 'userId' => 0, 'userName' => null, 'userToken' => null ) + $metadata + ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( + LogLevel::WARNING, 'Session X: Metadata has an anonymous user, but a non-anon user was provided' + ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Lookup user by ID + $this->store->setSessionMeta( $id, array( 'userToken' => null ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( $userInfo->getId(), $info->getUserInfo()->getId() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Lookup user by name + $this->store->setSessionMeta( + $id, array( 'userId' => 0, 'userName' => 'UTSysop', 'userToken' => null ) + $metadata + ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( $userInfo->getId(), $info->getUserInfo()->getId() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Lookup anonymous user + $this->store->setSessionMeta( + $id, array( 'userId' => 0, 'userName' => null, 'userToken' => null ) + $metadata + ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->getUserInfo()->isAnon() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Unverified user with metadata + $this->store->setSessionMeta( $id, $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $unverifiedUserInfo + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->getUserInfo()->isVerified() ); + $this->assertSame( $unverifiedUserInfo->getId(), $info->getUserInfo()->getId() ); + $this->assertSame( $unverifiedUserInfo->getName(), $info->getUserInfo()->getName() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Unverified user with metadata + $this->store->setSessionMeta( $id, $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $unverifiedUserInfo + ) ); + $this->assertFalse( $info->isIdSafe(), 'sanity check' ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->getUserInfo()->isVerified() ); + $this->assertSame( $unverifiedUserInfo->getId(), $info->getUserInfo()->getId() ); + $this->assertSame( $unverifiedUserInfo->getName(), $info->getUserInfo()->getName() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Wrong token + $this->store->setSessionMeta( $id, array( 'userToken' => 'Bad' ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: User token mismatch' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Provider metadata + $this->store->setSessionMeta( $id, array( 'provider' => 'Mock2' ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider2, + 'id' => $id, + 'userInfo' => $userInfo, + 'metadata' => array( 'Info' ), + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( 'Info', 'changed' => true ), $info->getProviderMetadata() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $this->store->setSessionMeta( $id, array( 'providerMetadata' => array( 'Saved' ) ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo, + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( 'Saved' ), $info->getProviderMetadata() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo, + 'metadata' => array( 'Info' ), + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( 'Merged' ), $info->getProviderMetadata() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo, + 'metadata' => array( 'Throw' ), + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Metadata merge failed: no merge!' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + + // Remember from session + $this->store->setSessionMeta( $id, $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $this->store->setSessionMeta( $id, array( 'remember' => true ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->wasRemembered() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $this->store->setSessionMeta( $id, array( 'remember' => false ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->wasRemembered() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // forceHTTPS from session + $this->store->setSessionMeta( $id, $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $this->store->setSessionMeta( $id, array( 'forceHTTPS' => true ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->forceHTTPS() ); + $this->assertSame( array(), $logger->getBuffer() ); + + $this->store->setSessionMeta( $id, array( 'forceHTTPS' => false ) + $metadata ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo, + 'forceHTTPS' => true + ) ); + $this->assertTrue( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $info->forceHTTPS() ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Provider refreshSessionInfo() returning false + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider3, + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertSame( array(), $logger->getBuffer() ); + + // Hook + $that = $this; + $called = false; + $data = array( 'foo' => 1 ); + $this->store->setSession( $id, array( 'metadata' => $metadata, 'data' => $data ) ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ) ); + $this->mergeMwGlobalArrayValue( 'wgHooks', array( + 'SessionCheckInfo' => array( function ( &$reason, $i, $r, $m, $d ) use ( + $that, $info, $metadata, $data, $request, &$called + ) { + $that->assertSame( $info->getId(), $i->getId() ); + $that->assertSame( $info->getProvider(), $i->getProvider() ); + $that->assertSame( $info->getUserInfo(), $i->getUserInfo() ); + $that->assertSame( $request, $r ); + $that->assertEquals( $metadata, $m ); + $that->assertEquals( $data, $d ); + $called = true; + return false; + } ) + ) ); + $this->assertFalse( $loadSessionInfoFromStore( $info ) ); + $this->assertTrue( $called ); + $this->assertSame( array( + array( LogLevel::WARNING, 'Session X: Hook aborted' ), + ), $logger->getBuffer() ); + $logger->clearBuffer(); + } + +} diff --git a/tests/phpunit/includes/session/SessionProviderTest.php b/tests/phpunit/includes/session/SessionProviderTest.php new file mode 100644 index 0000000000..9d816307cf --- /dev/null +++ b/tests/phpunit/includes/session/SessionProviderTest.php @@ -0,0 +1,177 @@ +getMockForAbstractClass( 'MediaWiki\\Session\\SessionProvider' ); + $priv = \TestingAccessWrapper::newFromObject( $provider ); + + $provider->setConfig( $config ); + $this->assertSame( $config, $priv->config ); + $provider->setLogger( $logger ); + $this->assertSame( $logger, $priv->logger ); + $provider->setManager( $manager ); + $this->assertSame( $manager, $priv->manager ); + $this->assertSame( $manager, $provider->getManager() ); + + $this->assertSame( array(), $provider->getVaryHeaders() ); + $this->assertSame( array(), $provider->getVaryCookies() ); + $this->assertSame( null, $provider->suggestLoginUsername( new \FauxRequest ) ); + + $this->assertSame( get_class( $provider ), (string)$provider ); + + $this->assertNull( $provider->whyNoSession() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'provider' => $provider, + ) ); + $metadata = array( 'foo' ); + $this->assertTrue( $provider->refreshSessionInfo( $info, new \FauxRequest, $metadata ) ); + $this->assertSame( array( 'foo' ), $metadata ); + } + + /** + * @dataProvider provideNewSessionInfo + * @param bool $persistId Return value for ->persistsSessionId() + * @param bool $persistUser Return value for ->persistsSessionUser() + * @param bool $ok Whether a SessionInfo is provided + */ + public function testNewSessionInfo( $persistId, $persistUser, $ok ) { + $manager = new SessionManager(); + + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' ) + ->setMethods( array( 'canChangeUser', 'persistsSessionId' ) ) + ->getMockForAbstractClass(); + $provider->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( $persistId ) ); + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( $persistUser ) ); + $provider->setManager( $manager ); + + if ( $ok ) { + $info = $provider->newSessionInfo(); + $this->assertNotNull( $info ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertTrue( $info->isIdSafe() ); + + $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $info = $provider->newSessionInfo( $id ); + $this->assertNotNull( $info ); + $this->assertSame( $id, $info->getId() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertTrue( $info->isIdSafe() ); + } else { + $this->assertNull( $provider->newSessionInfo() ); + } + } + + public function testMergeMetadata() { + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' ) + ->getMockForAbstractClass(); + + try { + $provider->mergeMetadata( + array( 'foo' => 1, 'baz' => 3 ), + array( 'bar' => 2, 'baz' => '3' ) + ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \UnexpectedValueException $ex ) { + $this->assertSame( 'Key "baz" changed', $ex->getMessage() ); + } + + $res = $provider->mergeMetadata( + array( 'foo' => 1, 'baz' => 3 ), + array( 'bar' => 2, 'baz' => 3 ) + ); + $this->assertSame( array( 'bar' => 2, 'baz' => 3 ), $res ); + } + + public static function provideNewSessionInfo() { + return array( + array( false, false, false ), + array( true, false, false ), + array( false, true, false ), + array( true, true, true ), + ); + } + + public function testImmutableSessions() { + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' ) + ->setMethods( array( 'canChangeUser', 'persistsSessionId' ) ) + ->getMockForAbstractClass(); + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( true ) ); + $provider->preventSessionsForUser( 'Foo' ); + + $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' ) + ->setMethods( array( 'canChangeUser', 'persistsSessionId' ) ) + ->getMockForAbstractClass(); + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( false ) ); + try { + $provider->preventSessionsForUser( 'Foo' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + } + + } + + public function testHashToSessionId() { + $config = new \HashConfig( array( + 'SecretKey' => 'Shhh!', + ) ); + + $provider = $this->getMockForAbstractClass( 'MediaWiki\\Session\\SessionProvider', + array(), 'MockSessionProvider' ); + $provider->setConfig( $config ); + $priv = \TestingAccessWrapper::newFromObject( $provider ); + + $this->assertSame( 'eoq8cb1mg7j30ui5qolafps4hg29k5bb', $priv->hashToSessionId( 'foobar' ) ); + $this->assertSame( '4do8j7tfld1g8tte9jqp3csfgmulaun9', + $priv->hashToSessionId( 'foobar', 'secret' ) ); + + try { + $priv->hashToSessionId( array() ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + '$data must be a string, array was passed', + $ex->getMessage() + ); + } + try { + $priv->hashToSessionId( '', false ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + '$key must be a string or null, boolean was passed', + $ex->getMessage() + ); + } + } + + public function testDescribe() { + $provider = $this->getMockForAbstractClass( 'MediaWiki\\Session\\SessionProvider', + array(), 'MockSessionProvider' ); + + $this->assertSame( + 'MockSessionProvider sessions', + $provider->describe( \Language::factory( 'en' ) ) + ); + } + +} diff --git a/tests/phpunit/includes/session/SessionTest.php b/tests/phpunit/includes/session/SessionTest.php new file mode 100644 index 0000000000..30d0267e8d --- /dev/null +++ b/tests/phpunit/includes/session/SessionTest.php @@ -0,0 +1,201 @@ +requests = array( -1 => 'dummy' ); + \TestingAccessWrapper::newFromObject( $backend )->id = new SessionId( 'abc' ); + + $session = new Session( $backend, 42 ); + $priv = \TestingAccessWrapper::newFromObject( $session ); + $this->assertSame( $backend, $priv->backend ); + $this->assertSame( 42, $priv->index ); + + $request = new \FauxRequest(); + $priv2 = \TestingAccessWrapper::newFromObject( $session->sessionWithRequest( $request ) ); + $this->assertSame( $backend, $priv2->backend ); + $this->assertNotSame( $priv->index, $priv2->index ); + $this->assertSame( $request, $priv2->getRequest() ); + } + + /** + * @dataProvider provideMethods + * @param string $m Method to test + * @param array $args Arguments to pass to the method + * @param bool $index Whether the backend method gets passed the index + * @param bool $ret Whether the method returns a value + */ + public function testMethods( $m, $args, $index, $ret ) { + $mock = $this->getMock( 'MediaWiki\\Session\\DummySessionBackend', + array( $m, 'deregisterSession' ) ); + $mock->expects( $this->once() )->method( 'deregisterSession' ) + ->with( $this->identicalTo( 42 ) ); + + $tmp = $mock->expects( $this->once() )->method( $m ); + $expectArgs = array(); + if ( $index ) { + $expectArgs[] = $this->identicalTo( 42 ); + } + foreach ( $args as $arg ) { + $expectArgs[] = $this->identicalTo( $arg ); + } + $tmp = call_user_func_array( array( $tmp, 'with' ), $expectArgs ); + + $retval = new \stdClass; + $tmp->will( $this->returnValue( $retval ) ); + + $session = TestUtils::getDummySession( $mock, 42 ); + + if ( $ret ) { + $this->assertSame( $retval, call_user_func_array( array( $session, $m ), $args ) ); + } else { + $this->assertNull( call_user_func_array( array( $session, $m ), $args ) ); + } + + // Trigger Session destructor + $session = null; + } + + public static function provideMethods() { + return array( + array( 'getId', array(), false, true ), + array( 'getSessionId', array(), false, true ), + array( 'resetId', array(), false, true ), + array( 'getProvider', array(), false, true ), + array( 'isPersistent', array(), false, true ), + array( 'persist', array(), false, false ), + array( 'shouldRememberUser', array(), false, true ), + array( 'setRememberUser', array( true ), false, false ), + array( 'getRequest', array(), true, true ), + array( 'getUser', array(), false, true ), + array( 'canSetUser', array(), false, true ), + array( 'setUser', array( new \stdClass ), false, false ), + array( 'suggestLoginUsername', array(), true, true ), + array( 'shouldForceHTTPS', array(), false, true ), + array( 'setForceHTTPS', array( true ), false, false ), + array( 'getLoggedOutTimestamp', array(), false, true ), + array( 'setLoggedOutTimestamp', array( 123 ), false, false ), + array( 'getProviderMetadata', array(), false, true ), + array( 'save', array(), false, false ), + array( 'delaySave', array(), false, true ), + array( 'renew', array(), false, false ), + ); + } + + public function testDataAccess() { + $session = TestUtils::getDummySession(); + $backend = \TestingAccessWrapper::newFromObject( $session )->backend; + + $this->assertEquals( 1, $session->get( 'foo' ) ); + $this->assertEquals( 'zero', $session->get( 0 ) ); + $this->assertFalse( $backend->dirty ); + + $this->assertEquals( null, $session->get( 'null' ) ); + $this->assertEquals( 'default', $session->get( 'null', 'default' ) ); + $this->assertFalse( $backend->dirty ); + + $session->set( 'foo', 55 ); + $this->assertEquals( 55, $backend->data['foo'] ); + $this->assertTrue( $backend->dirty ); + $backend->dirty = false; + + $session->set( 1, 'one' ); + $this->assertEquals( 'one', $backend->data[1] ); + $this->assertTrue( $backend->dirty ); + $backend->dirty = false; + + $session->set( 1, 'one' ); + $this->assertFalse( $backend->dirty ); + + $this->assertTrue( $session->exists( 'foo' ) ); + $this->assertTrue( $session->exists( 1 ) ); + $this->assertFalse( $session->exists( 'null' ) ); + $this->assertFalse( $session->exists( 100 ) ); + $this->assertFalse( $backend->dirty ); + + $session->remove( 'foo' ); + $this->assertArrayNotHasKey( 'foo', $backend->data ); + $this->assertTrue( $backend->dirty ); + $backend->dirty = false; + $session->remove( 1 ); + $this->assertArrayNotHasKey( 1, $backend->data ); + $this->assertTrue( $backend->dirty ); + $backend->dirty = false; + + $session->remove( 101 ); + $this->assertFalse( $backend->dirty ); + + $backend->data = array( 'a', 'b', '?' => 'c' ); + $this->assertSame( 3, $session->count() ); + $this->assertSame( 3, count( $session ) ); + $this->assertFalse( $backend->dirty ); + + $data = array(); + foreach ( $session as $key => $value ) { + $data[$key] = $value; + } + $this->assertEquals( $backend->data, $data ); + $this->assertFalse( $backend->dirty ); + + $this->assertEquals( $backend->data, iterator_to_array( $session ) ); + $this->assertFalse( $backend->dirty ); + } + + public function testClear() { + $session = TestUtils::getDummySession(); + $priv = \TestingAccessWrapper::newFromObject( $session ); + + $backend = $this->getMock( + 'MediaWiki\\Session\\DummySessionBackend', array( 'canSetUser', 'setUser', 'save' ) + ); + $backend->expects( $this->once() )->method( 'canSetUser' ) + ->will( $this->returnValue( true ) ); + $backend->expects( $this->once() )->method( 'setUser' ) + ->with( $this->callback( function ( $user ) { + return $user instanceof User && $user->isAnon(); + } ) ); + $backend->expects( $this->once() )->method( 'save' ); + $priv->backend = $backend; + $session->clear(); + $this->assertSame( array(), $backend->data ); + $this->assertTrue( $backend->dirty ); + + $backend = $this->getMock( + 'MediaWiki\\Session\\DummySessionBackend', array( 'canSetUser', 'setUser', 'save' ) + ); + $backend->data = array(); + $backend->expects( $this->once() )->method( 'canSetUser' ) + ->will( $this->returnValue( true ) ); + $backend->expects( $this->once() )->method( 'setUser' ) + ->with( $this->callback( function ( $user ) { + return $user instanceof User && $user->isAnon(); + } ) ); + $backend->expects( $this->once() )->method( 'save' ); + $priv->backend = $backend; + $session->clear(); + $this->assertFalse( $backend->dirty ); + + $backend = $this->getMock( + 'MediaWiki\\Session\\DummySessionBackend', array( 'canSetUser', 'setUser', 'save' ) + ); + $backend->expects( $this->once() )->method( 'canSetUser' ) + ->will( $this->returnValue( false ) ); + $backend->expects( $this->never() )->method( 'setUser' ); + $backend->expects( $this->once() )->method( 'save' ); + $priv->backend = $backend; + $session->clear(); + $this->assertSame( array(), $backend->data ); + $this->assertTrue( $backend->dirty ); + } + +} diff --git a/tests/phpunit/includes/session/TestBagOStuff.php b/tests/phpunit/includes/session/TestBagOStuff.php new file mode 100644 index 0000000000..e674e7bce0 --- /dev/null +++ b/tests/phpunit/includes/session/TestBagOStuff.php @@ -0,0 +1,78 @@ +setSession( $id, array( 'data' => $data ), $expiry, $user ); + } + + /** + * @param string $id Session ID + * @param array $metadata Session metadata + * @param int $expiry Expiry + */ + public function setSessionMeta( $id, array $metadata, $expiry = 0 ) { + $this->setSession( $id, array( 'metadata' => $metadata ), $expiry ); + } + + /** + * @param string $id Session ID + * @param array $blob Session metadata and data + * @param int $expiry Expiry + * @param User $user User for metadata + */ + public function setSession( $id, array $blob, $expiry = 0, User $user = null ) { + $blob += array( + 'data' => array(), + 'metadata' => array(), + ); + $blob['metadata'] += array( + 'userId' => $user ? $user->getId() : 0, + 'userName' => $user ? $user->getName() : null, + 'userToken' => $user ? $user->getToken( true ) : null, + 'provider' => 'DummySessionProvider', + ); + + $this->setRawSession( $id, $blob, $expiry, $user ); + } + + /** + * @param string $id Session ID + * @param array|mixed $blob Session metadata and data + * @param int $expiry Expiry + */ + public function setRawSession( $id, $blob, $expiry = 0 ) { + if ( $expiry <= 0 ) { + $expiry = \RequestContext::getMain()->getConfig()->get( 'ObjectCacheSessionExpiry' ); + } + + $this->set( wfMemcKey( 'MWSession', $id ), $blob, $expiry ); + } + + /** + * @param string $id Session ID + * @return mixed + */ + public function getSession( $id ) { + return $this->get( wfMemcKey( 'MWSession', $id ) ); + } + + /** + * @param string $id Session ID + */ + public function deleteSession( $id ) { + $this->delete( wfMemcKey( 'MWSession', $id ) ); + } + +} diff --git a/tests/phpunit/includes/session/TestUtils.php b/tests/phpunit/includes/session/TestUtils.php new file mode 100644 index 0000000000..16199837ee --- /dev/null +++ b/tests/phpunit/includes/session/TestUtils.php @@ -0,0 +1,99 @@ +setAccessible( true ); + $rGlobalSession = new \ReflectionProperty( + 'MediaWiki\\Session\\SessionManager', 'globalSession' + ); + $rGlobalSession->setAccessible( true ); + $rGlobalSessionRequest = new \ReflectionProperty( + 'MediaWiki\\Session\\SessionManager', 'globalSessionRequest' + ); + $rGlobalSessionRequest->setAccessible( true ); + + $oldInstance = $rInstance->getValue(); + + $reset = array( + array( $rInstance, $oldInstance ), + array( $rGlobalSession, $rGlobalSession->getValue() ), + array( $rGlobalSessionRequest, $rGlobalSessionRequest->getValue() ), + ); + + $rInstance->setValue( $manager ); + $rGlobalSession->setValue( null ); + $rGlobalSessionRequest->setValue( null ); + if ( $manager && PHPSessionHandler::isInstalled() ) { + PHPSessionHandler::install( $manager ); + } + + return new \ScopedCallback( function () use ( &$reset, $oldInstance ) { + foreach ( $reset as &$arr ) { + $arr[0]->setValue( $arr[1] ); + } + if ( $oldInstance && PHPSessionHandler::isInstalled() ) { + PHPSessionHandler::install( $oldInstance ); + } + } ); + } + + /** + * If you need a SessionBackend for testing but don't want to create a real + * one, use this. + * @return SessionBackend Unconfigured! Use reflection to set any private + * fields necessary. + */ + public static function getDummySessionBackend() { + $rc = new \ReflectionClass( 'MediaWiki\\Session\\SessionBackend' ); + if ( !method_exists( $rc, 'newInstanceWithoutConstructor' ) ) { + \PHPUnit_Framework_Assert::markTestSkipped( + 'ReflectionClass::newInstanceWithoutConstructor isn\'t available' + ); + } + + return $rc->newInstanceWithoutConstructor(); + } + + /** + * If you need a Session for testing but don't want to create a backend to + * construct one, use this. + * @param object $backend Object to serve as the SessionBackend + * @param int $index Index + * @return Session + */ + public static function getDummySession( $backend = null, $index = -1 ) { + $rc = new \ReflectionClass( 'MediaWiki\\Session\\Session' ); + if ( !method_exists( $rc, 'newInstanceWithoutConstructor' ) ) { + \PHPUnit_Framework_Assert::markTestSkipped( + 'ReflectionClass::newInstanceWithoutConstructor isn\'t available' + ); + } + + if ( $backend === null ) { + $backend = new DummySessionBackend; + } + + $session = $rc->newInstanceWithoutConstructor(); + $priv = \TestingAccessWrapper::newFromObject( $session ); + $priv->backend = $backend; + $priv->index = $index; + return $session; + } + +} diff --git a/tests/phpunit/includes/session/UserInfoTest.php b/tests/phpunit/includes/session/UserInfoTest.php new file mode 100644 index 0000000000..121bb72a77 --- /dev/null +++ b/tests/phpunit/includes/session/UserInfoTest.php @@ -0,0 +1,186 @@ +assertTrue( $userinfo->isAnon() ); + $this->assertTrue( $userinfo->isVerified() ); + $this->assertSame( 0, $userinfo->getId() ); + $this->assertSame( null, $userinfo->getName() ); + $this->assertSame( null, $userinfo->getToken() ); + $this->assertNotNull( $userinfo->getUser() ); + $this->assertSame( $userinfo, $userinfo->verified() ); + $this->assertSame( '', (string)$userinfo ); + } + + public function testNewFromId() { + $id = wfGetDB( DB_MASTER )->selectField( 'user', 'MAX(user_id)' ) + 1; + try { + UserInfo::newFromId( $id ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid ID', $ex->getMessage() ); + } + + $user = User::newFromName( 'UTSysop' ); + $userinfo = UserInfo::newFromId( $user->getId() ); + $this->assertFalse( $userinfo->isAnon() ); + $this->assertFalse( $userinfo->isVerified() ); + $this->assertSame( $user->getId(), $userinfo->getId() ); + $this->assertSame( $user->getName(), $userinfo->getName() ); + $this->assertSame( $user->getToken( true ), $userinfo->getToken() ); + $this->assertInstanceOf( 'User', $userinfo->getUser() ); + $userinfo2 = $userinfo->verified(); + $this->assertNotSame( $userinfo2, $userinfo ); + $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo ); + + $this->assertFalse( $userinfo2->isAnon() ); + $this->assertTrue( $userinfo2->isVerified() ); + $this->assertSame( $user->getId(), $userinfo2->getId() ); + $this->assertSame( $user->getName(), $userinfo2->getName() ); + $this->assertSame( $user->getToken( true ), $userinfo2->getToken() ); + $this->assertInstanceOf( 'User', $userinfo2->getUser() ); + $this->assertSame( $userinfo2, $userinfo2->verified() ); + $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 ); + + $userinfo = UserInfo::newFromId( $user->getId(), true ); + $this->assertTrue( $userinfo->isVerified() ); + $this->assertSame( $userinfo, $userinfo->verified() ); + } + + public function testNewFromName() { + try { + UserInfo::newFromName( '' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid user name', $ex->getMessage() ); + } + + // User name that exists + $user = User::newFromName( 'UTSysop' ); + $userinfo = UserInfo::newFromName( $user->getName() ); + $this->assertFalse( $userinfo->isAnon() ); + $this->assertFalse( $userinfo->isVerified() ); + $this->assertSame( $user->getId(), $userinfo->getId() ); + $this->assertSame( $user->getName(), $userinfo->getName() ); + $this->assertSame( $user->getToken( true ), $userinfo->getToken() ); + $this->assertInstanceOf( 'User', $userinfo->getUser() ); + $userinfo2 = $userinfo->verified(); + $this->assertNotSame( $userinfo2, $userinfo ); + $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo ); + + $this->assertFalse( $userinfo2->isAnon() ); + $this->assertTrue( $userinfo2->isVerified() ); + $this->assertSame( $user->getId(), $userinfo2->getId() ); + $this->assertSame( $user->getName(), $userinfo2->getName() ); + $this->assertSame( $user->getToken( true ), $userinfo2->getToken() ); + $this->assertInstanceOf( 'User', $userinfo2->getUser() ); + $this->assertSame( $userinfo2, $userinfo2->verified() ); + $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 ); + + $userinfo = UserInfo::newFromName( $user->getName(), true ); + $this->assertTrue( $userinfo->isVerified() ); + $this->assertSame( $userinfo, $userinfo->verified() ); + + // User name that does not exist should still be non-anon + $user = User::newFromName( 'DoesNotExist' ); + $this->assertSame( 0, $user->getId(), 'sanity check' ); + $userinfo = UserInfo::newFromName( $user->getName() ); + $this->assertFalse( $userinfo->isAnon() ); + $this->assertFalse( $userinfo->isVerified() ); + $this->assertSame( $user->getId(), $userinfo->getId() ); + $this->assertSame( $user->getName(), $userinfo->getName() ); + $this->assertSame( null, $userinfo->getToken() ); + $this->assertInstanceOf( 'User', $userinfo->getUser() ); + $userinfo2 = $userinfo->verified(); + $this->assertNotSame( $userinfo2, $userinfo ); + $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo ); + + $this->assertFalse( $userinfo2->isAnon() ); + $this->assertTrue( $userinfo2->isVerified() ); + $this->assertSame( $user->getId(), $userinfo2->getId() ); + $this->assertSame( $user->getName(), $userinfo2->getName() ); + $this->assertSame( null, $userinfo2->getToken() ); + $this->assertInstanceOf( 'User', $userinfo2->getUser() ); + $this->assertSame( $userinfo2, $userinfo2->verified() ); + $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 ); + + $userinfo = UserInfo::newFromName( $user->getName(), true ); + $this->assertTrue( $userinfo->isVerified() ); + $this->assertSame( $userinfo, $userinfo->verified() ); + } + + public function testNewFromUser() { + // User that exists + $user = User::newFromName( 'UTSysop' ); + $userinfo = UserInfo::newFromUser( $user ); + $this->assertFalse( $userinfo->isAnon() ); + $this->assertFalse( $userinfo->isVerified() ); + $this->assertSame( $user->getId(), $userinfo->getId() ); + $this->assertSame( $user->getName(), $userinfo->getName() ); + $this->assertSame( $user->getToken( true ), $userinfo->getToken() ); + $this->assertSame( $user, $userinfo->getUser() ); + $userinfo2 = $userinfo->verified(); + $this->assertNotSame( $userinfo2, $userinfo ); + $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo ); + + $this->assertFalse( $userinfo2->isAnon() ); + $this->assertTrue( $userinfo2->isVerified() ); + $this->assertSame( $user->getId(), $userinfo2->getId() ); + $this->assertSame( $user->getName(), $userinfo2->getName() ); + $this->assertSame( $user->getToken( true ), $userinfo2->getToken() ); + $this->assertSame( $user, $userinfo2->getUser() ); + $this->assertSame( $userinfo2, $userinfo2->verified() ); + $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 ); + + $userinfo = UserInfo::newFromUser( $user, true ); + $this->assertTrue( $userinfo->isVerified() ); + $this->assertSame( $userinfo, $userinfo->verified() ); + + // User name that does not exist should still be non-anon + $user = User::newFromName( 'DoesNotExist' ); + $this->assertSame( 0, $user->getId(), 'sanity check' ); + $userinfo = UserInfo::newFromUser( $user ); + $this->assertFalse( $userinfo->isAnon() ); + $this->assertFalse( $userinfo->isVerified() ); + $this->assertSame( $user->getId(), $userinfo->getId() ); + $this->assertSame( $user->getName(), $userinfo->getName() ); + $this->assertSame( null, $userinfo->getToken() ); + $this->assertSame( $user, $userinfo->getUser() ); + $userinfo2 = $userinfo->verified(); + $this->assertNotSame( $userinfo2, $userinfo ); + $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo ); + + $this->assertFalse( $userinfo2->isAnon() ); + $this->assertTrue( $userinfo2->isVerified() ); + $this->assertSame( $user->getId(), $userinfo2->getId() ); + $this->assertSame( $user->getName(), $userinfo2->getName() ); + $this->assertSame( null, $userinfo2->getToken() ); + $this->assertSame( $user, $userinfo2->getUser() ); + $this->assertSame( $userinfo2, $userinfo2->verified() ); + $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 ); + + $userinfo = UserInfo::newFromUser( $user, true ); + $this->assertTrue( $userinfo->isVerified() ); + $this->assertSame( $userinfo, $userinfo->verified() ); + + // Anonymous user gives anon + $userinfo = UserInfo::newFromUser( new User, false ); + $this->assertTrue( $userinfo->isVerified() ); + $this->assertSame( 0, $userinfo->getId() ); + $this->assertSame( null, $userinfo->getName() ); + } + +} diff --git a/tests/phpunit/includes/upload/UploadFromUrlTest.php b/tests/phpunit/includes/upload/UploadFromUrlTest.php index b749662962..428fd27629 100644 --- a/tests/phpunit/includes/upload/UploadFromUrlTest.php +++ b/tests/phpunit/includes/upload/UploadFromUrlTest.php @@ -16,7 +16,6 @@ class UploadFromUrlTest extends ApiTestCase { 'wgAllowCopyUploads' => true, 'wgAllowAsyncCopyUploads' => true, ) ); - wfSetupSession(); if ( wfLocalFile( 'UploadFromUrlTest.png' )->exists() ) { $this->deleteFile( 'UploadFromUrlTest.png' ); @@ -26,15 +25,12 @@ class UploadFromUrlTest extends ApiTestCase { protected function doApiRequest( array $params, array $unused = null, $appendModule = false, User $user = null ) { - $sessionId = session_id(); - session_write_close(); + global $wgRequest; - $req = new FauxRequest( $params, true, $_SESSION ); + $req = new FauxRequest( $params, true, $wgRequest->getSession() ); $module = new ApiMain( $req, true ); $module->execute(); - wfSetupSession( $sessionId ); - return array( $module->getResult()->getResultData( null, array( 'Strip' => 'all' ) ), $req diff --git a/tests/phpunit/includes/user/UserTest.php b/tests/phpunit/includes/user/UserTest.php index 45c4b8c5fa..aadc5c9f7c 100644 --- a/tests/phpunit/includes/user/UserTest.php +++ b/tests/phpunit/includes/user/UserTest.php @@ -446,89 +446,4 @@ class UserTest extends MediaWikiTestCase { $this->assertGreaterThan( $touched, $user->getDBTouched(), "user_touched increased with casOnTouched() #2" ); } - - public static function setExtendedLoginCookieDataProvider() { - $data = array(); - $now = time(); - - $secondsInDay = 86400; - - // Arbitrary durations, in units of days, to ensure it chooses the - // right one. There is a 5-minute grace period (see testSetExtendedLoginCookie) - // to work around slow tests, since we're not currently mocking time() for PHP. - - $durationOne = $secondsInDay * 5; - $durationTwo = $secondsInDay * 29; - $durationThree = $secondsInDay * 17; - - // If $wgExtendedLoginCookieExpiration is null, then the expiry passed to - // set cookie is time() + $wgCookieExpiration - $data[] = array( - null, - $durationOne, - $now + $durationOne, - ); - - // If $wgExtendedLoginCookieExpiration isn't null, then the expiry passed to - // set cookie is $now + $wgExtendedLoginCookieExpiration - $data[] = array( - $durationTwo, - $durationThree, - $now + $durationTwo, - ); - - return $data; - } - - /** - * @dataProvider setExtendedLoginCookieDataProvider - * @covers User::getRequest - * @covers User::setCookie - * @backupGlobals enabled - */ - public function testSetExtendedLoginCookie( - $extendedLoginCookieExpiration, - $cookieExpiration, - $expectedExpiry - ) { - $this->setMwGlobals( array( - 'wgExtendedLoginCookieExpiration' => $extendedLoginCookieExpiration, - 'wgCookieExpiration' => $cookieExpiration, - ) ); - - $response = $this->getMock( 'WebResponse' ); - $setcookieSpy = $this->any(); - $response->expects( $setcookieSpy ) - ->method( 'setcookie' ); - - $request = new MockWebRequest( $response ); - $user = new UserProxy( User::newFromSession( $request ) ); - $user->setExtendedLoginCookie( 'name', 'value', true ); - - $setcookieInvocations = $setcookieSpy->getInvocations(); - $setcookieInvocation = end( $setcookieInvocations ); - $actualExpiry = $setcookieInvocation->parameters[2]; - - // TODO: ± 600 seconds compensates for - // slow-running tests. However, the dependency on the time - // function should be removed. This requires some way - // to mock/isolate User->setExtendedLoginCookie's call to time() - $this->assertEquals( $expectedExpiry, $actualExpiry, '', 600 ); - } -} - -class UserProxy extends User { - - /** - * @var User - */ - protected $user; - - public function __construct( User $user ) { - $this->user = $user; - } - - public function setExtendedLoginCookie( $name, $value, $secure ) { - $this->user->setExtendedLoginCookie( $name, $value, $secure ); - } } diff --git a/tests/phpunit/mocks/session/DummySessionBackend.php b/tests/phpunit/mocks/session/DummySessionBackend.php new file mode 100644 index 0000000000..f96e61c480 --- /dev/null +++ b/tests/phpunit/mocks/session/DummySessionBackend.php @@ -0,0 +1,29 @@ + 1, + 'bar' => 2, + 0 => 'zero', + ); + public $dirty = false; + + public function &getData() { + return $this->data; + } + + public function dirty() { + $this->dirty = true; + } + + public function deregisterSession( $index ) { + } +} diff --git a/tests/phpunit/mocks/session/DummySessionProvider.php b/tests/phpunit/mocks/session/DummySessionProvider.php new file mode 100644 index 0000000000..446819198c --- /dev/null +++ b/tests/phpunit/mocks/session/DummySessionProvider.php @@ -0,0 +1,60 @@ + $this, + 'id' => self::ID, + 'persisted' => true, + 'userInfo' => UserInfo::newAnonymous(), + ) ); + } + + public function newSessionInfo( $id = null ) { + return new SessionInfo( SessionInfo::MIN_PRIORITY, array( + 'id' => $id, + 'idIsSafe' => true, + 'provider' => $this, + 'persisted' => false, + 'userInfo' => UserInfo::newAnonymous(), + ) ); + } + + public function persistsSessionId() { + return true; + } + + public function canChangeUser() { + return $this->persistsSessionId(); + } + + public function persistSession( SessionBackend $session, WebRequest $request ) { + } + + public function unpersistSession( WebRequest $request ) { + } + + public function immutableSessionCouldExistForUser( $user ) { + return false; + } + + public function preventImmutableSessionsForUser( $user ) { + } + + public function suggestLoginUsername( WebRequest $request ) { + return $request->getCookie( 'UserName' ); + } + +} diff --git a/tests/phpunit/phpunit.php b/tests/phpunit/phpunit.php index 09dc931df9..2a08047072 100755 --- a/tests/phpunit/phpunit.php +++ b/tests/phpunit/phpunit.php @@ -73,6 +73,7 @@ class PHPUnitMaintClass extends Maintenance { global $wgLanguageConverterCacheType, $wgUseDatabaseMessages; global $wgLocaltimezone, $wgLocalisationCacheConf; global $wgDevelopmentWarnings; + global $wgSessionProviders; // Inject test autoloader require_once __DIR__ . '/../TestsAutoLoader.php'; @@ -103,6 +104,19 @@ class PHPUnitMaintClass extends Maintenance { $wgLocalisationCacheConf['storeClass'] = 'LCStoreNull'; + // Generic MediaWiki\Session\SessionManager configuration for tests + // We use CookieSessionProvider because things might be expecting + // cookies to show up in a FauxRequest somewhere. + $wgSessionProviders = array( + array( + 'class' => 'MediaWiki\\Session\\CookieSessionProvider', + 'args' => array( array( + 'priority' => 30, + 'callUserSetCookiesHook' => true, + ) ), + ), + ); + // Bug 44192 Do not attempt to send a real e-mail Hooks::clear( 'AlternateUserMailer' ); Hooks::register( -- 2.20.1