Merge "Disable testIPTCParseForcedUTFButInvalid on PHP > 5.6.0 but < 5.6.10"
[lhc/web/wiklou.git] / includes / user / User.php
index 2fadd6b..749ec46 100644 (file)
@@ -24,9 +24,10 @@ use MediaWiki\Session\SessionManager;
 
 /**
  * String Some punctuation to prevent editing from broken text-mangling proxies.
+ * @deprecated since 1.27, use \\MediaWiki\\Session\\Token::SUFFIX
  * @ingroup Constants
  */
-define( 'EDIT_TOKEN_SUFFIX', '+\\' );
+define( 'EDIT_TOKEN_SUFFIX', MediaWiki\Session\Token::SUFFIX );
 
 /**
  * The User object encapsulates all of the user-specific settings (user_id,
@@ -44,9 +45,15 @@ 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.
+        * @deprecated since 1.27, use \\MediaWiki\\Session\\Token::SUFFIX
         */
        const EDIT_TOKEN_SUFFIX = EDIT_TOKEN_SUFFIX;
 
@@ -307,6 +314,22 @@ class User implements IDBAccessObject {
                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.
         *
@@ -325,7 +348,7 @@ class User implements IDBAccessObject {
                $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' ),
@@ -639,7 +662,8 @@ class User implements IDBAccessObject {
                $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 {
@@ -655,7 +679,7 @@ class User implements IDBAccessObject {
                        $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'] ) {
@@ -675,11 +699,11 @@ class User implements IDBAccessObject {
                                __METHOD__
                        );
                        $user->invalidateEmail();
+                       $user->mToken = self::INVALID_TOKEN;
                        $user->saveSettings();
+                       SessionManager::singleton()->preventSessionsForUser( $user->getName() );
                }
 
-               SessionManager::singleton()->preventSessionsForUser( $user->getName() );
-
                return $user;
        }
 
@@ -1154,7 +1178,7 @@ class User implements IDBAccessObject {
                        // 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 );
+                       $session->set( 'wsToken', $this->getToken() );
                        return true;
                }
 
@@ -1259,12 +1283,20 @@ class User implements IDBAccessObject {
                        $all = false;
                }
 
-               if ( isset( $row->user_email ) ) {
-                       $this->mEmail = $row->user_email;
-                       $this->mToken = $row->user_token;
-                       if ( $this->mToken == '' ) {
+               if ( isset( $row->user_token ) ) {
+                       // The definition for the column is binary(32), so trim the NULs
+                       // that appends. The previous definition was char(32), so trim
+                       // spaces too.
+                       $this->mToken = rtrim( $row->user_token, " \0" );
+                       if ( $this->mToken === '' ) {
                                $this->mToken = null;
                        }
+               } else {
+                       $all = false;
+               }
+
+               if ( isset( $row->user_email ) ) {
+                       $this->mEmail = $row->user_email;
                        $this->mEmailAuthenticated = wfTimestampOrNull( TS_MW, $row->user_email_authenticated );
                        $this->mEmailToken = $row->user_email_token;
                        $this->mEmailTokenExpires = wfTimestampOrNull( TS_MW, $row->user_email_token_expires );
@@ -1516,10 +1548,16 @@ class User implements IDBAccessObject {
                # 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
@@ -1728,40 +1766,43 @@ class User implements IDBAccessObject {
                $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
@@ -1771,6 +1812,7 @@ class User implements IDBAccessObject {
                                }
                        }
                }
+
                // Set the user limit key
                if ( $userLimit !== false ) {
                        list( $max, $period ) = $userLimit;
@@ -1778,6 +1820,30 @@ class User implements IDBAccessObject {
                        $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;
@@ -2419,14 +2485,38 @@ class User implements IDBAccessObject {
         * Get the user's current token.
         * @param bool $forceCreation Force the generation of a new token if the
         *   user doesn't have one (default=true for backwards compatibility).
-        * @return string Token
+        * @return string|null Token
         */
        public function getToken( $forceCreation = true ) {
+               global $wgAuthenticationTokenVersion;
+
                $this->load();
                if ( !$this->mToken && $forceCreation ) {
                        $this->setToken();
                }
-               return $this->mToken;
+
+               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.
+                       $len = max( 32, self::TOKEN_LENGTH );
+                       if ( strlen( $ret ) < $len ) {
+                               // Should never happen, even md5 is 128 bits
+                               throw new \UnexpectedValueException( 'Hmac returned less than 128 bits' );
+                       }
+                       return substr( $ret, -$len );
+               }
        }
 
        /**
@@ -2437,7 +2527,10 @@ class User implements IDBAccessObject {
         */
        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;
@@ -3392,22 +3485,21 @@ class User implements IDBAccessObject {
                                return;
                        }
 
-                       $that = $this;
                        // Try to update the DB post-send and only if needed...
-                       DeferredUpdates::addCallableUpdate( function() use ( $that, $title, $oldid ) {
-                               if ( !$that->getNewtalk() ) {
+                       DeferredUpdates::addCallableUpdate( function() use ( $title, $oldid ) {
+                               if ( !$this->getNewtalk() ) {
                                        return; // no notifications to clear
                                }
 
                                // Delete the last notifications (they stack up)
-                               $that->setNewtalk( false );
+                               $this->setNewtalk( false );
 
                                // If there is a new, unseen, revision, use its timestamp
                                $nextid = $oldid
                                        ? $title->getNextRevisionID( $oldid, Title::GAID_FOR_UPDATE )
                                        : null;
                                if ( $nextid ) {
-                                       $that->setNewtalk( true, Revision::newFromId( $nextid ) );
+                                       $this->setNewtalk( true, Revision::newFromId( $nextid ) );
                                }
                        } );
                }
@@ -3431,7 +3523,7 @@ class User implements IDBAccessObject {
                }
 
                $this->getWatchedItem( $title )->resetNotificationTimestamp(
-                       $force, $oldid, WatchedItem::DEFERRED
+                       $force, $oldid
                );
        }
 
@@ -4066,30 +4158,25 @@ class User implements IDBAccessObject {
        }
 
        /**
-        * Internal implementation for self::getEditToken() and
-        * self::matchEditToken().
+        * Initialize (if necessary) and return a session token value
+        * which can be used in edit forms to show that the user's
+        * login credentials aren't being hijacked with a foreign form
+        * submission.
         *
-        * @param string|array $salt
-        * @param WebRequest $request
-        * @param string|int $timestamp
-        * @return string
+        * @since 1.27
+        * @param string|array $salt Array of Strings Optional function-specific data for hashing
+        * @param WebRequest|null $request WebRequest object to use or null to use $wgRequest
+        * @return MediaWiki\\Session\\Token The new edit token
         */
-       private function getEditTokenAtTimestamp( $salt, $request, $timestamp ) {
+       public function getEditTokenObject( $salt = '', $request = null ) {
                if ( $this->isAnon() ) {
-                       return self::EDIT_TOKEN_SUFFIX;
-               } else {
-                       $token = $request->getSessionData( 'wsEditToken' );
-                       if ( $token === null ) {
-                               $token = MWCryptRand::generateHex( 32 );
-                               $request->setSessionData( 'wsEditToken', $token );
-                       }
-                       if ( is_array( $salt ) ) {
-                               $salt = implode( '|', $salt );
-                       }
-                       return hash_hmac( 'md5', $timestamp . $salt, $token, false ) .
-                               dechex( $timestamp ) .
-                               self::EDIT_TOKEN_SUFFIX;
+                       return new LoggedOutEditToken();
                }
+
+               if ( !$request ) {
+                       $request = $this->getRequest();
+               }
+               return $request->getSession()->getToken( $salt );
        }
 
        /**
@@ -4099,29 +4186,23 @@ class User implements IDBAccessObject {
         * submission.
         *
         * @since 1.19
-        *
         * @param string|array $salt Array of Strings Optional function-specific data for hashing
         * @param WebRequest|null $request WebRequest object to use or null to use $wgRequest
         * @return string The new edit token
         */
        public function getEditToken( $salt = '', $request = null ) {
-               return $this->getEditTokenAtTimestamp(
-                       $salt, $request ?: $this->getRequest(), wfTimestamp()
-               );
+               return $this->getEditTokenObject( $salt, $request )->toString();
        }
 
        /**
         * Get the embedded timestamp from a token.
+        * @deprecated since 1.27, use \\MediaWiki\\Session\\Token::getTimestamp instead.
         * @param string $val Input token
         * @return int|null
         */
        public static function getEditTokenTimestamp( $val ) {
-               $suffixLen = strlen( self::EDIT_TOKEN_SUFFIX );
-               if ( strlen( $val ) <= 32 + $suffixLen ) {
-                       return null;
-               }
-
-               return hexdec( substr( $val, 32, -$suffixLen ) );
+               wfDeprecated( __METHOD__, '1.27' );
+               return MediaWiki\Session\Token::getTimestamp( $val );
        }
 
        /**
@@ -4137,28 +4218,7 @@ class User implements IDBAccessObject {
         * @return bool Whether the token matches
         */
        public function matchEditToken( $val, $salt = '', $request = null, $maxage = null ) {
-               if ( $this->isAnon() ) {
-                       return $val === self::EDIT_TOKEN_SUFFIX;
-               }
-
-               $timestamp = self::getEditTokenTimestamp( $val );
-               if ( $timestamp === null ) {
-                       return false;
-               }
-               if ( $maxage !== null && $timestamp < wfTimestamp() - $maxage ) {
-                       // Expired token
-                       return false;
-               }
-
-               $sessionToken = $this->getEditTokenAtTimestamp(
-                       $salt, $request ?: $this->getRequest(), $timestamp
-               );
-
-               if ( !hash_equals( $sessionToken, $val ) ) {
-                       wfDebug( "User::matchEditToken: broken session data\n" );
-               }
-
-               return hash_equals( $sessionToken, $val );
+               return $this->getEditTokenObject( $salt, $request )->match( $val, $maxage );
        }
 
        /**
@@ -4810,9 +4870,8 @@ class User implements IDBAccessObject {
         * Deferred version of incEditCountImmediate()
         */
        public function incEditCount() {
-               $that = $this;
-               wfGetDB( DB_MASTER )->onTransactionPreCommitOrIdle( function() use ( $that ) {
-                       $that->incEditCountImmediate();
+               wfGetDB( DB_MASTER )->onTransactionPreCommitOrIdle( function() {
+                       $this->incEditCountImmediate();
                } );
        }