mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/ConfirmEdit
synced 2024-11-15 03:35:15 +00:00
8180e6450d
Some of this is not necessary at all because all properties are public anyway. In other cases only one property needs to use the wrapper, the rest is public and can be accessed normally. In other cases we just missed the @var annotation to make the IDE aware of what's actually going on. Change-Id: I29b526ee3aad9f0c9671fb133c625b9f14309db9
330 lines
13 KiB
PHP
330 lines
13 KiB
PHP
<?php
|
|
|
|
use MediaWiki\Auth\AuthManager;
|
|
use MediaWiki\Auth\UsernameAuthenticationRequest;
|
|
use MediaWiki\Context\RequestContext;
|
|
use MediaWiki\Extension\ConfirmEdit\Auth\CaptchaAuthenticationRequest;
|
|
use MediaWiki\Extension\ConfirmEdit\Auth\CaptchaPreAuthenticationProvider;
|
|
use MediaWiki\Extension\ConfirmEdit\Auth\LoginAttemptCounter;
|
|
use MediaWiki\Extension\ConfirmEdit\Hooks;
|
|
use MediaWiki\Extension\ConfirmEdit\SimpleCaptcha\SimpleCaptcha;
|
|
use MediaWiki\Extension\ConfirmEdit\Store\CaptchaHashStore;
|
|
use MediaWiki\Extension\ConfirmEdit\Store\CaptchaStore;
|
|
use MediaWiki\Request\FauxRequest;
|
|
use MediaWiki\Tests\Unit\Auth\AuthenticationProviderTestTrait;
|
|
use MediaWiki\User\User;
|
|
use Wikimedia\TestingAccessWrapper;
|
|
|
|
/**
|
|
* @covers \MediaWiki\Extension\ConfirmEdit\Auth\CaptchaPreAuthenticationProvider
|
|
* @group Database
|
|
*/
|
|
class CaptchaPreAuthenticationProviderTest extends MediaWikiIntegrationTestCase {
|
|
use AuthenticationProviderTestTrait;
|
|
|
|
public function setUp(): void {
|
|
parent::setUp();
|
|
$this->overrideConfigValues( [
|
|
'CaptchaClass' => SimpleCaptcha::class,
|
|
'CaptchaBadLoginAttempts' => 1,
|
|
'CaptchaBadLoginPerUserAttempts' => 1,
|
|
'CaptchaStorageClass' => CaptchaHashStore::class,
|
|
] );
|
|
CaptchaStore::unsetInstanceForTests();
|
|
CaptchaStore::get()->clearAll();
|
|
}
|
|
|
|
public function tearDown(): void {
|
|
parent::tearDown();
|
|
/** @var Hooks $req */
|
|
$req = TestingAccessWrapper::newFromClass( Hooks::class );
|
|
// make sure $wgCaptcha resets between tests
|
|
$req->instanceCreated = false;
|
|
}
|
|
|
|
/**
|
|
* @dataProvider provideGetAuthenticationRequests
|
|
*/
|
|
public function testGetAuthenticationRequests(
|
|
$action, $useExistingUserOrNull, $triggers, $needsCaptcha, $preTestCallback = null
|
|
) {
|
|
if ( $useExistingUserOrNull === true ) {
|
|
$username = $this->getTestSysop()->getUserIdentity()->getName();
|
|
} elseif ( $useExistingUserOrNull === false ) {
|
|
$username = 'Foo';
|
|
} else {
|
|
$username = null;
|
|
}
|
|
$this->setTriggers( $triggers );
|
|
if ( $preTestCallback ) {
|
|
$fn = array_shift( $preTestCallback );
|
|
call_user_func_array( [ $this, $fn ], $preTestCallback );
|
|
}
|
|
|
|
/** @var FauxRequest $request */
|
|
$request = RequestContext::getMain()->getRequest();
|
|
$request->setCookie( 'UserName', $username );
|
|
|
|
$provider = new CaptchaPreAuthenticationProvider();
|
|
$this->initProvider( $provider, null, null, $this->getServiceContainer()->getAuthManager() );
|
|
$reqs = $provider->getAuthenticationRequests( $action, [ 'username' => $username ] );
|
|
if ( $needsCaptcha ) {
|
|
$this->assertCount( 1, $reqs );
|
|
$this->assertInstanceOf( CaptchaAuthenticationRequest::class, $reqs[0] );
|
|
} else {
|
|
$this->assertSame( [], $reqs );
|
|
}
|
|
}
|
|
|
|
public static function provideGetAuthenticationRequests() {
|
|
return [
|
|
[ AuthManager::ACTION_LOGIN, null, [], false ],
|
|
[ AuthManager::ACTION_LOGIN, null, [ 'badlogin' ], false ],
|
|
[ AuthManager::ACTION_LOGIN, null, [ 'badlogin' ], true, [ 'blockLogin', 'Foo' ] ],
|
|
[ AuthManager::ACTION_LOGIN, null, [ 'badloginperuser' ], false, [ 'blockLogin', 'Foo' ] ],
|
|
[ AuthManager::ACTION_LOGIN, false, [ 'badloginperuser' ], false, [ 'blockLogin', 'Bar' ] ],
|
|
[ AuthManager::ACTION_LOGIN, false, [ 'badloginperuser' ], true, [ 'blockLogin', 'Foo' ] ],
|
|
[ AuthManager::ACTION_LOGIN, null, [ 'badloginperuser' ], true, [ 'flagSession' ] ],
|
|
[ AuthManager::ACTION_CREATE, null, [], false ],
|
|
[ AuthManager::ACTION_CREATE, null, [ 'createaccount' ], true ],
|
|
[ AuthManager::ACTION_CREATE, true, [ 'createaccount' ], false ],
|
|
[ AuthManager::ACTION_LINK, null, [], false ],
|
|
[ AuthManager::ACTION_CHANGE, null, [], false ],
|
|
[ AuthManager::ACTION_REMOVE, null, [], false ],
|
|
];
|
|
}
|
|
|
|
public function testGetAuthenticationRequests_store() {
|
|
$this->setTriggers( [ 'createaccount' ] );
|
|
$captcha = new SimpleCaptcha();
|
|
$provider = new CaptchaPreAuthenticationProvider();
|
|
$this->initProvider( $provider, null, null, $this->getServiceContainer()->getAuthManager() );
|
|
|
|
$reqs = $provider->getAuthenticationRequests( AuthManager::ACTION_CREATE,
|
|
[ 'username' => 'Foo' ] );
|
|
|
|
$this->assertCount( 1, $reqs );
|
|
/** @var CaptchaAuthenticationRequest $req */
|
|
$req = $reqs[0];
|
|
$this->assertInstanceOf( CaptchaAuthenticationRequest::class, $req );
|
|
|
|
$id = $req->captchaId;
|
|
$data = $req->captchaData;
|
|
$this->assertEquals( $captcha->retrieveCaptcha( $id ), $data + [ 'index' => $id ] );
|
|
}
|
|
|
|
/**
|
|
* @dataProvider provideTestForAuthentication
|
|
*/
|
|
public function testTestForAuthentication( $req, $isBadLoginTriggered,
|
|
$isBadLoginPerUserTriggered, $result
|
|
) {
|
|
$this->setTemporaryHook( 'PingLimiter', static function ( $user, $action, &$result ) {
|
|
$result = false;
|
|
return false;
|
|
} );
|
|
CaptchaStore::get()->store( '345', [ 'question' => '2+2', 'answer' => '4' ] );
|
|
$loginAttemptCounter = $this->getMockBuilder( LoginAttemptCounter::class )
|
|
->onlyMethods( [ 'isBadLoginTriggered', 'isBadLoginPerUserTriggered' ] )
|
|
->disableOriginalConstructor()
|
|
->getMock();
|
|
$loginAttemptCounter->expects( $this->any() )->method( 'isBadLoginTriggered' )
|
|
->willReturn( $isBadLoginTriggered );
|
|
$loginAttemptCounter->expects( $this->any() )->method( 'isBadLoginPerUserTriggered' )
|
|
->willReturn( $isBadLoginPerUserTriggered );
|
|
$provider = $this->getProvider();
|
|
$provider->loginAttemptCounter = $loginAttemptCounter;
|
|
$this->initProvider( $provider, null, null, $this->getServiceContainer()->getAuthManager() );
|
|
|
|
$status = $provider->testForAuthentication( $req ? [ $req ] : [] );
|
|
|
|
$this->assertEquals( $result, $status->isGood() );
|
|
}
|
|
|
|
public static function provideTestForAuthentication() {
|
|
$fallback = new UsernameAuthenticationRequest();
|
|
$fallback->username = 'Foo';
|
|
return [
|
|
// [ auth request, bad login?, bad login per user?, result ]
|
|
'no need to check' => [ $fallback, false, false, true ],
|
|
'badlogin' => [ $fallback, true, false, false ],
|
|
'badloginperuser, no username' => [ null, false, true, true ],
|
|
'badloginperuser' => [ $fallback, false, true, false ],
|
|
'non-existent captcha' => [ self::getCaptchaRequest( '123', '4' ), true, true, false ],
|
|
'wrong captcha' => [ self::getCaptchaRequest( '345', '6' ), true, true, false ],
|
|
'correct captcha' => [ self::getCaptchaRequest( '345', '4' ), true, true, true ],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @dataProvider provideTestForAccountCreation
|
|
*/
|
|
public function testTestForAccountCreation( $req, $creatorIsSysop, $result, $disableTrigger = false ) {
|
|
$this->setTemporaryHook( 'PingLimiter', static function ( $user, $action, &$result ) {
|
|
$result = false;
|
|
return false;
|
|
} );
|
|
$this->setTriggers( $disableTrigger ? [] : [ 'createaccount' ] );
|
|
CaptchaStore::get()->store( '345', [ 'question' => '2+2', 'answer' => '4' ] );
|
|
$user = User::newFromName( 'Foo' );
|
|
$provider = new CaptchaPreAuthenticationProvider();
|
|
$this->initProvider( $provider, null, null, $this->getServiceContainer()->getAuthManager() );
|
|
|
|
$creator = $creatorIsSysop ? $this->getTestSysop()->getUser() : User::newFromName( 'Bar' );
|
|
$status = $provider->testForAccountCreation( $user, $creator, $req ? [ $req ] : [] );
|
|
$this->assertEquals( $result, $status->isGood() );
|
|
}
|
|
|
|
public static function provideTestForAccountCreation() {
|
|
return [
|
|
// [ auth request, creator, result, disable trigger? ]
|
|
'no captcha' => [ null, false, false ],
|
|
'non-existent captcha' => [ self::getCaptchaRequest( '123', '4' ), false, false ],
|
|
'wrong captcha' => [ self::getCaptchaRequest( '345', '6' ), false, false ],
|
|
'correct captcha' => [ self::getCaptchaRequest( '345', '4' ), false, true ],
|
|
'user is exempt' => [ null, true, true ],
|
|
'disabled' => [ null, false, true, 'disable' ],
|
|
];
|
|
}
|
|
|
|
public function testPostAuthentication() {
|
|
$this->setTriggers( [ 'badlogin', 'badloginperuser' ] );
|
|
$captcha = new SimpleCaptcha();
|
|
$user = User::newFromName( 'Foo' );
|
|
$anotherUser = User::newFromName( 'Bar' );
|
|
$provider = $this->getProvider();
|
|
$loginAttemptCounter = new LoginAttemptCounter( $captcha );
|
|
$provider->loginAttemptCounter = $loginAttemptCounter;
|
|
$this->initProvider( $provider, null, null, $this->getServiceContainer()->getAuthManager() );
|
|
|
|
$this->assertFalse( $loginAttemptCounter->isBadLoginTriggered() );
|
|
$this->assertFalse( $loginAttemptCounter->isBadLoginPerUserTriggered( $user ) );
|
|
|
|
$provider->postAuthentication( $user, \MediaWiki\Auth\AuthenticationResponse::newFail(
|
|
wfMessage( '?' ) ) );
|
|
|
|
$this->assertTrue( $loginAttemptCounter->isBadLoginTriggered() );
|
|
$this->assertTrue( $loginAttemptCounter->isBadLoginPerUserTriggered( $user ) );
|
|
$this->assertFalse( $loginAttemptCounter->isBadLoginPerUserTriggered( $anotherUser ) );
|
|
|
|
$provider->postAuthentication( $user, \MediaWiki\Auth\AuthenticationResponse::newPass( 'Foo' ) );
|
|
|
|
$this->assertFalse( $loginAttemptCounter->isBadLoginPerUserTriggered( $user ) );
|
|
}
|
|
|
|
public function testPostAuthentication_disabled() {
|
|
$this->setTriggers( [] );
|
|
$captcha = new SimpleCaptcha();
|
|
$loginAttemptCounter = new LoginAttemptCounter( $captcha );
|
|
$user = User::newFromName( 'Foo' );
|
|
$provider = $this->getProvider();
|
|
$provider->loginAttemptCounter = $loginAttemptCounter;
|
|
$this->initProvider( $provider, null, null, $this->getServiceContainer()->getAuthManager() );
|
|
|
|
$this->assertFalse( $loginAttemptCounter->isBadLoginTriggered() );
|
|
$this->assertFalse( $loginAttemptCounter->isBadLoginPerUserTriggered( $user ) );
|
|
|
|
$provider->postAuthentication( $user, \MediaWiki\Auth\AuthenticationResponse::newFail(
|
|
wfMessage( '?' ) ) );
|
|
|
|
$this->assertFalse( $loginAttemptCounter->isBadLoginTriggered() );
|
|
$this->assertFalse( $loginAttemptCounter->isBadLoginPerUserTriggered( $user ) );
|
|
}
|
|
|
|
/**
|
|
* @dataProvider providePingLimiter
|
|
*/
|
|
public function testPingLimiter( array $attempts ) {
|
|
$this->mergeMwGlobalArrayValue(
|
|
'wgRateLimits',
|
|
[
|
|
'badcaptcha' => [
|
|
'user' => [ 1, 1 ],
|
|
],
|
|
]
|
|
);
|
|
$provider = new CaptchaPreAuthenticationProvider();
|
|
$this->initProvider( $provider, null, null, $this->getServiceContainer()->getAuthManager() );
|
|
/** @var CaptchaPreAuthenticationProvider $providerAccess */
|
|
$providerAccess = TestingAccessWrapper::newFromObject( $provider );
|
|
|
|
$disablePingLimiter = false;
|
|
$this->setTemporaryHook( 'PingLimiter',
|
|
static function ( &$user, $action, &$result ) use ( &$disablePingLimiter ) {
|
|
if ( $disablePingLimiter ) {
|
|
$result = false;
|
|
return false;
|
|
}
|
|
return null;
|
|
}
|
|
);
|
|
foreach ( $attempts as $attempt ) {
|
|
$disablePingLimiter = !empty( $attempts[3] );
|
|
$captcha = new SimpleCaptcha();
|
|
CaptchaStore::get()->store( '345', [ 'question' => '7+7', 'answer' => '14' ] );
|
|
$success = $providerAccess->verifyCaptcha( $captcha, [ $attempts[0] ], $attempts[1] );
|
|
$this->assertEquals( $attempts[2], $success );
|
|
}
|
|
}
|
|
|
|
public static function providePingLimiter() {
|
|
$sysop = User::newFromName( 'UTSysop' );
|
|
return [
|
|
// sequence of [ auth request, user, result, disable ping limiter? ]
|
|
'no failure' => [
|
|
[ self::getCaptchaRequest( '345', '14' ), new User(), true ],
|
|
[ self::getCaptchaRequest( '345', '14' ), new User(), true ],
|
|
],
|
|
'limited' => [
|
|
[ self::getCaptchaRequest( '345', '33' ), new User(), false ],
|
|
[ self::getCaptchaRequest( '345', '14' ), new User(), false ],
|
|
],
|
|
'exempt user' => [
|
|
[ self::getCaptchaRequest( '345', '33' ), $sysop, false ],
|
|
[ self::getCaptchaRequest( '345', '14' ), $sysop, true ],
|
|
],
|
|
'pinglimiter disabled' => [
|
|
[ self::getCaptchaRequest( '345', '33' ), new User(), false, 'disable' ],
|
|
[ self::getCaptchaRequest( '345', '14' ), new User(), true, 'disable' ],
|
|
],
|
|
];
|
|
}
|
|
|
|
protected static function getCaptchaRequest( $id, $word, $username = null ) {
|
|
$req = new CaptchaAuthenticationRequest( $id, [ 'question' => '?', 'answer' => $word ] );
|
|
$req->captchaWord = $word;
|
|
$req->username = $username;
|
|
return $req;
|
|
}
|
|
|
|
protected function blockLogin( $username ) {
|
|
$counter = new LoginAttemptCounter( new SimpleCaptcha() );
|
|
$counter->increaseBadLoginCounter( $username );
|
|
}
|
|
|
|
protected function flagSession() {
|
|
RequestContext::getMain()->getRequest()->getSession()
|
|
->set( 'ConfirmEdit:loginCaptchaPerUserTriggered', true );
|
|
}
|
|
|
|
protected function setTriggers( $triggers ) {
|
|
$types = [ 'edit', 'create', 'sendemail', 'addurl', 'createaccount', 'badlogin',
|
|
'badloginperuser' ];
|
|
$captchaTriggers = array_combine( $types, array_map( static function ( $type ) use ( $triggers ) {
|
|
return in_array( $type, $triggers, true );
|
|
}, $types ) );
|
|
$this->overrideConfigValue( 'CaptchaTriggers', $captchaTriggers );
|
|
}
|
|
|
|
private function getProvider(): CaptchaPreAuthenticationProvider {
|
|
return new class() extends CaptchaPreAuthenticationProvider {
|
|
public ?LoginAttemptCounter $loginAttemptCounter = null;
|
|
|
|
protected function getLoginAttemptCounter( SimpleCaptcha $captcha ): LoginAttemptCounter {
|
|
return $this->loginAttemptCounter ?: parent::getLoginAttemptCounter( $captcha );
|
|
}
|
|
};
|
|
}
|
|
|
|
}
|