Merge "Allow more fine-grained throttling of login attempts"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Fri, 26 Feb 2016 22:40:15 +0000 (22:40 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Fri, 26 Feb 2016 22:40:15 +0000 (22:40 +0000)
includes/DefaultSettings.php
includes/api/ApiLogin.php
includes/specials/SpecialChangeEmail.php
includes/specials/SpecialChangePassword.php
includes/specials/SpecialUserlogin.php

index 9337673..41bf42b 100644 (file)
@@ -5190,7 +5190,7 @@ $wgHideUserContribLimit = 1000;
 /**
  * Number of accounts each IP address may create, 0 to disable.
  *
- * @warning Requires memcached
+ * @warning Requires $wgMainCacheType to be enabled
  */
 $wgAccountCreationThrottle = 0;
 
@@ -5371,9 +5371,23 @@ $wgQueryPageDefaultLimit = 50;
 /**
  * Limit password attempts to X attempts per Y seconds per IP per account.
  *
- * @warning Requires memcached.
- */
-$wgPasswordAttemptThrottle = [ 'count' => 5, 'seconds' => 300 ];
+ * Value is an array of arrays. Each sub-array must have a key for count
+ * (ie count of how many attempts before throttle) and a key for seconds.
+ * If the key 'allIPs' (case sensitive) is present, then the limit is
+ * just per account instead of per IP per account.
+ *
+ * @since 1.27 allIps support and multiple limits added in 1.27. Prior
+ *   to 1.27 this only supported having a single throttle.
+ * @warning Requires $wgMainCacheType to be enabled
+ */
+$wgPasswordAttemptThrottle = [
+       // Short term limit
+       [ 'count' => 5, 'seconds' => 300 ],
+       // Long term limit. We need to balance the risk
+       // of somebody using this as a DoS attack to lock someone
+       // out of their account, and someone doing a brute force attack.
+       [ 'count' => 150, 'seconds' => 60*60*48 ],
+];
 
 /**
  * @var Array Map of (grant => right => boolean)
index 84b9f49..a6e6c49 100644 (file)
@@ -209,7 +209,7 @@ class ApiLogin extends ApiBase {
                        case LoginForm::THROTTLED:
                                $result['result'] = 'Throttled';
                                $throttle = $this->getConfig()->get( 'PasswordAttemptThrottle' );
-                               $result['wait'] = intval( $throttle['seconds'] );
+                               $result['wait'] = intval( $loginForm->mThrottleWait );
                                break;
 
                        case LoginForm::USER_BLOCKED:
index 4c3fc0e..b35446d 100644 (file)
@@ -173,13 +173,12 @@ class SpecialChangeEmail extends FormSpecialPage {
                        return Status::newFatal( 'changeemail-nochange' );
                }
 
-               $throttleCount = LoginForm::incLoginThrottle( $user->getName() );
-               if ( $throttleCount === true ) {
+               $throttleInfo = LoginForm::incrementLoginThrottle( $user->getName() );
+               if ( $throttleInfo ) {
                        $lang = $this->getLanguage();
-                       $throttleInfo = $this->getConfig()->get( 'PasswordAttemptThrottle' );
                        return Status::newFatal(
                                'changeemail-throttled',
-                               $lang->formatDuration( $throttleInfo['seconds'] )
+                               $lang->formatDuration( $throttleInfo['wait'] )
                        );
                }
 
@@ -190,9 +189,7 @@ class SpecialChangeEmail extends FormSpecialPage {
                        return Status::newFatal( 'wrongpassword' );
                }
 
-               if ( $throttleCount ) {
-                       LoginForm::clearLoginThrottle( $user->getName() );
-               }
+               LoginForm::clearLoginThrottle( $user->getName() );
 
                $oldaddr = $user->getEmail();
                $status = $user->setEmailWithConfirmation( $newaddr );
index 4f7ba25..2d0d020 100644 (file)
@@ -257,12 +257,10 @@ class SpecialChangePassword extends FormSpecialPage {
                        return Status::newFatal( $this->msg( 'badretype' ) );
                }
 
-               $throttleCount = LoginForm::incLoginThrottle( $this->mUserName );
-               if ( $throttleCount === true ) {
-                       $lang = $this->getLanguage();
-                       $throttleInfo = $this->getConfig()->get( 'PasswordAttemptThrottle' );
+               $throttleInfo = LoginForm::incrementLoginThrottle( $this->mUserName );
+               if ( $throttleInfo ) {
                        return Status::newFatal( $this->msg( 'changepassword-throttled' )
-                               ->params( $lang->formatDuration( $throttleInfo['seconds'] ) )
+                               ->durationParams( $throttleInfo['wait'] )
                        );
                }
 
@@ -286,9 +284,7 @@ class SpecialChangePassword extends FormSpecialPage {
                }
 
                // Please reset throttle for successful logins, thanks!
-               if ( $throttleCount ) {
-                       LoginForm::clearLoginThrottle( $this->mUserName );
-               }
+               LoginForm::clearLoginThrottle( $this->mUserName );
 
                try {
                        $user->setPassword( $newpass );
index 442eee4..90a6314 100644 (file)
@@ -21,6 +21,7 @@
  * @ingroup SpecialPage
  */
 use MediaWiki\Logger\LoggerFactory;
+use Psr\Log\LogLevel;
 use MediaWiki\Session\SessionManager;
 
 /**
@@ -86,6 +87,11 @@ class LoginForm extends SpecialPage {
        ];
 
        public $mAbortLoginErrorMsg = null;
+       /**
+        * @var int How many seconds user is throttled for
+        * @since 1.27
+        */
+       public $mThrottleWait = '?';
 
        protected $mUsername;
        protected $mPassword;
@@ -745,8 +751,9 @@ class LoginForm extends SpecialPage {
                        return self::NEED_TOKEN;
                }
 
-               $throttleCount = self::incLoginThrottle( $this->mUsername );
-               if ( $throttleCount === true ) {
+               $throttleCount = self::incrementLoginThrottle( $this->mUsername );
+               if ( $throttleCount ) {
+                       $this->mThrottleWait = $throttleCount['wait'];
                        return self::THROTTLED;
                }
 
@@ -862,9 +869,7 @@ class LoginForm extends SpecialPage {
                        $this->getContext()->setUser( $u );
 
                        // Please reset throttle for successful logins, thanks!
-                       if ( $throttleCount ) {
-                               self::clearLoginThrottle( $this->mUsername );
-                       }
+                       self::clearLoginThrottle( $this->mUsername );
 
                        if ( $isAutoCreated ) {
                                // Must be run after $wgUser is set, for correct new user log
@@ -881,31 +886,90 @@ class LoginForm extends SpecialPage {
        /**
         * Increment the login attempt throttle hit count for the (username,current IP)
         * tuple unless the throttle was already reached.
+        *
+        * @since 1.27 Return value changed.
         * @param string $username The user name
-        * @return bool|int The integer hit count or True if it is already at the limit
+        * @return bool|array false if below limit or an array if above limit
+        *   Array contains keys wait, count, and throttleIndex
         */
-       public static function incLoginThrottle( $username ) {
+       public static function incrementLoginThrottle( $username ) {
                global $wgPasswordAttemptThrottle, $wgRequest;
-               $username = trim( $username ); // sanity
+               $username = User::getCanonicalName( $username, 'usable' ) ?: $username;
 
                $throttleCount = 0;
                if ( is_array( $wgPasswordAttemptThrottle ) ) {
-                       $throttleKey = wfGlobalCacheKey( 'password-throttle', $wgRequest->getIP(), md5( $username ) );
-                       $count = $wgPasswordAttemptThrottle['count'];
-                       $period = $wgPasswordAttemptThrottle['seconds'];
-
-                       $cache = ObjectCache::getLocalClusterInstance();
-                       $throttleCount = $cache->get( $throttleKey );
-                       if ( !$throttleCount ) {
-                               $cache->add( $throttleKey, 1, $period ); // start counter
-                       } elseif ( $throttleCount < $count ) {
-                               $cache->incr( $throttleKey );
-                       } elseif ( $throttleCount >= $count ) {
-                               return true;
+                       $throttleConfig = $wgPasswordAttemptThrottle;
+                       if ( isset( $wgPasswordAttemptThrottle['count'] ) ) {
+                               // old style. Convert for backwards compat.
+                               $throttleConfig = [ $wgPasswordAttemptThrottle ];
+                       }
+                       foreach ( $throttleConfig as $index => $specificThrottle ) {
+                               if ( isset( $specificThrottle['allIPs'] ) ) {
+                                       $ip = 'All';
+                               } else {
+                                       $ip = $wgRequest->getIP();
+                               }
+                               $throttleKey = wfGlobalCacheKey( 'password-throttle',
+                                       $index, $ip, md5( $username )
+                               );
+                               $count = $specificThrottle['count'];
+                               $period = $specificThrottle['seconds'];
+
+                               $cache = ObjectCache::getLocalClusterInstance();
+                               $throttleCount = $cache->get( $throttleKey );
+                               if ( !$throttleCount ) {
+                                       $cache->add( $throttleKey, 1, $period ); // start counter
+                               } elseif ( $throttleCount < $count ) {
+                                       $cache->incr( $throttleKey );
+                               } elseif ( $throttleCount >= $count ) {
+                                       $logMsg = 'Login attempt rejected because logins to '
+                                               . '{acct} from IP {ip} have been throttled for '
+                                               . '{period} seconds due to {count} failed attempts';
+                                       // If we are hitting a throttle for >= 50 attempts,
+                                       // it is much more likely to be an attack than someone
+                                       // simply forgetting their password, so log it at a
+                                       // higher level.
+                                       $level = $count >= 50 ? LogLevel::WARNING : LogLevel::INFO;
+                                       // It should be noted that once the throttle is hit,
+                                       // every attempt to login will generate the log message
+                                       // until the throttle expires, not just the attempt that
+                                       // puts the throttle over the top.
+                                       LoggerFactory::getInstance( 'password-throttle' )->log(
+                                               $level,
+                                               $logMsg,
+                                               [
+                                                       'ip' => $ip,
+                                                       'period' => $period,
+                                                       'acct' => $username,
+                                                       'count' => $count,
+                                                       'throttleIdentifier' => $index,
+                                                       'method' => __METHOD__
+                                               ]
+                                       );
+
+                                       return [
+                                               'throttleIndex' => $index,
+                                               'wait' => $period,
+                                               'count' => $count
+                                       ];
+                               }
                        }
                }
+               return false;
+       }
 
-               return $throttleCount;
+       /**
+        * Increment the login attempt throttle hit count for the (username,current IP)
+        * tuple unless the throttle was already reached.
+        *
+        * @deprecated Use LoginForm::incrementLoginThrottle instead
+        * @param string $username The user name
+        * @return bool|int true if above throttle, or 0 (prior to 1.27, returned current count)
+        */
+       public static function incLoginThrottle( $username ) {
+               wfDeprecated( __METHOD__, "1.27" );
+               $res = self::incrementLoginThrottle( $username );
+               return is_array( $res ) ? true : 0;
        }
 
        /**
@@ -914,11 +978,27 @@ class LoginForm extends SpecialPage {
         * @return void
         */
        public static function clearLoginThrottle( $username ) {
-               global $wgRequest;
-               $username = trim( $username ); // sanity
+               global $wgRequest, $wgPasswordAttemptThrottle;
+               $username = User::getCanonicalName( $username, 'usable' ) ?: $username;
 
-               $throttleKey = wfGlobalCacheKey( 'password-throttle', $wgRequest->getIP(), md5( $username ) );
-               ObjectCache::getLocalClusterInstance()->delete( $throttleKey );
+               if ( is_array( $wgPasswordAttemptThrottle ) ) {
+                       $throttleConfig = $wgPasswordAttemptThrottle;
+                       if ( isset( $wgPasswordAttemptThrottle['count'] ) ) {
+                               // old style. Convert for backwards compat.
+                               $throttleConfig = [ $wgPasswordAttemptThrottle ];
+                       }
+                       foreach ( $throttleConfig as $index => $specificThrottle ) {
+                               if ( isset( $specificThrottle['allIPs'] ) ) {
+                                       $ip = 'All';
+                               } else {
+                                       $ip = $wgRequest->getIP();
+                               }
+                               $throttleKey = wfGlobalCacheKey( 'password-throttle', $index,
+                                       $ip, md5( $username )
+                               );
+                               ObjectCache::getLocalClusterInstance()->delete( $throttleKey );
+                       }
+               }
        }
 
        /**
@@ -977,7 +1057,7 @@ class LoginForm extends SpecialPage {
        }
 
        function processLogin() {
-               global $wgLang, $wgSecureLogin, $wgPasswordAttemptThrottle, $wgInvalidPasswordReset;
+               global $wgLang, $wgSecureLogin, $wgInvalidPasswordReset;
 
                $cache = ObjectCache::getLocalClusterInstance();
                $authRes = $this->authenticateUserData();
@@ -999,10 +1079,9 @@ class LoginForm extends SpecialPage {
                                self::clearLoginToken();
 
                                // Reset the throttle
-                               $request = $this->getRequest();
-                               $key = wfGlobalCacheKey( 'password-throttle', $request->getIP(), md5( $this->mUsername ) );
-                               $cache->delete( $key );
+                               self::clearLoginThrottle( $this->mUsername );
 
+                               $request = $this->getRequest();
                                if ( $this->hasSessionCookie() || $this->mSkipCookieCheck ) {
                                        /* Replace the language object to provide user interface in
                                         * correct language immediately on this first page load.
@@ -1079,8 +1158,7 @@ class LoginForm extends SpecialPage {
                        case self::THROTTLED:
                                $error = $this->mAbortLoginErrorMsg ?: 'login-throttled';
                                $this->mainLoginForm( $this->msg( $error )
-                                       ->params( $this->getLanguage()->formatDuration( $wgPasswordAttemptThrottle['seconds'] ) )
-                                       ->text()
+                                       ->durationParams( $this->mThrottleWait )->text()
                                );
                                break;
                        case self::USER_BLOCKED: