Allow more fine-grained throttling of login attempts
authorBrian Wolff <bawolff+wn@gmail.com>
Mon, 15 Feb 2016 05:01:55 +0000 (00:01 -0500)
committerBrian Wolff <bawolff+wn@gmail.com>
Tue, 23 Feb 2016 23:02:35 +0000 (18:02 -0500)
In addition to the 5 attempts every 5 minutes rule, add some long
term rules. Its extraordinarily unlikely that a non-malicious person would
use the wrong password 150 times in a row, so add a rule that you
can't have 150 login fails in a row in 48 hours all from the same
IP address. Also add the ability to set throttles across all IPs, but
do not set any of these types by default (There is an unclear risk/benefit
tradeoff between making it easy to lock someone out of their
account in a DoS attack, and preventing brute-forcing)

Bug: T122164
Change-Id: I5c279906936ef3991a42fc21325c3ffd4a200493

includes/DefaultSettings.php
includes/api/ApiLogin.php
includes/specials/SpecialChangeEmail.php
includes/specials/SpecialChangePassword.php
includes/specials/SpecialUserlogin.php

index 08538ee..1e713e5 100644 (file)
@@ -5204,7 +5204,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;
 
@@ -5385,9 +5385,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: