From fea1a38de5cd6b9203aa9ff632033f76eb5748b1 Mon Sep 17 00:00:00 2001 From: Brian Wolff Date: Mon, 28 Mar 2016 04:26:48 -0400 Subject: [PATCH] Initial version of extension to notify people on failed login attempts. Bug: T11838 Change-Id: Id96aff70f06879a7b754b19af8e65c0f641e84e0 --- COPYING | 21 + Lock.svg | 30 ++ LoginNotify.hooks.php | 235 ++++++++++++ LoginNotifyFormatter.php | 19 + LoginNotifyPresentationModel.php | 44 +++ LoginNotify_body.php | 640 +++++++++++++++++++++++++++++++ extension.json | 68 ++++ i18n/en.json | 21 + i18n/qqq.json | 22 ++ tests/LoginNotifyTests.php | 312 +++++++++++++++ 10 files changed, 1412 insertions(+) create mode 100644 COPYING create mode 100644 Lock.svg create mode 100644 LoginNotify.hooks.php create mode 100644 LoginNotifyFormatter.php create mode 100644 LoginNotifyPresentationModel.php create mode 100644 LoginNotify_body.php create mode 100644 extension.json create mode 100644 i18n/en.json create mode 100644 i18n/qqq.json create mode 100644 tests/LoginNotifyTests.php diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..96d3f68 --- /dev/null +++ b/COPYING @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Lock.svg b/Lock.svg new file mode 100644 index 0000000..cdae19a --- /dev/null +++ b/Lock.svg @@ -0,0 +1,30 @@ + + + + + + + diff --git a/LoginNotify.hooks.php b/LoginNotify.hooks.php new file mode 100644 index 0000000..0cdb97b --- /dev/null +++ b/LoginNotify.hooks.php @@ -0,0 +1,235 @@ + 'LoginNotify/Lock.svg' + ]; + + $notificationCategories['login-fail'] = array( + 'priority' => 7, + 'tooltip' => 'echo-pref-tooltip-login-fail', + ); + + $loginBase = [ + EchoAttributeManager::ATTR_LOCATORS => [ + 'EchoUserLocator::locateEventAgent' + ], + 'category' => 'login-fail', + 'group' => 'negative', + 'presentation-model' => 'LoginNotifyPresentationModel', + // fixme, what does this actually do? + 'title-message' => 'loginnotify-login-fail', + 'title-params' => [], + 'email-subject-message' => 'notification-loginnotify-login-fail-email-subject', +//FIXME Should count be a parameter + 'email-subject-params' => [ 'agent', 'count' ], + 'email-body-batch-params' => [ 'agent', 'count' ], + // FIXME is it ok not to set batch email messages, since + // we have immediate flag? + 'icon' => 'LoginNotify-lock', + 'immediate' => true, + 'formatter-class' => 'LoginNotifyFormatter', + ]; + $notifications['login-fail-new'] = [ + 'email-body-batch-message' => 'notification-loginnotify-login-fail-new-emailbatch' + ] + $loginBase; + $notifications['login-fail-known'] = [ + 'email-body-batch-message' => 'notification-loginnotify-login-fail-known-emailbatch' + ] + $loginBase; + if ( $wgLoginNotifyEnableOnSuccess ) { + $notificationCategories['login-success'] = array( + 'priority' => 7, + 'tooltip' => 'echo-pref-tooltip-login-success', + ); + $notifications['login-success'] = [ + 'category' => 'login-success', + 'email-subject-message' => 'notification-loginnotify-login-success-email-subject', + 'email-body-batch-message' => 'notification-loginnotify-login-success-emailbatch', + 'email-body-batch-params' => [ 'agent' ], + // FIXME title-message. What is its purpose?? + ] + $loginBase; + } + + return true; + } + + /** + * Hook to check login result + * + * @todo Doesn't catcha captcha or throttle failures + * @param $user User User in question + * @param $pass String password + * @param $retval int LoginForm constant (e.g. LoginForm::SUCCESS) + * @return bool Standard hook return + */ + public static function onLoginAuthenticateAudit( User $user, $pass, $retval ) { + if ( !class_exists( 'EchoEvent' ) ) { + throw new FatalError( "LoginNotify extension requires the Echo extension to be installed" ); + } + $loginNotify = new LoginNotify(); + + if ( $retval === LoginForm::WRONG_PASS ) { + $loginNotify->recordFailure( $user ); + } elseif ( $retval === LoginForm::SUCCESS ) { + $loginNotify->clearCounters( $user ); + $loginNotify->sendSuccessNotice( $user ); + $loginNotify->setCurrentAddressAsKnown( $user ); + } + // We ignore things like RESET_PASS for now + } + + /** + * Set a cookie saying this is a known computer when creating an account. + * + * @todo This still sets cookies if user creates account well logged in as someone else. + * @param User $user + * @param boolean $byMail Account created by email + */ + public static function onAddNewAccount( User $user, $byMail ) { + if ( !$byMail ) { + $loginNotify = new LoginNotify(); + $loginNotify->setCurrentAddressAsKnown( $user ); + } + } + + /** + * Hook for loading options. + * + * This is a bit hacky. Used to be able to set a different + * default for admins then other users + * + * @param $user User + * @param &$options array + * @return bool + */ + public static function onUserLoadOptions( User $user, array &$options ) { + global $wgLoginNotifyEnableForPriv; + if ( !is_array( $wgLoginNotifyEnableForPriv ) ) { + return true; + } + + if ( !self::isUserOptionOverriden( $user, $wgLoginNotifyEnableForPriv ) ) { + return true; + } + + $defaultOpts = User::getDefaultOptions(); + $optionsToCheck = self::getOverridenOptions(); + + foreach( $optionsToCheck as $opt ) { + if ( $options[$opt] === self::OPTIONS_FAKE_FALSE ) { + $options[$opt] = '0'; + } + if ( $defaultOpts[$opt] !== false ) { + continue; + } + if ( $options[$opt] === false ) { + $options[$opt] = self::OPTIONS_FAKE_TRUTH; + } + } + return true; + } + + /** + * Hook for saving options. + * + * This is a bit hacky. Used to be able to set a different + * default for admins then other users. Since admins are higher value + * targets, it may make sense to have notices enabled by default for + * them, but disabled for normal users. + * + * @todo This is a bit icky. Need to decide if we really want to do this. + * @todo If someone explicitly enables, gets admin rights, gets de-admined, + * this will then disable the preference, which is defnitely non-ideal. + * @param $user User + * @param &$options array + * @return bool + */ + public function onUserSaveOptions( User $user, array &$options ) { + global $wgLoginNotifyEnableForPriv; + $optionsToCheck = self::getOverridenOptions(); + $defaultOpts = User::getDefaultOptions(); + if ( !self::isUserOptionOverriden( $user, $wgLoginNotifyEnableForPriv ) ) { + return true; + } + foreach( $optionsToCheck as $opt ) { + if ( $defaultOpts[$opt] !== false ) { + continue; + } + + if ( $options[$opt] === self::OPTIONS_FAKE_TRUTH ) { + $options[$opt] = false; + } + if ( $options[$opt] !== self::OPTIONS_FAKE_TRUTH + && $options[$opt] + ) { + // Its checked on the form. Keep at default + } + + if ( !$options[$opt] ) { + // Somehow this means it got unchecked on form + $options[$opt] = self::OPTIONS_FAKE_FALSE; + } + } + return true; + } + + /** + * Helper for onUser(Load|Save)Options + * + * @return Array Which option keys to check + */ + private static function getOverridenOptions() { + // For login-success, it makes most sense to email + // people about it, but auto-subscribing people to email + // is a bit icky as nobody likes to be spammed. + return [ + 'echo-subscriptions-web-login-fail', + 'echo-subscriptions-web-login-success' + ]; + } + + private static function isUserOptionOverriden( User $user ) { + global $wgLoginNotifyEnableForPriv; + // Note: isAllowedAny calls into session for per-session restrictions, + // which we do not want to take into account, and more importantly + // causes an infinite loop. + $rights = User::getGroupPermissions( $user->getEffectiveGroups() ); + if ( !array_intersect( $rights, $wgLoginNotifyEnableForPriv ) ) { + // Not a user we care about. + return false; + } + return true; + } + + /** + * Register phpunit tests + * + * @param Array &$files List of directories with unit tests. + * @return bool + */ + public static function onUnitTestList( array &$files ) { + $files[] = __DIR__ . '/tests/'; + return true; + } +} diff --git a/LoginNotifyFormatter.php b/LoginNotifyFormatter.php new file mode 100644 index 0000000..60a8df7 --- /dev/null +++ b/LoginNotifyFormatter.php @@ -0,0 +1,19 @@ +params( $event->getExtraParam( 'count' ) ); + } else { + parent::processParam( $event, $param, $message, $user ); + } + } +} diff --git a/LoginNotifyPresentationModel.php b/LoginNotifyPresentationModel.php new file mode 100644 index 0000000..61a239f --- /dev/null +++ b/LoginNotifyPresentationModel.php @@ -0,0 +1,44 @@ +numParams( + $this->event->getExtraParam( 'count', 0 ) + ); + } + + /** + * @todo FIXME Unclear if this is a good idea + */ + public function getSecondaryLinks() { + return array( $this->getAgentLink() ); + } +} diff --git a/LoginNotify_body.php b/LoginNotify_body.php new file mode 100644 index 0000000..9bd1b7c --- /dev/null +++ b/LoginNotify_body.php @@ -0,0 +1,640 @@ +getConfig(); + } + $this->cache = $cache; + $this->config = $cfg; + + if ( $this->config->get( 'LoginNotifySecretKey' ) !== null ) { + $this->secret = $this->config->get( 'LoginNotifySecretKey' ); + } else { + $globalSecret = $this->config->get( 'SecretKey' ); + $this->secret = hash( 'sha256', $globalSecret + 'LoginNotify' ); + } + + if ( !$log ) { + $log = LoggerFactory::getInstance( 'LoginNotify' ); + } + $this->log = $log; + } + + + /** + * Get just network part of an IP (assuming /24 or /64) + * + * @param String $ip Either IPv4 or IPv6 address + * @return string Just the network part (e.g. 127.0.0.) + */ + private function getIPNetwork( $ip ) { + $ip = IP::sanitizeIP( $ip ); + if ( IP::isIPv6( $ip ) ) { + // Match against the /64 + $subnetRegex = '/[0-9A-F]+:[0-9A-F]+:[0-9A-F]+:[0-9A-F]+$/i'; + } elseif ( IP::isIPv4( $ip ) ) { + // match against the /24 + $subnetRegex = '/\d+$/'; + } else { + throw new UnexpectedValueException( "Unrecognized IP address: $ip" ); + } + $prefix = preg_replace( $subnetRegex, '', $ip ); + if ( !is_string( $prefix ) ) { + throw new Exception( __METHOD__ . " Regex failed!?" ); + } + return $prefix; + } + + /** + * Is the current computer known to be used by the given user + * + * @param $user User User in question + * @return boolean true if the user has used this computer before + */ + private function isFromKnownIP( User $user ) { + $cookieResult = $this->checkUserInCookie( $user ); + if ( $cookieResult === true ) { + // User has cookie + return true; + } + + $cacheResult = $this->checkUserInCache( $user ); + if ( $cacheResult === true ) { + return true; + } + + $cuResult = $this->checkUserInCheckUser( $user ); + if ( $cuResult === true ) { + return true; + } + + // If we have no check user data for the user, and there was + // no cookie supplied, just pass the user in, since we don't have + // enough info to determine if from known ip. + // FIXME: Does this make sense + if ( + $cuResult === self::NO_INFO_AVAILABLE && + $cookieResult === self::NO_INFO_AVAILABLE && + $cacheResult === self::NO_INFO_AVAILABLE + ) { + // We have to be careful here. Whether $cookieResult is + // self::NO_INFO_AVAILABLE, is under control of the attacker. + // If checking CheckUser is disabled, then we should not + // hit this branch. + + $this->log->info( "Assuming {user} is from known IP since no info available", [ + 'method' => __METHOD__, + 'user' => $user->getName() + ] ); + return true; + } + + return false; + } + + /** + * Check if we cached this user's ip address from last login. + * + * @param $user User User in question. + * @return Mixed true, false or self::NO_INFO_AVAILABLE. + */ + private function checkUserInCache( User $user ) { + $ipPrefix = $this->getIPNetwork( $user->getRequest()->getIP() ); + $key = $this->getKey( $user, 'prevSubnet' ); + $res = $this->cache->get( $key ); + if ( $res !== false ) { + return $res === $ipPrefix; + } + return self::NO_INFO_AVAILABLE; + } + + /** + * Is the subnet of the current IP in the check user data for the user. + * + * If CentralAuth is installed, this will check not only the current wiki, + * but also the ten wikis where user has most edits on. + * + * @param $user User User in question. + * @return Mixed true, false or self::NO_INFO_AVAILABLE. + */ + private function checkUserInCheckUser( User $user ) { + if ( !$this->config->get( 'LoginNotifyCheckKnownIPs' ) + || !class_exists( 'CheckUser' ) + ) { + // Check user checks disabled. + // Note: Its important this be false and not self::NO_INFO_AVAILABLE. + return false; + } + + $haveAnyInfo = false; + $prefix = $this->getIPNetwork( $user->getRequest()->getIP() ); + + $dbr = wfGetDB( DB_SLAVE ); + $localResult = $this->checkUserInCheckUserQuery( $user->getId(), $prefix, $dbr ); + if ( $localResult ) { + return true; + } + + if ( !$haveAnyInfo ) { + $haveAnyInfo = $this->checkUserInCheckUserAnyInfo( $user->getId(), $dbr ); + } + + // Also check checkuser table on the top ten wikis where this user has + // edited the most. We only do top ten, to limit the worst-case where the + // user has accounts on 800 wikis. + if ( class_exists( 'CentralAuthUser' ) ) { + $wikisByEditCounts = []; + $globalUser = CentralAuthUser::getInstance( $user ); + if ( $globalUser->exists() ) { + // This is expensive. However, On WMF wikis, probably + // already done as part of password complexity check, and + // will be cached. + $info = $globalUser->queryAttached(); + // already checked the local wiki. + unset( $info[wfWikiId()] ); + usort( $info, function( $a, $b ) { + // descending order + return $b['editCount'] - $a['editCount']; + } + ); + $count = 0; + foreach( $info as $wiki => $localInfo ) { + if ( $count > 10 || $localInfo['editCount'] < 1 ) { + break; + } + $lb = wfGetLB( $wiki ); + $dbrLocal = $lb->getConnection( DB_SLAVE, [], $wiki ); + // FIXME The case where there are no CU entries for + // this user. + $res = $this->checkUserInCheckUserQuery( + $localInfo['id'], + $prefix, + $dbrLocal + ); + + $lb->reuseConnection( $dbrLocal ); + if ( $res ) { + return true; + } + if ( !$haveAnyInfo ) { + $haveAnyInfo = $this->checkUserInCheckUserAnyInfo( $user->getId(), $dbr ); + } + $count++; + } + } + } + if ( !$haveAnyInfo ) { + return self::NO_INFO_AVAILABLE; + } + return false; + } + + /** + * Actually do the query of the check user table. + * + * @note This catches and ignores database errors. + * @param $userId int User id number (Not neccesarily for the local wiki) + * @param $ipFragment string Prefix to match against cuc_ip (from $this->getIPNetwork()) + * @param $dbr DatabaseBase A database connection (possibly foreign) + * @return boolean If $ipFragment is in check user db + */ + private function checkUserInCheckUserQuery( $userId, $ipFragment, DatabaseBase $dbr ) { + // For some unknown reason, the index is on + // (cuc_user, cuc_ip, cuc_timestamp), instead of + // cuc_ip_hex which would be ideal. + // user-agent might also be good to look at, + // but no index on that. + try { + $IPHasBeenUsedBefore = $dbr->selectField( + 'cu_changes', + '1', + [ + 'cuc_user' => $userId, + 'cuc_ip ' . $dbr->buildLike( + $ipFragment, + $dbr->anyString() + ) + ], + __METHOD__ + ); + } catch ( DBQueryError $e ) { + // Maybe we're doing a cross-wiki db query + // on a wiki which doesn't have CU installed? + $this->log->warning( "LoginNotify: Error getting CU data for user no. {user} on " . $dbr->getWikiID(), [ + 'user' => $userId, + 'method' => __METHOD__, + 'exception' => $e, + 'wikiId' => $dbr->getWikiID() + ] ); + return false; + } + return $IPHasBeenUsedBefore; + } + + /** + * Check if we have any check user info for this user + * + * If we have no info for user, we maybe don't treat it as + * an unknown IP, since user has no known IPs. + * + * @todo FIXME Does this behaviour make sense, esp. with cookie check? + * @param $userId int User id number (possibly on foreign wiki) + * @param $dbr DatabaseBase DB connection (possibly to foreign wiki) + */ + private function checkUserInCheckUserAnyInfo( $userId, DatabaseBase $dbr ) { + // Verify that we actually have IP info for + // this user. + // @todo: Should this instead be if we have a + // a certain number of checkuser entries for this + // user. Or maybe it should be if we have at least + // 2 different IPs for this user. Or something else. + try { + $haveIPInfo = $dbr->selectField( + 'cu_changes', + '1', + [ + 'cuc_user' => $userId + ], + __METHOD__ + ); + } catch ( DBQueryError $e ) { + // Maybe we're doing a cross-wiki db query + // on a wiki which doesn't have CU installed? + $this->log->warning( "LoginNotify: Error getting CU data for user no. {user} on " . $dbr->getWikiID(), [ + 'user' => $userId, + 'method' => __METHOD__, + 'exception' => $e, + 'wikiId' => $dbr->getWikiID() + ] ); + return false; + } + + return $haveIPInfo; + } + + /** + * Give the user a cookie saying that they've previously logged in from this computer. + * + * @note If user already has a cookie, this will refresh it. + * @param $user User User in question who just logged in. + */ + private function setLoginCookie( User $user ) { + $cookie = $this->getPrevLoginCookie( $user->getRequest() ); + list( , $newCookie ) = $this->checkAndGenerateCookie( $user, $cookie ); + $expire = time() + $this->config->get( 'LoginNotifyCookieExpire' ); + $resp = $user->getRequest()->response(); + $resp->setCookie( + self::COOKIE_NAME, + $newCookie, + $expire, + [ + 'domain' => $this->config->get( 'LoginNotifyCookieDomain' ), + // Allow sharing this cookie between wikis + 'prefix' => '' + ] + ); + } + + /** + * Give the user a cookie and cache address in memcache + * + * It is expected this be called upon successful log in. + * + * @param $user User The user in question. + */ + public function setCurrentAddressAsKnown( User $user ) { + $this->cacheLoginIP( $user ); + $this->setLoginCookie( $user ); + } + + /** + * Cache the current IP subnet as being known location for user + * + * @param $user User + */ + private function cacheLoginIP( User $user ) { + // For simplicity, this only stores the last IP subnet used. + // Its assumed that most of the time, we'll be able to rely on + // the cookie or checkuser data. + $expiry = $this->config->get( 'LoginNotifyCacheLoginIPExpiry' ); + if ( $expiry !== false ) { + $ipPrefix = $this->getIPNetwork( $user->getRequest()->getIP() ); + $key = $this->getKey( $user, 'prevSubnet' ); + $res = $this->cache->set( $key, $ipPrefix, $expiry ); + } + } + + /** + * Check if a certain user is in the cookie. + * + * @param $user User User in question + * @return Mixed true, false or self::NO_INFO_AVAILABLE. + */ + private function checkUserInCookie( User $user ) { + $cookie = $this->getPrevLoginCookie( $user->getRequest() ); + if ( $cookie === '' ) { + // FIXME, does this really make sense? + return self::NO_INFO_AVAILABLE; + } + list( $userKnown, ) = $this->checkAndGenerateCookie( $user, $cookie ); + return $userKnown; + } + + /** + * Get the cookie with previous login names in it + * + * @param WebRequest + * @return String The cookie. Empty string if no cookie. + */ + private function getPrevLoginCookie( WebRequest $req ) { + return $req->getCookie( self::COOKIE_NAME, '', '' ); + } + + /** + * Check if user is in cookie, and generate a new cookie with user record + * + * When generating a new cookie, it will add the current user to the top, + * remove any previous instances of the current user, and remove older user + * references, if there is too many records. + * + * @param $user User User that person is attempting to log in as + * @param $cookie String A cookie, which has records separated by '!' + */ + private function checkAndGenerateCookie( User $user, $cookie ) { + $userSeenBefore = false; + if ( $cookie === '' ) { + $cookieRecords = []; + } else { + $cookieRecords = explode( '.', $cookie ); + } + $newCookie = $this->generateUserCookieRecord( $user->getName() ); + $maxCookieRecords = $this->config->get( 'LoginNotifyMaxCookieRecords' ); + + for( $i = 0; $i < count( $cookieRecords ); $i++ ) { + if ( !$this->validateCookieRecord( $cookieRecords[$i] ) ) { + // Skip invalid or old cookie records. + continue; + } + $curUser = $this->checkUserRecordGivenCookie( $user, $cookieRecords[$i] ); + $userSeenBefore = $userSeenBefore || $curUser; + if ( $i < $maxCookieRecords && !$curUser ) { + $newCookie .= '.' . $cookieRecords[$i]; + } + } + return [ $userSeenBefore, $newCookie ]; + } + + /** + * See if a specific cookie record is for a specific user. + * + * Cookie record format is: Year - 32-bit salt - hash + * where hash is sha1-HMAC of username + | + year + salt + * Salt and hash is base 36 encoded. + * + * The point of the salt is to ensure that a given user creates + * different cookies on different machines, so that nobody + * can after the fact figure out a single user has used both + * machines. + */ + private function checkUserRecordGivenCookie( User $user, $cookieRecord ) { + if ( !$this->validateCookieRecord( $cookieRecord ) ) { + // Most callers will probably already check this, but + // doesn't hurt to be careful. + return false; + } + $parts = explode( "-", $cookieRecord, 3 ); + $hash = $this->generateUserCookieRecord( $user->getName(), $parts[0], $parts[1] ); + return hash_equals( $hash, $cookieRecord ); + } + + /** + * Check if cookie is valid (Is not too old, has 3 fields) + * + * @param $cookieRecord Cookie record + * @return boolean True if valid + */ + private function validateCookieRecord( $cookieRecord ) { + $parts = explode( "-", $cookieRecord, 3 ); + if ( count( $parts ) !== 3 || strlen( $parts[0] ) !== 4 ) { + $this->log->warning( "Got cookie with invalid format", + [ + 'method' => __METHOD__, + 'cookieRecord' => $cookie + ] + ); + return false; + } + if ( (int)$parts[0] < gmdate( 'Y' ) - 3 ) { + // Record is too old. If user hasn't logged in from this + // computer in two years, should probably not consider it trusted. + return false; + } + return true; + } + + /** + * Generate a single record for use in the previous login cookie + * + * The format is YYYY-SSSSSSS-HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH + * where Y is the year, S is a 32-bit salt, H is an sha1-hmac. + * Both S and H are base-36 encoded. The actual cookie consists + * of several of these records separated by a ".". + * + * When checking if a hash is valid, provide all three arguments. + * When generating a new hash, only use the first argument. + * + * @param $username String Username, + * @param $year int [Optional] Year. Default to current year + * @param $salt string [Optional] Salt (expected to be base-36 encoded) + * @return String A record for the cookie + */ + private function generateUserCookieRecord( $username, $year = false, $salt = false ) { + if ( $year === false ) { + $year = gmdate( 'Y' ); + } + + if ( $salt === false ) { + $salt = wfBaseConvert( MWCryptRand::generateHex( 8 ), 16, 36 ); + } + + // FIXME Maybe shorten, e.g. User only half the hash? + $res = hash_hmac( 'sha1', $username . '|' . $year . $salt, $this->secret ); + if ( !is_string( $res ) ) { + throw new UnexpectedValueException( "Hash failed" ); + } + $encoded = $year . '-' . $salt . '-' . wfBaseConvert( $res, 16, 36 ); + return $encoded; + } + + /** + * Get the cache key for the counter. + * + * @param $user User + * @param $type string 'known' or 'new' + * @return string The cache key + */ + private function getKey( User $user, $type ) { + $userHash = Wikimedia\base_convert( sha1( $user->getName() ), 16, 36, 31 ); + return $this->cache->makeGlobalKey( + 'loginnotify', $type, $userHash + ); + } + + /** + * Increment hit counters for a failed login from an unknown computer. + * + * If a sufficient number of hits have accumulated, send an echo notice. + * + * @param User $user + */ + private function incNewIP( User $user ) { + $key = $this->getKey( $user, 'new' ); + $count = $this->checkAndIncKey( + $key, + $this->config->get( 'LoginNotifyAttemptsNewIP' ), + $this->config->get( 'LoginNotifyExpiryNewIP' ) + ); + if ( $count ) { + $this->sendNotice( $user, 'login-fail-new', $count ); + } + } + + /* + * Increment hit counters for a failed login from a known computer. + * + * If a sufficient number of hits have accumulated, send an echo notice. + * + * @param User $user + */ + private function incKnownIP( User $user ) { + $key = $this->getKey( $user, 'known' ); + $count = $this->checkAndIncKey( + $key, + $this->config->get( 'LoginNotifyAttemptsKnownIP' ), + $this->config->get( 'LoginNotifyExpiryKnownIP' ) + ); + if ( $count ) { + $this->sendNotice( $user, 'login-fail-known', $count ); + } + } + + /** + * Send a notice about login attempts + * + * @param $user User The account in question + * @param $type String 'login-fail-new' or 'login-fail-known' + * @param $count int [Optional] How many failed attempts + */ + private function sendNotice( User $user, $type, $count = null ) { + $extra = [ 'notifyAgent' => true ]; + if ( $count !== null ) { + $extra['count'] = $count; + } + EchoEvent::create( [ + 'type' => $type, + 'extra' => $extra, + 'agent' => $user, + ] ); + } + + /** + * Check if we've reached limit, and increment cache key. + * + * @param $key string cache key + * @param $max int interval of one to send notice + * @param $expiry When to expire cache key. + * @return Bool|int false to not send notice, or number of hits + */ + private function checkAndIncKey( $key, $interval, $expiry ) { + $cache = $this->cache; + $cur = $cache->incr( $key ); + if ( !$cur ) { + $cache->add( $key, 1, $expiry ); + $cur = 1; + } + if ( $cur % $interval === 0 ) { + return $cur; + } + return false; + } + + /** + * Clear attempt counter for user. + * + * When a user succesfully logs in, we start back from 0, as + * otherwise a mistake here and there will trigger the warning. + * + * @param $user User + */ + public function clearCounters( User $user ) { + $cache = $this->cache; + $keyKnown = $this->getKey( $user, 'known' ); + $keyNew = $this->getKey( $user, 'new' ); + + $cache->delete( $keyKnown ); + $cache->delete( $keyNew ); + } + + /** + * On login failure, record failure and maybe send notice + * + * @param $user User The user whose account was attempted to log into + */ + public function recordFailure( User $user ) { + $fromKnownIP = $this->isFromKnownIP( $user ); + if ( $fromKnownIP ) { + $this->incKnownIP( $user ); + } else { + $this->incNewIP( $user ); + } + } + + /** + * Send a notice on successful login if not known ip + * + * @param $user User Account in question + */ + public function sendSuccessNotice( User $user ) { + if ( $this->config->get( 'LoginNotifyEnableOnSuccess' ) + && !$this->isFromKnownIP( $user ) + ) { + $this->sendNotice( $user, 'login-success' ); + } + } +} diff --git a/extension.json b/extension.json new file mode 100644 index 0000000..06e71fa --- /dev/null +++ b/extension.json @@ -0,0 +1,68 @@ +{ + "name": "LoginNotify", + "version": "0.1", + "author": [ + "Brian Wolff" + ], + "url": "https://www.mediawiki.org/wiki/Extension:LoginNotify", + "descriptionmsg": "loginnotify-desc", + "license-name": "MIT", + "type": "other", + "DefaultUserOptions": { + "echo-subscriptions-web-login-fail": true, + "echo-subscriptions-email-login-fail": false, + "echo-subscriptions-web-login-success": false, + "echo-subscriptions-email-login-success": false + }, + "MessagesDirs": { + "LoginNotify": [ + "i18n" + ] + }, + "AutoloadClasses": { + "LoginNotify": "LoginNotify_body.php", + "LoginNotifyHooks": "LoginNotify.hooks.php", + "LoginNotifyPresentationModel": "LoginNotifyPresentationModel.php", + "LoginNotifyFormatter": "LoginNotifyFormatter.php" + }, + "Hooks": { + "BeforeCreateEchoEvent": [ + "LoginNotifyHooks::onBeforeCreateEchoEvent" + ], + "LoginAuthenticateAudit": [ + "LoginNotifyHooks::onLoginAuthenticateAudit" + ], + "AddNewAccount": [ + "LoginNotifyHooks::onAddNewAccount" + ], + "UserLoadOptions": [ + "LoginNotifyHooks::onUserLoadOptions" + ], + "UserSaveOptions": [ + "LoginNotifyHooks::onUserSaveOptions" + ], + "UnitTestsList": [ + "LoginNotifyHooks::onUnitTestList" + ] + }, + "config": { + "LoginNotifyAttemptsKnownIP": 15, + "LoginNotifyExpiryKnownIP": 604800, + "LoginNotifyAttemptsNewIP": 5, + "LoginNotifyExpiryNewIP": 1209600, + "LoginNotifyCheckKnownIPs": true, + "LoginNotifyEnableOnSuccess": true, + "@doc": "Enable notification for users with certain rights. To disable set to false", + "LoginNotifyEnableForPriv": [ "editinterface", "userrights" ], + "@doc": "Override this to use a different secret than $wgSecretKey", + "LoginNotifySecretKey": null, + "@doc": "Expiry in seconds. Default is 180 days", + "LoginNotifyCookieExpire": 15552000, + "@doc": "Override to allow sharing login cookies between sites on different subdomains", + "LoginNotifyCookieDomain": null, + "LoginNotifyMaxCookieRecords": 6, + "@doc": "Set to false to disable caching IPs in memcache. Set to 0 to cache forever. Default 60 days.", + "LoginNotifyCacheLoginIPExpiry": 5184000 + }, + "manifest_version": 1 +} diff --git a/i18n/en.json b/i18n/en.json new file mode 100644 index 0000000..5865900 --- /dev/null +++ b/i18n/en.json @@ -0,0 +1,21 @@ +{ + "@metadata": { + "authors": [ + "Brian Wolff" + ] + }, + "loginnotify-desc": "Notify users about suspicious logins", + "echo-category-title-login-fail": "Failed login attempts", + "echo-pref-tooltip-login-fail": "Notify me when there have been multiple failed attempts to login to my account.", + "echo-category-title-login-success": "Login from new computer", + "echo-pref-tooltip-login-success": "Notify me whenever somebody logs into my account from a computer I have not used before.", + "loginnotify-login-fail": "There have been several failed attempts to login to your account", + "notification-loginnotify-login-fail-email-subject": "{{PLURAL:$2|Failed attempt|Multiple failed attempts}} to login to {{SITENAME}} as $1", + "notification-loginnotify-login-success-email-subject": "Login to {{SITENAME}} as $1 from a computer you have not recently used", + "notification-header-login-fail-known": "There {{PLURAL:$3|has been '''one failed attempt'''|have been '''$3 failed attempts'''}} to login to your account since the last time you logged in.", + "notification-header-login-fail-new": "There {{PLURAL:$3|has been '''one failed attempt'''|have been '''$3 failed attempts'''}} to login to your account from a computer you have not edited from recently, since the last time you logged in.", + "notification-header-login-success": "Someone has successfully logged into your account from a computer which you have not edited from recently.", + "notification-loginnotify-login-fail-new-emailbatch": "There {{PLURAL:$2|has been a failed attempt|have been $2 failed attempts}} to login to your account '$1' on {{SITENAME}} from a computer you haven't used before.", + "notification-loginnotify-login-fail-known-emailbatch": "There {{PLURAL:$2|has been a failed attempt|have been $2 failed attempts}} to login to your account, '$1' on {{SITENAME}}.", + "notification-loginnotify-login-success-emailbatch": "Someone has successfully logged into your account '$1' on {{SITENAME}} from a computer which you have not edited from recently." +} diff --git a/i18n/qqq.json b/i18n/qqq.json new file mode 100644 index 0000000..96d429d --- /dev/null +++ b/i18n/qqq.json @@ -0,0 +1,22 @@ +{ + "@metadata": { + "authors": [ + "Brian Wolff" + ] + }, + "loginnotify-desc": "{{desc|name=LoginNotify|url=https://www.mediawiki.org/wiki/Extension:LoginNotify}}", + "echo-category-title-login-fail": "Label for 2 checkboxes in the Notifications section of [[Special:Preferences]]. The checkbox controls if the user receives notification on someone attempting unsuccessfully to log in as them.", + "echo-pref-tooltip-login-fail": "Help tooltip for {{msg-mw|echo-category-title-login-fail}}", + "echo-category-title-login-success": "Label for 2 checkboxes in the Notifications section of [[Special:Preferences]]. The checkbox controls if the user receives notification on someone logging in to this account from a computer that has not previously been used to log in by this user.", + "echo-pref-tooltip-login-success": "Help tooltip for {{msg-mw|echo-category-title-login-success}}", + "loginnotify-login-fail": "FIXME I have no idea where this message is used.\n{{notranslate}}", + "notification-loginnotify-login-fail-email-subject": "Email subject line for email notice that there have been failed login attempts for the user's account. $1 is the user in question.", + "notification-loginnotify-login-success-email-subject": "Email subject line for email notice that someone has logged in to the user's account from a computer that has not previously been used. $1 is the user in question.", + "notification-header-login-fail-known": "Text of notification for failed login attempt from a known computer. This is shown in the user's echo notifications, and is emailed to the user.\n* $1 = Username for account, formatted for display\n* $2 = Username for account, unformatted for use in GENDER\n* $3 = Number of failed login attempts so far", + "notification-header-login-fail-new": "Text of notification for failed login attempt from a computeri that is not recognized. This is shown in the user's echo notifications, and is emailed to the user.\n* $1 = Username for account, formatted for display\n* $2 = Username for account, unformatted for use in GENDER\n* $3 = Number of failed login attempts so far", + "notification-header-login-success": "Text of notification for when someone successfully logs in as the current account from a computer that has not been previously used by that account. This is shown in the user's echo notifications, and is possibly emailed to the user.\n* $1 = Username for account, formatted for display\n* $2 = Username for account, unformatted for use in GENDER", + "notification-loginnotify-login-fail-new-emailbatch": "Body of email notification that someone from a computer not previously used by the user has attempted and failed to log into the user's account. Subject of Message is {{msg-mw|notification-loginnotify-login-fail-email-subject}}. $1 is account name. $2 is the number of attempts", + "notification-loginnotify-login-fail-known-emailbatch": "Body of email notification that someone from a computer which has been previously used by the user has attempted and failed to log into the user's account. Subject of Message is {{msg-mw|notification-loginnotify-login-fail-email-subject}}. $1 is account name. $2 is the number of attempts", + "notification-loginnotify-login-success-emailbatch": "Body of email notification that someone from a computer not previously used by the user has succesfully logged into the user's account. Subject of Message is {{msg-mw|notification-loginnotify-login-success-email-subject}}. $1 is account name." + +} diff --git a/tests/LoginNotifyTests.php b/tests/LoginNotifyTests.php new file mode 100644 index 0000000..9fe5203 --- /dev/null +++ b/tests/LoginNotifyTests.php @@ -0,0 +1,312 @@ + 15, + "LoginNotifyExpiryKnownIP" => 604800, + "LoginNotifyAttemptsNewIP" => 5, + "LoginNotifyExpiryNewIP" => 1209600, + "LoginNotifyCheckKnownIPs" => true, + "LoginNotifyEnableOnSuccess" => true, + "LoginNotifyEnableForPriv" => [ "editinterface", "userrights" ], + "LoginNotifySecretKey" => "Secret Stuff!", + "LoginNotifyCookieExpire" => 15552000, + "LoginNotifyCookieDomain" => null, + "LoginNotifyMaxCookieRecords" => 6, + "LoginNotifyCacheLoginIPExpiry" => 60*60*24*60 + ] ); + $this->inst = TestingAccessWrapper::newFromObject( + new LoginNotify( + $config, + new HashBagOStuff, + new NullLogger + ) + ); + } + + public function setUp() { + $this->setUpLoginNotify(); + return parent::setUp(); + } + + /** + * @dataProvider provideGetIPNetwork + */ + public function testGetIPNetwork( $ip, $expected ) { + $actual = $this->inst->getIPNetwork( $ip ); + $this->assertSame( $expected, $actual ); + } + + public function provideGetIPNetwork() { + return [ + [ '127.0.0.1', '127.0.0.' ], + [ '118.221.191.18', '118.221.191.' ], + [ '::1', '0:0:0:0:' ], + [ '1:2:3:4:5:6:7:8', '1:2:3:4:' ], + [ '1:ffe1::7:8', '1:FFE1:0:0:' ], + [ 'd3::1:2:3:4:5:6', 'D3:0:1:2:' ] + ]; + } + + /** + * @expectedException UnexpectedValueException + */ + public function testGetIPNetworkInvalid() { + $this->inst->getIPNetwork( 'localhost' ); + } + + /** + * @param $username string + * @param $year int + * @param $salt string + * @param $expected string + * @dataProvider provideGenerateUserCookieRecord + */ + public function testGenerateUserCookieRecord( $username, $year, $salt, $expected ) { + $actual = $this->inst->generateUserCookieRecord( $username, $year, $salt ); + $this->assertEquals( $expected, $actual ); + } + + public function provideGenerateUserCookieRecord() { + return [ + [ 'Foo', 2011, 'a4321f', '2011-a4321f-8oerxg4l59zpiu0by7m2to1b4cjeer4' ], + [ 'Foo', 2011, 'A4321f', '2011-A4321f-in65gc2i9czojfopkeieijc0ek8j5vu' ], + [ 'Foo', 2015, 'a4321f', '2015-a4321f-2hf2zh9h3afv79b1u4l474ozc0by2xe' ], + [ 'FOo', 2011, 'a4321f', '2011-a4321f-d0dhdzxg3te3yd3np6xdfrwdrckop7m' ], + ]; + } + + /** + * @dataProvider provideCheckUserRecordGivenCookie + */ + public function testCheckUserRecordGivenCookie( User $user, $cookieRecord, $expected, $desc ) { + $actual = $this->inst->checkUserRecordGivenCookie( $user, $cookieRecord ); + $this->assertEquals( $expected, $actual, "For {$user->getName()} on test $desc." ); + } + + public function provideCheckUserRecordGivenCookie() { + $this->setUpLoginNotify(); + $u = User::newFromName( 'Foo', false ); + $cookie1 = $this->inst->generateUserCookieRecord( 'Foo2' ); + $cookie2 = $this->inst->generateUserCookieRecord( 'Foo' ); + $cookie3 = $this->inst->generateUserCookieRecord( 'Foo', gmdate( 'Y' ) - 2 ); + $cookie4 = $this->inst->generateUserCookieRecord( 'Foo', gmdate( 'Y' ), 'RAND' ); + $cookie5 = $this->inst->generateUserCookieRecord( 'Foo', gmdate( 'Y' ) - 4 ); + return [ + [ $u, '2015-in65gc2i9czojfopkeieijc0ek8j5vu', false, "no salt" ], + [ $u, '2011-A4321f-in65gc2i9czojfopkeieijc0ek8j5vu', false, "too old" ], + [ $u, $cookie1, false, "name mismatch" ], + [ $u, $cookie2, true, "normal" ], + [ $u, $cookie3, true, "2 year old" ], + [ $u, $cookie4, true, "Specific salt" ], + [ $u, $cookie5, false, "4 year old" ], + ]; + } + + public function testGetPrevLoginCookie() { + $req = new FauxRequest(); + $res1 = $this->inst->getPrevLoginCookie( $req ); + $this->assertEquals( '', $res1, "no cookie set" ); + + $req->setCookie( 'mw_prevLogin', 'foo', '' ); + $res2 = $this->inst->getPrevLoginCookie( $req ); + $this->assertEquals( 'foo', $res2, "get dummy cookie" ); + } + + public function testGetKey() { + $user1 = User::newFromName( 'Foo_bar' ); + // Make sure proper normalization happens. + $user2 = User::newFromName( 'Foo__bar' ); + $user3 = User::newFromName( 'Somebody' ); + + $this->assertEquals( + 'global:loginnotify:new:ok2qitd5efi25tzjy2l3el4n57g6l3l', + $this->inst->getKey( $user1, 'new' ) + ); + $this->assertEquals( + 'global:loginnotify:known:ok2qitd5efi25tzjy2l3el4n57g6l3l', + $this->inst->getKey( $user1, 'known' ) + ); + $this->assertEquals( + 'global:loginnotify:new:ok2qitd5efi25tzjy2l3el4n57g6l3l', + $this->inst->getKey( $user2, 'new' ) + ); + $this->assertEquals( + 'global:loginnotify:new:tuwpi7e2h9pidovmaxxswk6aq327ewg', + $this->inst->getKey( $user3, 'new' ) + ); + } + + public function testCheckAndIncKey() { + $key = 'global:loginnotify:new:tuwpi7e2h9pidovmaxxswk6aq327ewg'; + for ( $i = 1; $i < 5; $i++ ) { + $res = $this->inst->checkAndIncKey( $key, 5, 3600 ); + $this->assertFalse( $res, "key check numb $i" ); + } + $this->assertEquals( 5, $this->inst->checkAndIncKey( $key, 5, 3600 ) ); + for ( $i = 1; $i < 5; $i++ ) { + $res = $this->inst->checkAndIncKey( $key, 5, 3600 ); + $this->assertFalse( $res, "key check numb $i+5" ); + } + $this->assertEquals( 10, $this->inst->checkAndIncKey( $key, 5, 3600 ) ); + + $key2 = 'global:loginnotify:known:tuwpi7e2h9pidovmaxxswk6aq327ewg'; + for ( $i = 1; $i < 5; $i++ ) { + $res = $this->inst->checkAndIncKey( $key2, 1, 3600 ); + $this->assertEquals( $i, $res, "key check interval 1 numb $i" ); + } + } + + /** + * @dataProvider provideClearCounters + */ + public function testClearCounters( $key ) { + $user = User::newFromName( "Fred" ); + $key = $this->inst->getKey( $user, $key ); + + $this->inst->checkAndIncKey( $key, 1, 3600 ); + $res = $this->inst->checkAndIncKey( $key, 1, 3600 ); + $this->assertEquals( 2, $res, "prior to clear" ); + $this->inst->clearCounters( $user ); + $res = $this->inst->checkAndIncKey( $key, 1, 3600 ); + $this->assertEquals( 1, $res, "after clear" ); + } + + public function provideClearCounters() { + return [ + [ 'new' ], + [ 'known' ], + ]; + } + + /** + * @note Expected new cookie does not include first record, as + * first record depends on random numbers. + * @dataProvider provideCheckAndGenerateCookie + */ + public function testCheckAndGenerateCookie( + User $user, + $cookie, + $expectedSeenBefore, + $expectedNewCookie, + $desc + ) { + list( $actualSeenBefore, $actualNewCookie ) = $this->inst->checkAndGenerateCookie( $user, $cookie ); + + $this->assertEquals( $expectedSeenBefore, $actualSeenBefore, "[Seen before] $desc" ); + $newCookieParts = explode( '.', $actualNewCookie, 2 ); + if ( !isset( $newCookieParts[1] ) ) { + $newCookieParts[1] = ''; + } + $this->assertTrue( $this->inst->checkUserRecordGivenCookie( $user, $newCookieParts[0] ), "[Cookie new entry] $desc" ); + $this->assertEquals( $expectedNewCookie, $newCookieParts[1], "[Cookie] $desc" ); + } + + public function provideCheckAndGenerateCookie() { + $this->setUpLoginNotify(); + $y = gmdate( 'Y' ); + $oldYear = $curYear - 3; + $u1 = User::newFromName( 'Foo' ); + + $cookie1 = $this->inst->generateUserCookieRecord( 'Foo' ); + $cookie2 = $this->inst->generateUserCookieRecord( 'Foo' ); + $cookieOld = $this->inst->generateUserCookieRecord( 'Foo', 2001 ); + $cookieOtherUser = "$y-1veusdo-tr049njztrrvkkz4tk3kre8rm1zb134"; + return [ + [ $u1, '', false, '', "no cookie" ], + [ + $u1, + "$cookieOtherUser", + false, + "$cookieOtherUser", + "not in cookie" + ], + [ + $u1, + "$cookieOtherUser.$y-.$y-abcdefg-8oerxg4l59zpiu0by7m2to1b4cjeer4.$oldYear-1234567-tpnsk00419wba6vjh1upif21qtst1cv", + false, + "$cookieOtherUser.$y-abcdefg-8oerxg4l59zpiu0by7m2to1b4cjeer4", + "old values in cookie" + ], + [ + $u1, + $cookieOld, + false, + "", + "Only old value" + ], + [ + $u1, + $cookie1, + true, + "", + "Normal success" + ], + [ + $u1, + "$cookieOtherUser.$cookie1.$cookie2." + . "$y-1234567-tpnsk00419wba6vjh1upif21qtst1cv.$cookie1", + true, + "$cookieOtherUser.$y-1234567-tpnsk00419wba6vjh1upif21qtst1cv", + "Remove all of current user." + ], + [ + $u1, + "$cookieOtherUser.$cookieOtherUser.$cookieOtherUser." + . "$cookieOtherUser.$cookieOtherUser.$cookieOtherUser." + . "$cookieOtherUser.$cookieOtherUser.$cookieOtherUser." + . "$cookieOtherUser.$cookieOtherUser.$cookieOtherUser." + . "$cookieOtherUser.$cookieOtherUser.$cookieOtherUser." + . "$cookieOtherUser.$cookieOtherUser.$cookieOtherUser." + . "$cookieOtherUser.$cookieOtherUser.$cookieOtherUser." + . "$cookieOtherUser.$cookieOtherUser.$cookieOtherUser." + . "$cookieOtherUser.$cookieOtherUser.$cookieOtherUser." + . "$cookieOtherUser.$cookieOtherUser.$cookieOtherUser." + . "$cookieOtherUser.$cookieOtherUser.$cookieOtherUser", + false, + "$cookieOtherUser.$cookieOtherUser.$cookieOtherUser." + . "$cookieOtherUser.$cookieOtherUser.$cookieOtherUser", + "Limit max number of records." + ] + ]; + } + + /** + * @dataProvider provideValidateCookieRecord + */ + public function testValidateCookieRecord( $cookie, $expected ) { + $this->assertEquals($expected, $this->inst->validateCookieRecord( $cookie ) ); + } + + public function provideValidateCookieRecord() { + $y = gmdate( 'Y' ); + return [ + [ 'fdakslnknfaknasf', false ], + [ '--', false ], + [ '91-ffff-fdaskfjlasflasd', false ], + [ '2011-ffff-fdaskfjlasflasd', false ], + [ "$y-1veusdo-tr049njztrrvkkz4tk3kre8rm1zb134", true ], + [ "1991-1veusdo-tr049njztrrvkkz4tk3kre8rm1zb134", false ], + ]; + } + + public function testCheckUserInCache() { + $u = User::newFromName( 'Xyzzy' ); + $uWrap = TestingAccessWrapper::newFromObject( $u ); + $this->assertEquals( $this->inst->checkUserInCache( $u ), LoginNotify::NO_INFO_AVAILABLE ); + + $this->inst->cacheLoginIP( $u ); + $this->assertTrue( $this->inst->checkUserInCache( $u ) ); + + $uWrap->mRequest = new FauxRequest(); + $uWrap->mRequest->setIP( '10.1.2.3' ); + + $this->assertFalse( $this->inst->checkUserInCache( $u ) ); + } +}