From: Brian Wolff Date: Mon, 15 Feb 2016 05:01:55 +0000 (-0500) Subject: Allow more fine-grained throttling of login attempts X-Git-Tag: 1.31.0-rc.0~7815^2 X-Git-Url: https://git.cyclocoop.org/%27.WWW_URL.%27admin/?a=commitdiff_plain;h=6fcfa981544fca6a6e490334451a66046e0ab3ba;p=lhc%2Fweb%2Fwiklou.git Allow more fine-grained throttling of login attempts 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 --- diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 08538eebf2..1e713e508b 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -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) diff --git a/includes/api/ApiLogin.php b/includes/api/ApiLogin.php index 84b9f499ad..a6e6c49d79 100644 --- a/includes/api/ApiLogin.php +++ b/includes/api/ApiLogin.php @@ -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: diff --git a/includes/specials/SpecialChangeEmail.php b/includes/specials/SpecialChangeEmail.php index 4c3fc0e114..b35446de65 100644 --- a/includes/specials/SpecialChangeEmail.php +++ b/includes/specials/SpecialChangeEmail.php @@ -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 ); diff --git a/includes/specials/SpecialChangePassword.php b/includes/specials/SpecialChangePassword.php index 4f7ba25dcb..2d0d020cac 100644 --- a/includes/specials/SpecialChangePassword.php +++ b/includes/specials/SpecialChangePassword.php @@ -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 ); diff --git a/includes/specials/SpecialUserlogin.php b/includes/specials/SpecialUserlogin.php index 442eee4157..90a6314825 100644 --- a/includes/specials/SpecialUserlogin.php +++ b/includes/specials/SpecialUserlogin.php @@ -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: