Initial version of extension to notify people on failed login attempts.

Bug: T11838
Change-Id: Id96aff70f06879a7b754b19af8e65c0f641e84e0
This commit is contained in:
Brian Wolff 2016-03-28 04:26:48 -04:00
parent 3042154017
commit fea1a38de5
10 changed files with 1412 additions and 0 deletions

21
COPYING Normal file
View file

@ -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.

30
Lock.svg Normal file
View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
width="300"
height="300"
title="Lock icon"
version="1.1"
>
<g
transform="translate(0,-752.3622)">
<rect
style="fill:#ffffff;fill-opacity:1;stroke:#575757;stroke-width:169;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0"
id="rect3592"
width="5.3447614"
height="3.4287148"
x="147.32762"
y="933.18579" />
<path
sodipodi:type="arc"
style="fill:none;stroke:#575757;stroke-width:3.46316123;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0"
id="path3594"
sodipodi:cx="156.18221"
sodipodi:cy="107.04989"
sodipodi:rx="11.713666"
sodipodi:ry="28.958786"
d="m 167.89587,107.04989 a 11.713666,28.958786 0 1 1 -23.42733,0 11.713666,28.958786 0 1 1 23.42733,0 z"
transform="matrix(5.4683279,0,0,3.8748291,-704.05551,467.714)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

235
LoginNotify.hooks.php Normal file
View file

@ -0,0 +1,235 @@
<?php
/**
* Body of LoginNotify extension
*
* @file
* @ingroup Extensions
*/
class LoginNotifyHooks {
const OPTIONS_FAKE_TRUTH = 2;
const OPTIONS_FAKE_FALSE = 'fake-false';
/**
* Add LoginNotify events to Echo
*
* @param $notifications array of Echo notifications
* @param $notificationCategories array of Echo notification categories
* @param $icons array of icon details
* @return bool
*/
public static function onBeforeCreateEchoEvent( &$notifications, &$notificationCategories, &$icons ) {
global $wgLoginNotifyEnableOnSuccess;
$icons['LoginNotify-lock'] = [
'path' => '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;
}
}

19
LoginNotifyFormatter.php Normal file
View file

@ -0,0 +1,19 @@
<?php
class LoginNotifyFormatter extends EchoBasicFormatter {
/**
* Add the number of attempts as a param to the email.
*
* @param $event EchoEvent
* @param $param
* @param $message Message
* @param $user User
*/
protected function processParam( $event, $param, $message, $user ) {
if ( $param === 'count' ) {
$message->params( $event->getExtraParam( 'count' ) );
} else {
parent::processParam( $event, $param, $message, $user );
}
}
}

View file

@ -0,0 +1,44 @@
<?php
class LoginNotifyPresentationModel extends EchoEventPresentationModel {
/**
* Show a lock icon, for account security.
*
* @return String Name of icon
*/
public function getIconType() {
return 'LoginNotify-lock';
}
/**
* Nothing really to link to
*
* @return boolean false to disable link
*/
public function getPrimaryLink() {
return false;
}
/**
* Include the number of attempts in the message
*
* (For grep) This uses i18n messages:
* notification-header-login-fail-known
* notification-header-login-fail-new
* notification-header-login-success
*
* @return Message
*/
public function getHeaderMessage() {
return parent::getHeaderMessage()->numParams(
$this->event->getExtraParam( 'count', 0 )
);
}
/**
* @todo FIXME Unclear if this is a good idea
*/
public function getSecondaryLinks() {
return array( $this->getAgentLink() );
}
}

640
LoginNotify_body.php Normal file
View file

@ -0,0 +1,640 @@
<?php
/**
* Body of LoginNotify extension
*
* @file
* @ingroup Extensions
*/
use MediaWiki\Logger\LoggerFactory;
use Psr\Log\LoggerInterface;
/**
* Handle sending notifications on login from unknown source.
*
* @author Brian Wolff
*/
class LoginNotify {
const COOKIE_NAME = 'mw_prevLogin';
const NO_INFO_AVAILABLE = 2;
/** @var BagOStuff */
private $cache;
/** @var Config */
private $config;
/** @var LoggerInterface Usually instance of LoginNotify log */
private $log;
/**
* Constructor
*
* @param $cfg Config Optional. Set if you have handy.
* @param $cache BagOStuff Optional. Most callers should not set this
* @param $log LoggerInterface Optional. Most callers should not set this.
*/
function __construct( Config $cfg = null, BagOStuff $cache = null, LoggerInterface $log = null ) {
if ( !$cache ) {
$cache = ObjectCache::getLocalClusterInstance();
}
if ( !$cfg ) {
$cfg = RequestContext::getMain()->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' );
}
}
}

68
extension.json Normal file
View file

@ -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
}

21
i18n/en.json Normal file
View file

@ -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."
}

22
i18n/qqq.json Normal file
View file

@ -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."
}

312
tests/LoginNotifyTests.php Normal file
View file

@ -0,0 +1,312 @@
<?php
use \Psr\Log\NullLogger;
class LoginNotifyTests extends MediaWikiTestCase {
/** @var LoginNotify */
private $inst;
public function setUpLoginNotify() {
$config = new HashConfig( [
"LoginNotifyAttemptsKnownIP" => 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 ) );
}
}