Merge "Introduce User::INVALID_TOKEN"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Mon, 8 Feb 2016 00:55:04 +0000 (00:55 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Mon, 8 Feb 2016 00:55:04 +0000 (00:55 +0000)
1  2 
includes/session/SessionManager.php
includes/user/User.php
tests/phpunit/includes/session/SessionManagerTest.php

@@@ -25,7 -25,6 +25,7 @@@ namespace MediaWiki\Session
  
  use Psr\Log\LoggerInterface;
  use BagOStuff;
 +use CachedBagOStuff;
  use Config;
  use FauxRequest;
  use Language;
@@@ -55,8 -54,11 +55,8 @@@ final class SessionManager implements S
        /** @var Config */
        private $config;
  
 -      /** @var BagOStuff|null */
 -      private $tempStore;
 -
 -      /** @var BagOStuff|null */
 -      private $permStore;
 +      /** @var CachedBagOStuff|null */
 +      private $store;
  
        /** @var SessionProvider[] */
        private $sessionProviders = null;
                        $this->setLogger( \MediaWiki\Logger\LoggerFactory::getInstance( 'session' ) );
                }
  
 -              $this->tempStore = new \HashBagOStuff;
                if ( isset( $options['store'] ) ) {
                        if ( !$options['store'] instanceof BagOStuff ) {
                                throw new \InvalidArgumentException(
                                        '$options[\'store\'] must be an instance of BagOStuff'
                                );
                        }
 -                      $this->permStore = $options['store'];
 +                      $store = $options['store'];
                } else {
 -                      $this->permStore = \ObjectCache::getInstance( $this->config->get( 'SessionCacheType' ) );
 -                      $this->permStore->setLogger( $this->logger );
 +                      $store = \ObjectCache::getInstance( $this->config->get( 'SessionCacheType' ) );
 +                      $store->setLogger( $this->logger );
                }
 +              $this->store = $store instanceof CachedBagOStuff ? $store : new CachedBagOStuff( $store );
  
                register_shutdown_function( array( $this, 'shutdown' ) );
        }
                // Test this here to provide a better log message for the common case
                // of "no such ID"
                $key = wfMemcKey( 'MWSession', $id );
 -              $existing = $this->tempStore->get( $key );
 -              if ( $existing === false ) {
 -                      $existing = $this->permStore->get( $key );
 -                      if ( $existing !== false ) {
 -                              $this->tempStore->set( $key, $existing );
 -                      }
 -              }
 -              if ( is_array( $existing ) ) {
 +              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 );
                        }
  
                        $key = wfMemcKey( 'MWSession', $id );
 -                      $existing = $this->tempStore->get( $key );
 -                      if ( $existing === false ) {
 -                              $existing = $this->permStore->get( $key );
 -                              if ( $existing !== false ) {
 -                                      $this->tempStore->set( $key, $existing );
 -                              }
 -                      }
 -                      if ( is_array( $existing ) ) {
 +                      if ( is_array( $this->store->get( $key ) ) ) {
                                throw new \InvalidArgumentException( 'Session ID already exists' );
                        }
                }
        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( false ) ) {
-                       $user->setToken();
-                       $user->saveSettings();
-               }
                // Instruct the session providers to kill any other sessions too.
                foreach ( $this->getProviders() as $provider ) {
                        $provider->preventSessionsForUser( $username );
         */
        private function loadSessionInfoFromStore( SessionInfo &$info, WebRequest $request ) {
                $key = wfMemcKey( 'MWSession', $info->getId() );
 -              $blob = $this->tempStore->get( $key );
 -              if ( $blob === false ) {
 -                      $blob = $this->permStore->get( $key );
 -                      if ( $blob !== false ) {
 -                              $this->tempStore->set( $key, $blob );
 -                      }
 -              }
 +              $blob = $this->store->get( $key );
  
                $newParams = array();
  
                        // Sanity check: blob must be an array, if it's saved at all
                        if ( !is_array( $blob ) ) {
                                $this->logger->warning( "Session $info: Bad data" );
 -                              $this->tempStore->delete( $key );
 -                              $this->permStore->delete( $key );
 +                              $this->store->delete( $key );
                                return false;
                        }
  
                                !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] )
                        ) {
                                $this->logger->warning( "Session $info: Bad data structure" );
 -                              $this->tempStore->delete( $key );
 -                              $this->permStore->delete( $key );
 +                              $this->store->delete( $key );
                                return false;
                        }
  
                                !array_key_exists( 'provider', $metadata )
                        ) {
                                $this->logger->warning( "Session $info: Bad metadata" );
 -                              $this->tempStore->delete( $key );
 -                              $this->permStore->delete( $key );
 +                              $this->store->delete( $key );
                                return false;
                        }
  
                                $newParams['provider'] = $provider = $this->getProvider( $metadata['provider'] );
                                if ( !$provider ) {
                                        $this->logger->warning( "Session $info: Unknown provider, " . $metadata['provider'] );
 -                                      $this->tempStore->delete( $key );
 -                                      $this->permStore->delete( $key );
 +                                      $this->store->delete( $key );
                                        return false;
                                }
                        } elseif ( $metadata['provider'] !== (string)$provider ) {
                        $backend = new SessionBackend(
                                $this->allSessionIds[$id],
                                $info,
 -                              $this->tempStore,
 -                              $this->permStore,
 +                              $this->store,
                                $this->logger,
                                $this->config->get( 'ObjectCacheSessionExpiry' )
                        );
                do {
                        $id = wfBaseConvert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
                        $key = wfMemcKey( 'MWSession', $id );
 -              } while ( isset( $this->allSessionIds[$id] ) ||
 -                      is_array( $this->tempStore->get( $key ) ) || is_array( $this->permStore->get( $key ) )
 -              );
 +              } while ( isset( $this->allSessionIds[$id] ) || is_array( $this->store->get( $key ) ) );
                return $id;
        }
  
         * @param PHPSessionHandler $handler
         */
        public function setupPHPSessionHandler( PHPSessionHandler $handler ) {
 -              $handler->setManager( $this, $this->permStore, $this->logger );
 +              $handler->setManager( $this, $this->store, $this->logger );
        }
  
        /**
diff --combined includes/user/User.php
@@@ -45,6 -45,11 +45,11 @@@ class User implements IDBAccessObject 
         */
        const TOKEN_LENGTH = 32;
  
+       /**
+        * @const string An invalid value for user_token
+        */
+       const INVALID_TOKEN = '*** INVALID ***';
        /**
         * Global constant made accessible as class constants so that autoloader
         * magic can be used.
                return $this->getName();
        }
  
 +      /**
 +       * Test if it's safe to load this User object. You should typically check this before using
 +       * $wgUser or RequestContext::getUser in a method that might be called before the system has
 +       * been fully initialized. If the object is unsafe, you should use an anonymous user:
 +       * \code
 +       * $user = $wgUser->isSafeToLoad() ? $wgUser : new User;
 +       * \endcode
 +       *
 +       * @since 1.27
 +       * @return bool
 +       */
 +      public function isSafeToLoad() {
 +              global $wgFullyInitialised;
 +              return $wgFullyInitialised || $this->mLoadedItems === true || $this->mFrom !== 'session';
 +      }
 +
        /**
         * Load the user table data for this object from the source given by mFrom.
         *
                $this->queryFlagsUsed = $flags;
  
                // If this is called too early, things are likely to break.
 -              if ( $this->mFrom === 'session' && empty( $wgFullyInitialised ) ) {
 +              if ( !$wgFullyInitialised && $this->mFrom === 'session' ) {
                        \MediaWiki\Logger\LoggerFactory::getInstance( 'session' )
                                ->warning( 'User::loadFromSession called before the end of Setup.php', array(
                                        'exception' => new Exception( 'User::loadFromSession called before the end of Setup.php' ),
                $user = self::newFromRow( $row );
  
                // A user is considered to exist as a non-system user if it has a
-               // password set, or a temporary password set, or an email set.
+               // password set, or a temporary password set, or an email set, or a
+               // non-invalid token.
                $passwordFactory = new PasswordFactory();
                $passwordFactory->init( RequestContext::getMain()->getConfig() );
                try {
                        $newpassword = PasswordFactory::newInvalidPassword();
                }
                if ( !$password instanceof InvalidPassword || !$newpassword instanceof InvalidPassword
-                       || $user->mEmail
+                       || $user->mEmail || $user->mToken !== self::INVALID_TOKEN
                ) {
                        // User exists. Steal it?
                        if ( !$options['steal'] ) {
                                __METHOD__
                        );
                        $user->invalidateEmail();
+                       $user->mToken = self::INVALID_TOKEN;
                        $user->saveSettings();
+                       SessionManager::singleton()->preventSessionsForUser( $user->getName() );
                }
  
-               SessionManager::singleton()->preventSessionsForUser( $user->getName() );
                return $user;
        }
  
                # We only need to worry about passing the IP address to the Block generator if the
                # user is not immune to autoblocks/hardblocks, and they are the current user so we
                # know which IP address they're actually coming from
 -              if ( !$this->isAllowed( 'ipblock-exempt' ) && $this->equals( $wgUser ) ) {
 -                      $ip = $this->getRequest()->getIP();
 -              } else {
 -                      $ip = null;
 +              $ip = null;
 +              if ( !$this->isAllowed( 'ipblock-exempt' ) ) {
 +                      // $wgUser->getName() only works after the end of Setup.php. Until
 +                      // then, assume it's a logged-out user.
 +                      $globalUserName = $wgUser->isSafeToLoad()
 +                              ? $wgUser->getName()
 +                              : IP::sanitizeIP( $wgUser->getRequest()->getIP() );
 +                      if ( $this->getName() === $globalUserName ) {
 +                              $ip = $this->getRequest()->getIP();
 +                      }
                }
  
                // User/IP blocking
                $keys = array();
                $id = $this->getId();
                $userLimit = false;
 +              $isNewbie = $this->isNewbie();
  
 -              if ( isset( $limits['anon'] ) && $id == 0 ) {
 -                      $keys[wfMemcKey( 'limiter', $action, 'anon' )] = $limits['anon'];
 -              }
 -
 -              if ( isset( $limits['user'] ) && $id != 0 ) {
 -                      $userLimit = $limits['user'];
 -              }
 -              if ( $this->isNewbie() ) {
 -                      if ( isset( $limits['newbie'] ) && $id != 0 ) {
 +              if ( $id == 0 ) {
 +                      // limits for anons
 +                      if ( isset( $limits['anon'] ) ) {
 +                              $keys[wfMemcKey( 'limiter', $action, 'anon' )] = $limits['anon'];
 +                      }
 +              } else {
 +                      // limits for logged-in users
 +                      if ( isset( $limits['user'] ) ) {
 +                              $userLimit = $limits['user'];
 +                      }
 +                      // limits for newbie logged-in users
 +                      if ( $isNewbie && isset( $limits['newbie'] ) ) {
                                $keys[wfMemcKey( 'limiter', $action, 'user', $id )] = $limits['newbie'];
                        }
 +              }
 +
 +              // limits for anons and for newbie logged-in users
 +              if ( $isNewbie ) {
 +                      // ip-based limits
                        if ( isset( $limits['ip'] ) ) {
                                $ip = $this->getRequest()->getIP();
                                $keys["mediawiki:limiter:$action:ip:$ip"] = $limits['ip'];
                        }
 +                      // subnet-based limits
                        if ( isset( $limits['subnet'] ) ) {
                                $ip = $this->getRequest()->getIP();
 -                              $matches = array();
 -                              $subnet = false;
 -                              if ( IP::isIPv6( $ip ) ) {
 -                                      $parts = IP::parseRange( "$ip/64" );
 -                                      $subnet = $parts[0];
 -                              } elseif ( preg_match( '/^(\d+\.\d+\.\d+)\.\d+$/', $ip, $matches ) ) {
 -                                      // IPv4
 -                                      $subnet = $matches[1];
 -                              }
 +                              $subnet = IP::getSubnet( $ip );
                                if ( $subnet !== false ) {
                                        $keys["mediawiki:limiter:$action:subnet:$subnet"] = $limits['subnet'];
                                }
                        }
                }
 +
                // Check for group-specific permissions
 -              // If more than one group applies, use the group with the highest limit
 +              // If more than one group applies, use the group with the highest limit ratio (max/period)
                foreach ( $this->getGroups() as $group ) {
                        if ( isset( $limits[$group] ) ) {
                                if ( $userLimit === false
                                }
                        }
                }
 +
                // Set the user limit key
                if ( $userLimit !== false ) {
                        list( $max, $period ) = $userLimit;
                        $keys[wfMemcKey( 'limiter', $action, 'user', $id )] = $userLimit;
                }
  
 +              // ip-based limits for all ping-limitable users
 +              if ( isset( $limits['ip-all'] ) ) {
 +                      $ip = $this->getRequest()->getIP();
 +                      // ignore if user limit is more permissive
 +                      if ( $isNewbie || $userLimit === false
 +                              || $limits['ip-all'][0] / $limits['ip-all'][1] > $userLimit[0] / $userLimit[1] ) {
 +                              $keys["mediawiki:limiter:$action:ip-all:$ip"] = $limits['ip-all'];
 +                      }
 +              }
 +
 +              // subnet-based limits for all ping-limitable users
 +              if ( isset( $limits['subnet-all'] ) ) {
 +                      $ip = $this->getRequest()->getIP();
 +                      $subnet = IP::getSubnet( $ip );
 +                      if ( $subnet !== false ) {
 +                              // ignore if user limit is more permissive
 +                              if ( $isNewbie || $userLimit === false
 +                                      || $limits['ip-all'][0] / $limits['ip-all'][1]
 +                                      > $userLimit[0] / $userLimit[1] ) {
 +                                      $keys["mediawiki:limiter:$action:subnet-all:$subnet"] = $limits['subnet-all'];
 +                              }
 +                      }
 +              }
 +
                $cache = ObjectCache::getLocalClusterInstance();
  
                $triggered = false;
                        $this->setToken();
                }
  
-               // If the user doesn't have a token, return null to indicate that.
-               // Otherwise, hmac the version with the secret if we have a version.
                if ( !$this->mToken ) {
+                       // The user doesn't have a token, return null to indicate that.
                        return null;
+               } elseif ( $this->mToken === self::INVALID_TOKEN ) {
+                       // We return a random value here so existing token checks are very
+                       // likely to fail.
+                       return MWCryptRand::generateHex( self::TOKEN_LENGTH );
                } elseif ( $wgAuthenticationTokenVersion === null ) {
+                       // $wgAuthenticationTokenVersion not in use, so return the raw secret
                        return $this->mToken;
                } else {
+                       // $wgAuthenticationTokenVersion in use, so hmac it.
                        $ret = MWCryptHash::hmac( $wgAuthenticationTokenVersion, $this->mToken, false );
  
                        // The raw hash can be overly long. Shorten it up.
         */
        public function setToken( $token = false ) {
                $this->load();
-               if ( !$token ) {
+               if ( $this->mToken === self::INVALID_TOKEN ) {
+                       \MediaWiki\Logger\LoggerFactory::getInstance( 'session' )
+                               ->debug( __METHOD__ . ": Ignoring attempt to set token for system user \"$this\"" );
+               } elseif ( !$token ) {
                        $this->mToken = MWCryptRand::generateHex( self::TOKEN_LENGTH );
                } else {
                        $this->mToken = $token;
@@@ -103,7 -103,7 +103,7 @@@ class SessionManagerTest extends MediaW
                $manager = \TestingAccessWrapper::newFromObject( $this->getManager() );
                $this->assertSame( $this->config, $manager->config );
                $this->assertSame( $this->logger, $manager->logger );
 -              $this->assertSame( $this->store, $manager->permStore );
 +              $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->permStore );
 +              $this->assertSame( \ObjectCache::$instances['testSessionStore'], $manager->store );
  
                foreach ( array(
                        'config' => '$options[\'config\'] must be an instance of Config',
  
        public function testGetSessionById() {
                $manager = $this->getManager();
 -
 -              // Disable the in-process cache so our $this->store->setSession() takes effect.
 -              \TestingAccessWrapper::newFromObject( $manager )->tempStore = new \EmptyBagOStuff;
 -
                try {
                        $manager->getSessionById( 'bad' );
                        $this->fail( 'Expected exception not thrown' );
                        'Bar' => array( 'X', 'Bar1', 3 => 'Bar2' ),
                        'Quux' => array( 'Quux' ),
                        'Baz' => array(),
 -                      'Quux' => array( 'Quux' ),
                );
  
                $this->assertEquals( $expect, $manager->getVaryHeaders() );
  
                $that = $this;
  
 -              \ObjectCache::$instances[__METHOD__] = new \HashBagOStuff();
 +              \ObjectCache::$instances[__METHOD__] = new TestBagOStuff();
                $this->setMwGlobals( array( 'wgMainCacheType' => __METHOD__ ) );
  
                $this->stashMwGlobals( array( 'wgGroupPermissions' ) );
                        $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' ) );
        }
  
                $manager->setLogger( $logger );
                $request = new \FauxRequest();
  
 -              // Disable the in-process cache so our $this->store->setSession() takes effect.
 -              \TestingAccessWrapper::newFromObject( $manager )->tempStore = new \EmptyBagOStuff;
 -
                // TestingAccessWrapper can't handle methods with reference arguments, sigh.
                $rClass = new \ReflectionClass( $manager );
                $rMethod = $rClass->getMethod( 'loadSessionInfoFromStore' );