Merge "Log multiple IPs using the same session or the same user account"
[lhc/web/wiklou.git] / includes / session / SessionManager.php
index d84a2d6..d3b7a2d 100644 (file)
 namespace MediaWiki\Session;
 
 use Psr\Log\LoggerInterface;
+use Psr\Log\LogLevel;
 use BagOStuff;
+use CachedBagOStuff;
 use Config;
 use FauxRequest;
-use Language;
-use Message;
 use User;
 use WebRequest;
 
@@ -54,7 +54,7 @@ final class SessionManager implements SessionManagerInterface {
        /** @var Config */
        private $config;
 
-       /** @var BagOStuff|null */
+       /** @var CachedBagOStuff|null */
        private $store;
 
        /** @var SessionProvider[] */
@@ -165,11 +165,12 @@ final class SessionManager implements SessionManagerInterface {
                                        '$options[\'store\'] must be an instance of BagOStuff'
                                );
                        }
-                       $this->store = $options['store'];
+                       $store = $options['store'];
                } else {
-                       $this->store = \ObjectCache::getInstance( $this->config->get( 'SessionCacheType' ) );
-                       $this->store->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' ) );
        }
@@ -214,8 +215,11 @@ final class SessionManager implements SessionManagerInterface {
                        try {
                                $session = $this->getEmptySessionInternal( $request, $id );
                        } catch ( \Exception $ex ) {
-                               $this->logger->error( __METHOD__ . ': failed to create empty session: ' .
-                                       $ex->getMessage() );
+                               $this->logger->error( 'Failed to create empty session: {exception}',
+                                       array(
+                                               'method' => __METHOD__,
+                                               'exception' => $ex,
+                               ) );
                                $session = null;
                        }
                }
@@ -460,14 +464,21 @@ final class SessionManager implements SessionManagerInterface {
 
                // Checks passed, create the user...
                $from = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : 'CLI';
-               $logger->info( __METHOD__ . ": creating new user ($userName) - from: $from" );
+               $logger->info( __METHOD__ . ': creating new user ({username}) - from: {url}',
+                       array(
+                               'username' => $userName,
+                               'url' => $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() );
+                               $logger->error( __METHOD__ . ': failed with message ' . $status->getWikiText(),
+                                       array(
+                                               'username' => $userName,
+                               ) );
                                $user->setId( 0 );
                                $user->loadFromId();
                                return false;
@@ -475,7 +486,10 @@ final class SessionManager implements SessionManagerInterface {
                        }
                } catch ( \Exception $ex ) {
                        // @codeCoverageIgnoreStart
-                       $logger->error( __METHOD__ . ': failed with exception ' . $ex->getMessage() );
+                       $logger->error( __METHOD__ . ': failed with exception {exception}', array(
+                               'exception' => $ex,
+                               'username' => $userName,
+                       ) );
                        // Do not keep throwing errors for a while
                        $cache->set( $backoffKey, 1, 600 );
                        // Bubble up error; which should normally trigger DB rollbacks
@@ -483,11 +497,7 @@ final class SessionManager implements SessionManagerInterface {
                        // @codeCoverageIgnoreEnd
                }
 
-               # Notify hooks (e.g. Newuserlog)
-               \Hooks::run( 'AuthPluginAutoCreate', array( $user ) );
-               \Hooks::run( 'LocalUserCreated', array( $user, true ) );
-
-               # Notify AuthPlugin too
+               # Notify AuthPlugin
                $tmpUser = $user;
                $wgAuth->initUser( $tmpUser, true );
                if ( $tmpUser !== $user ) {
@@ -495,6 +505,10 @@ final class SessionManager implements SessionManagerInterface {
                                get_class( $wgAuth ) . '::initUser() replaced the user object' );
                }
 
+               # Notify hooks (e.g. Newuserlog)
+               \Hooks::run( 'AuthPluginAutoCreate', array( $user ) );
+               \Hooks::run( 'LocalUserCreated', array( $user, true ) );
+
                $user->saveSettings();
 
                # Update user count
@@ -518,13 +532,6 @@ final class SessionManager implements SessionManagerInterface {
        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 );
@@ -667,7 +674,9 @@ final class SessionManager implements SessionManagerInterface {
                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" );
+                               $this->logger->warning( 'Session "{session}": Bad data', array(
+                                       'session' => $info,
+                               ) );
                                $this->store->delete( $key );
                                return false;
                        }
@@ -676,7 +685,9 @@ final class SessionManager implements SessionManagerInterface {
                        if ( !isset( $blob['data'] ) || !is_array( $blob['data'] ) ||
                                !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] )
                        ) {
-                               $this->logger->warning( "Session $info: Bad data structure" );
+                               $this->logger->warning( 'Session "{session}": Bad data structure', array(
+                                       'session' => $info,
+                               ) );
                                $this->store->delete( $key );
                                return false;
                        }
@@ -691,7 +702,9 @@ final class SessionManager implements SessionManagerInterface {
                                !array_key_exists( 'userToken', $metadata ) ||
                                !array_key_exists( 'provider', $metadata )
                        ) {
-                               $this->logger->warning( "Session $info: Bad metadata" );
+                               $this->logger->warning( 'Session "{session}": Bad metadata', array(
+                                       'session' => $info,
+                               ) );
                                $this->store->delete( $key );
                                return false;
                        }
@@ -701,13 +714,21 @@ final class SessionManager implements SessionManagerInterface {
                        if ( $provider === null ) {
                                $newParams['provider'] = $provider = $this->getProvider( $metadata['provider'] );
                                if ( !$provider ) {
-                                       $this->logger->warning( "Session $info: Unknown provider, " . $metadata['provider'] );
+                                       $this->logger->warning(
+                                               'Session "{session}": Unknown provider ' . $metadata['provider'],
+                                               array(
+                                                       'session' => $info,
+                                               )
+                                       );
                                        $this->store->delete( $key );
                                        return false;
                                }
                        } elseif ( $metadata['provider'] !== (string)$provider ) {
-                               $this->logger->warning( "Session $info: Wrong provider, " .
-                                       $metadata['provider'] . ' !== ' . $provider );
+                               $this->logger->warning( 'Session "{session}": Wrong provider ' .
+                                       $metadata['provider'] . ' !== ' . $provider,
+                                       array(
+                                               'session' => $info,
+                               ) );
                                return false;
                        }
 
@@ -724,8 +745,14 @@ final class SessionManager implements SessionManagerInterface {
                                                if ( $newProviderMetadata !== $providerMetadata ) {
                                                        $newParams['metadata'] = $newProviderMetadata;
                                                }
-                                       } catch ( \UnexpectedValueException $ex ) {
-                                               $this->logger->warning( "Session $info: Metadata merge failed: " . $ex->getMessage() );
+                                       } catch ( MetadataMergeException $ex ) {
+                                               $this->logger->warning(
+                                                       'Session "{session}": Metadata merge failed: {exception}',
+                                                       array(
+                                                               'session' => $info,
+                                                               'exception' => $ex,
+                                                       ) + $ex->getContext()
+                                               );
                                                return false;
                                        }
                                }
@@ -744,7 +771,10 @@ final class SessionManager implements SessionManagerInterface {
                                                $userInfo = UserInfo::newAnonymous();
                                        }
                                } catch ( \InvalidArgumentException $ex ) {
-                                       $this->logger->error( "Session $info: " . $ex->getMessage() );
+                                       $this->logger->error( 'Session "{session}": {exception}', array(
+                                               'session' => $info,
+                                               'exception' => $ex,
+                                       ) );
                                        return false;
                                }
                                $newParams['userInfo'] = $userInfo;
@@ -753,8 +783,13 @@ final class SessionManager implements SessionManagerInterface {
                                // 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() );
+                                               $this->logger->warning(
+                                                       'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}',
+                                                       array(
+                                                               'session' => $info,
+                                                               'uid_a' => $metadata['userId'],
+                                                               'uid_b' => $userInfo->getId(),
+                                               ) );
                                                return false;
                                        }
 
@@ -762,24 +797,35 @@ final class SessionManager implements SessionManagerInterface {
                                        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() );
+                                               $this->logger->warning(
+                                                       'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}',
+                                                       array(
+                                                               'session' => $info,
+                                                               'uname_a' => $metadata['userName'],
+                                                               'uname_b' => $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() );
+                                               $this->logger->warning(
+                                                       'Session "{session}": User name mismatch, {uname_a} !== {uname_b}',
+                                                       array(
+                                                               'session' => $info,
+                                                               'uname_a' => $metadata['userName'],
+                                                               'uname_b' => $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'
-                                       );
+                                               'Session "{session}": Metadata has an anonymous user, but a non-anon user was provided',
+                                               array(
+                                                       'session' => $info,
+                                       ) );
                                        return false;
                                }
                        }
@@ -788,7 +834,9 @@ final class SessionManager implements SessionManagerInterface {
                        if ( $metadata['userToken'] !== null &&
                                $userInfo->getToken() !== $metadata['userToken']
                        ) {
-                               $this->logger->warning( "Session $info: User token mismatch" );
+                               $this->logger->warning( 'Session "{session}": User token mismatch', array(
+                                       'session' => $info,
+                               ) );
                                return false;
                        }
                        if ( !$userInfo->isVerified() ) {
@@ -811,7 +859,11 @@ final class SessionManager implements SessionManagerInterface {
                } 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" );
+                               $this->logger->warning(
+                                       'Session "{session}": Null provider and no metadata',
+                                       array(
+                                               'session' => $info,
+                               ) );
                                return false;
                        }
 
@@ -821,14 +873,18 @@ final class SessionManager implements SessionManagerInterface {
                                        $newParams['userInfo'] = UserInfo::newAnonymous();
                                } else {
                                        $this->logger->info(
-                                               "Session $info: No user provided and provider cannot set user"
-                                       );
+                                               'Session "{session}": No user provided and provider cannot set user',
+                                               array(
+                                                       'session' => $info,
+                                       ) );
                                        return false;
                                }
                        } elseif ( !$info->getUserInfo()->isVerified() ) {
                                $this->logger->warning(
-                                       "Session $info: Unverified user provided and no metadata to auth it"
-                               );
+                                       'Session "{session}": Unverified user provided and no metadata to auth it',
+                                       array(
+                                               'session' => $info,
+                               ) );
                                return false;
                        }
 
@@ -868,7 +924,9 @@ final class SessionManager implements SessionManagerInterface {
                        'SessionCheckInfo',
                        array( &$reason, $info, $request, $metadata, $data )
                ) ) {
-                       $this->logger->warning( "Session $info: $reason" );
+                       $this->logger->warning( 'Session "{session}": ' . $reason, array(
+                               'session' => $info,
+                       ) );
                        return false;
                }
 
@@ -997,6 +1055,96 @@ final class SessionManager implements SessionManagerInterface {
                self::$globalSessionRequest = null;
        }
 
+       /**
+        * Do a sanity check to make sure the session is not used from many different IP addresses
+        * and store some data for later sanity checks.
+        * FIXME remove this once SessionManager is considered stable
+        * @private For use in Setup.php only
+        * @param Session $session Defaults to the global session.
+        */
+       public function checkIpLimits( Session $session = null ) {
+               $session = $session ?: self::getGlobalSession();
+
+               try {
+                       $ip = $session->getRequest()->getIP();
+               } catch ( \MWException $e ) {
+                       return;
+               }
+               if ( $ip === '127.0.0.1' || \IP::isConfiguredProxy( $ip ) ) {
+                       return;
+               }
+               $now = time();
+
+               // Record (and possibly log) that the IP is using the current session.
+               // Don't touch the stored data unless we are adding a new IP or re-adding an expired one.
+               // This is slightly inaccurate (when an existing IP is seen again, the expiry is not
+               // extended) but that shouldn't make much difference and limits the session write frequency
+               // to # of IPs / $wgSuspiciousIpExpiry.
+               $data = $session->get( 'SessionManager-ip', array() );
+               if (
+                       !isset( $data[$ip] )
+                       || $data[$ip] < $now
+               ) {
+                       $data[$ip] = time() + $this->config->get( 'SuspiciousIpExpiry' );
+                       foreach ( $data as $key => $expires ) {
+                               if ( $expires < $now ) {
+                                       unset( $data[$key] );
+                               }
+                       }
+                       $session->set( 'SessionManager-ip', $data );
+
+                       $logger = \MediaWiki\Logger\LoggerFactory::getInstance( 'session-ip' );
+                       $logLevel = count( $data ) >= $this->config->get( 'SuspiciousIpPerSessionLimit' )
+                               ? LogLevel::WARNING : ( count( $data ) === 1 ? LogLevel::DEBUG : LogLevel::INFO );
+                       $logger->log(
+                               $logLevel,
+                               'Same session used from {count} IPs',
+                               array(
+                                       'count' => count( $data ),
+                                       'ips' => $data,
+                                       'session' => $session->getId(),
+                                       'user' => $session->getUser()->getName(),
+                                       'persistent' => $session->isPersistent(),
+                               )
+                       );
+               }
+
+               // Now do the same thing globally for the current user.
+               // We are using the object cache and assume it is shared between all wikis of a farm,
+               // and further assume that the same name belongs to the same user on all wikis. (It's either
+               // that or a central ID lookup which would mean an extra SQL query on every request.)
+               if ( $session->getUser()->isLoggedIn() ) {
+                       $userKey = 'SessionManager-ip:' . md5( $session->getUser()->getName() );
+                       $data = $this->store->get( $userKey ) ?: array();
+                       if (
+                               !isset( $data[$ip] )
+                               || $data[$ip] < $now
+                       ) {
+                               $data[$ip] = time() + $this->config->get( 'SuspiciousIpExpiry' );
+                               foreach ( $data as $key => $expires ) {
+                                       if ( $expires < $now ) {
+                                               unset( $data[$key] );
+                                       }
+                               }
+                               $this->store->set( $userKey, $data, $this->config->get( 'SuspiciousIpExpiry' ) );
+                               $logger = \MediaWiki\Logger\LoggerFactory::getInstance( 'session-ip' );
+                               $logLevel = count( $data ) >= $this->config->get( 'SuspiciousIpPerUserLimit' )
+                                       ? LogLevel::WARNING : ( count( $data ) === 1 ? LogLevel::DEBUG : LogLevel::INFO );
+                               $logger->log(
+                                       $logLevel,
+                                       'Same user had sessions from {count} IPs',
+                                       array(
+                                               'count' => count( $data ),
+                                               'ips' => $data,
+                                               'session' => $session->getId(),
+                                               'user' => $session->getUser()->getName(),
+                                               'persistent' => $session->isPersistent(),
+                                       )
+                               );
+                       }
+               }
+       }
+
        /**@}*/
 
 }