mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/LoginNotify
synced 2024-11-28 00:40:38 +00:00
Initial version of extension to notify people on failed login attempts.
Bug: T11838 Change-Id: Id96aff70f06879a7b754b19af8e65c0f641e84e0
This commit is contained in:
parent
3042154017
commit
fea1a38de5
21
COPYING
Normal file
21
COPYING
Normal 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
30
Lock.svg
Normal 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
235
LoginNotify.hooks.php
Normal 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
19
LoginNotifyFormatter.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
}
|
44
LoginNotifyPresentationModel.php
Normal file
44
LoginNotifyPresentationModel.php
Normal 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
640
LoginNotify_body.php
Normal 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
68
extension.json
Normal 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
21
i18n/en.json
Normal 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
22
i18n/qqq.json
Normal 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
312
tests/LoginNotifyTests.php
Normal 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 ) );
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue