wiki.techinc.nl/tests/phpunit/includes/auth/LocalPasswordPrimaryAuthenticationProviderTest.php
DannyS712 791e890fd5 AuthManager: inject more services
- BotPasswordStore
- UserFactory
- UserIdentityLookup
- UserOptionsManager

Bug: T265769
Bug: T141495
Change-Id: If220a25b8dfc9105faee5c04ea17ae8487b275f0
2021-08-05 21:31:02 +00:00

699 lines
22 KiB
PHP

<?php
namespace MediaWiki\Auth;
use MediaWiki\MediaWikiServices;
use MediaWiki\Tests\Unit\Auth\AuthenticationProviderTestTrait;
use MediaWiki\User\UserNameUtils;
use Psr\Container\ContainerInterface;
use Wikimedia\TestingAccessWrapper;
/**
* @group AuthManager
* @group Database
* @covers \MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider
*/
class LocalPasswordPrimaryAuthenticationProviderTest extends \MediaWikiIntegrationTestCase {
use AuthenticationProviderTestTrait;
private $manager = null;
private $config = null;
private $validity = null;
/**
* Get an instance of the provider
*
* $provider->checkPasswordValidity is mocked to return $this->validity,
* because we don't need to test that here.
*
* @param bool $loginOnly
* @return LocalPasswordPrimaryAuthenticationProvider
*/
protected function getProvider( $loginOnly = false ) {
$mwServices = MediaWikiServices::getInstance();
if ( !$this->config ) {
$this->config = new \HashConfig();
}
$config = new \MultiConfig( [
$this->config,
$mwServices->getMainConfig()
] );
// We need a real HookContainer since testProviderChangeAuthenticationData()
// modifies $wgHooks
$hookContainer = $mwServices->getHookContainer();
if ( !$this->manager ) {
$services = $this->createNoOpAbstractMock( ContainerInterface::class );
$objectFactory = new \Wikimedia\ObjectFactory( $services );
$userNameUtils = $this->createNoOpMock( UserNameUtils::class );
$this->manager = new AuthManager(
new \FauxRequest(),
$config,
$objectFactory,
$hookContainer,
$mwServices->getReadOnlyMode(),
$userNameUtils,
$mwServices->getBlockManager(),
$mwServices->getWatchlistManager(),
$mwServices->getDBLoadBalancer(),
$mwServices->getContentLanguage(),
$mwServices->getLanguageConverterFactory(),
$mwServices->getBotPasswordStore(),
$mwServices->getUserFactory(),
$mwServices->getUserIdentityLookup(),
$mwServices->getUserOptionsManager()
);
}
$this->validity = \Status::newGood();
$provider = $this->getMockBuilder( LocalPasswordPrimaryAuthenticationProvider::class )
->onlyMethods( [ 'checkPasswordValidity' ] )
->setConstructorArgs( [
$mwServices->getDBLoadBalancer(),
[ 'loginOnly' => $loginOnly ]
] )
->getMock();
$provider->method( 'checkPasswordValidity' )
->will( $this->returnCallback( function () {
return $this->validity;
} ) );
$this->initProvider(
$provider, $config, null, $this->manager, $hookContainer, $this->getServiceContainer()->getUserNameUtils()
);
return $provider;
}
public function testBasics() {
$user = $this->getMutableTestUser()->getUser();
$userName = $user->getName();
$lowerInitialUserName = mb_strtolower( $userName[0] ) . substr( $userName, 1 );
$provider = $this->getProvider();
$this->assertSame(
PrimaryAuthenticationProvider::TYPE_CREATE,
$provider->accountCreationType()
);
$this->assertTrue( $provider->testUserExists( $userName ) );
$this->assertTrue( $provider->testUserExists( $lowerInitialUserName ) );
$this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) );
$this->assertFalse( $provider->testUserExists( '<invalid>' ) );
$provider = $this->getProvider( [ 'loginOnly' => true ] );
$this->assertSame(
PrimaryAuthenticationProvider::TYPE_NONE,
$provider->accountCreationType()
);
$this->assertTrue( $provider->testUserExists( $userName ) );
$this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) );
$req = new PasswordAuthenticationRequest;
$req->action = AuthManager::ACTION_CHANGE;
$req->username = '<invalid>';
$provider->providerChangeAuthenticationData( $req );
}
public function testTestUserCanAuthenticate() {
$user = $this->getMutableTestUser()->getUser();
$userName = $user->getName();
$dbw = wfGetDB( DB_PRIMARY );
$provider = $this->getProvider();
$this->assertFalse( $provider->testUserCanAuthenticate( '<invalid>' ) );
$this->assertFalse( $provider->testUserCanAuthenticate( 'DoesNotExist' ) );
$this->assertTrue( $provider->testUserCanAuthenticate( $userName ) );
$lowerInitialUserName = mb_strtolower( $userName[0] ) . substr( $userName, 1 );
$this->assertTrue( $provider->testUserCanAuthenticate( $lowerInitialUserName ) );
$dbw->update(
'user',
[ 'user_password' => \PasswordFactory::newInvalidPassword()->toString() ],
[ 'user_name' => $userName ]
);
$this->assertFalse( $provider->testUserCanAuthenticate( $userName ) );
// Really old format
$dbw->update(
'user',
[ 'user_password' => '0123456789abcdef0123456789abcdef' ],
[ 'user_name' => $userName ]
);
$this->assertTrue( $provider->testUserCanAuthenticate( $userName ) );
}
public function testSetPasswordResetFlag() {
// Set instance vars
$this->getProvider();
// @todo: Because we're currently using User, which uses the global config...
$this->setMwGlobals( [ 'wgPasswordExpireGrace' => 100 ] );
$this->config->set( 'PasswordExpireGrace', 100 );
$this->config->set( 'InvalidPasswordReset', true );
$provider = new LocalPasswordPrimaryAuthenticationProvider(
$this->getServiceContainer()->getDBLoadBalancer()
);
$this->initProvider( $provider, $this->config, null, $this->manager );
$providerPriv = TestingAccessWrapper::newFromObject( $provider );
$user = $this->getMutableTestUser()->getUser();
$userName = $user->getName();
$dbw = wfGetDB( DB_PRIMARY );
$row = $dbw->selectRow(
'user',
'*',
[ 'user_name' => $userName ],
__METHOD__
);
$this->manager->removeAuthenticationSessionData( null );
$row->user_password_expires = wfTimestamp( TS_MW, time() + 200 );
$providerPriv->setPasswordResetFlag( $userName, \Status::newGood(), $row );
$this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
$this->manager->removeAuthenticationSessionData( null );
$row->user_password_expires = wfTimestamp( TS_MW, time() - 200 );
$providerPriv->setPasswordResetFlag( $userName, \Status::newGood(), $row );
$ret = $this->manager->getAuthenticationSessionData( 'reset-pass' );
$this->assertNotNull( $ret );
$this->assertSame( 'resetpass-expired', $ret->msg->getKey() );
$this->assertTrue( $ret->hard );
$this->manager->removeAuthenticationSessionData( null );
$row->user_password_expires = wfTimestamp( TS_MW, time() - 1 );
$providerPriv->setPasswordResetFlag( $userName, \Status::newGood(), $row );
$ret = $this->manager->getAuthenticationSessionData( 'reset-pass' );
$this->assertNotNull( $ret );
$this->assertSame( 'resetpass-expired-soft', $ret->msg->getKey() );
$this->assertFalse( $ret->hard );
$this->manager->removeAuthenticationSessionData( null );
$row->user_password_expires = null;
$status = \Status::newGood( [ 'suggestChangeOnLogin' => true ] );
$status->error( 'testing' );
$providerPriv->setPasswordResetFlag( $userName, $status, $row );
$ret = $this->manager->getAuthenticationSessionData( 'reset-pass' );
$this->assertNotNull( $ret );
$this->assertSame( 'resetpass-validity-soft', $ret->msg->getKey() );
$this->assertFalse( $ret->hard );
$this->manager->removeAuthenticationSessionData( null );
$row->user_password_expires = null;
$status = \Status::newGood( [ 'forceChange' => true ] );
$status->error( 'testing' );
$providerPriv->setPasswordResetFlag( $userName, $status, $row );
$ret = $this->manager->getAuthenticationSessionData( 'reset-pass' );
$this->assertNotNull( $ret );
$this->assertSame( 'resetpass-validity', $ret->msg->getKey() );
$this->assertTrue( $ret->hard );
$this->manager->removeAuthenticationSessionData( null );
$row->user_password_expires = null;
$status = \Status::newGood( [ 'suggestChangeOnLogin' => false, ] );
$status->error( 'testing' );
$providerPriv->setPasswordResetFlag( $userName, $status, $row );
$ret = $this->manager->getAuthenticationSessionData( 'reset-pass' );
$this->assertNull( $ret );
}
public function testAuthentication() {
$testUser = $this->getMutableTestUser();
$userName = $testUser->getUser()->getName();
$dbw = wfGetDB( DB_PRIMARY );
$id = \User::idFromName( $userName );
$req = new PasswordAuthenticationRequest();
$req->action = AuthManager::ACTION_LOGIN;
$reqs = [ PasswordAuthenticationRequest::class => $req ];
$provider = $this->getProvider();
// General failures
$this->assertEquals(
AuthenticationResponse::newAbstain(),
$provider->beginPrimaryAuthentication( [] )
);
$req->username = 'foo';
$req->password = null;
$this->assertEquals(
AuthenticationResponse::newAbstain(),
$provider->beginPrimaryAuthentication( $reqs )
);
$req->username = null;
$req->password = 'bar';
$this->assertEquals(
AuthenticationResponse::newAbstain(),
$provider->beginPrimaryAuthentication( $reqs )
);
$req->username = '<invalid>';
$req->password = 'WhoCares';
$ret = $provider->beginPrimaryAuthentication( $reqs );
$this->assertEquals(
AuthenticationResponse::newAbstain(),
$provider->beginPrimaryAuthentication( $reqs )
);
$req->username = 'DoesNotExist';
$req->password = 'DoesNotExist';
$ret = $provider->beginPrimaryAuthentication( $reqs );
$this->assertEquals(
AuthenticationResponse::FAIL,
$ret->status
);
$this->assertEquals(
'wrongpassword',
$ret->message->getKey()
);
// Validation failure
$req->username = $userName;
$req->password = $testUser->getPassword();
$this->validity = \Status::newFatal( 'arbitrary-failure' );
$ret = $provider->beginPrimaryAuthentication( $reqs );
$this->assertEquals(
AuthenticationResponse::FAIL,
$ret->status
);
$this->assertEquals(
'arbitrary-failure',
$ret->message->getKey()
);
// Successful auth
$this->manager->removeAuthenticationSessionData( null );
$this->validity = \Status::newGood();
$this->assertEquals(
AuthenticationResponse::newPass( $userName ),
$provider->beginPrimaryAuthentication( $reqs )
);
$this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
// Successful auth after normalizing name
$this->manager->removeAuthenticationSessionData( null );
$this->validity = \Status::newGood();
$req->username = mb_strtolower( $userName[0] ) . substr( $userName, 1 );
$this->assertEquals(
AuthenticationResponse::newPass( $userName ),
$provider->beginPrimaryAuthentication( $reqs )
);
$this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
$req->username = $userName;
// Successful auth with reset
$this->manager->removeAuthenticationSessionData( null );
$this->validity = \Status::newGood( [ 'suggestChangeOnLogin' => true ] );
$this->validity->error( 'arbitrary-warning' );
$this->assertEquals(
AuthenticationResponse::newPass( $userName ),
$provider->beginPrimaryAuthentication( $reqs )
);
$this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
// Wrong password
$this->validity = \Status::newGood();
$req->password = 'Wrong';
$ret = $provider->beginPrimaryAuthentication( $reqs );
$this->assertEquals(
AuthenticationResponse::FAIL,
$ret->status
);
$this->assertEquals(
'wrongpassword',
$ret->message->getKey()
);
// Correct handling of legacy encodings
$password = ':B:salt:' . md5( 'salt-' . md5( "\xe1\xe9\xed\xf3\xfa" ) );
$dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => $userName ] );
$req->password = 'áéíóú';
$ret = $provider->beginPrimaryAuthentication( $reqs );
$this->assertEquals(
AuthenticationResponse::FAIL,
$ret->status
);
$this->assertEquals(
'wrongpassword',
$ret->message->getKey()
);
$this->config->set( 'LegacyEncoding', true );
$this->assertEquals(
AuthenticationResponse::newPass( $userName ),
$provider->beginPrimaryAuthentication( $reqs )
);
$req->password = 'áéíóú Wrong';
$ret = $provider->beginPrimaryAuthentication( $reqs );
$this->assertEquals(
AuthenticationResponse::FAIL,
$ret->status
);
$this->assertEquals(
'wrongpassword',
$ret->message->getKey()
);
// Correct handling of really old password hashes
$this->config->set( 'PasswordSalt', true );
$password = md5( "$id-" . md5( 'FooBar' ) );
$dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => $userName ] );
$req->password = 'FooBar';
$this->assertEquals(
AuthenticationResponse::newPass( $userName ),
$provider->beginPrimaryAuthentication( $reqs )
);
}
/**
* @dataProvider provideProviderAllowsAuthenticationDataChange
* @param string $type
* @param string $user
* @param \Status $validity Result of the password validity check
* @param \StatusValue $expect1 Expected result with $checkData = false
* @param \StatusValue $expect2 Expected result with $checkData = true
*/
public function testProviderAllowsAuthenticationDataChange( $type, $user, \Status $validity,
\StatusValue $expect1, \StatusValue $expect2
) {
if ( $type === PasswordAuthenticationRequest::class ) {
$req = new $type();
} elseif ( $type === PasswordDomainAuthenticationRequest::class ) {
$req = new $type( [] );
} else {
$req = $this->createMock( $type );
}
$req->action = AuthManager::ACTION_CHANGE;
$req->username = $user;
$req->password = 'NewPassword';
$req->retype = 'NewPassword';
$provider = $this->getProvider();
$this->validity = $validity;
$this->assertEquals( $expect1, $provider->providerAllowsAuthenticationDataChange( $req, false ) );
$this->assertEquals( $expect2, $provider->providerAllowsAuthenticationDataChange( $req, true ) );
$req->retype = 'BadRetype';
$this->assertEquals(
$expect1,
$provider->providerAllowsAuthenticationDataChange( $req, false )
);
$this->assertEquals(
$expect2->getValue() === 'ignored' ? $expect2 : \StatusValue::newFatal( 'badretype' ),
$provider->providerAllowsAuthenticationDataChange( $req, true )
);
$provider = $this->getProvider( true );
$this->assertEquals(
\StatusValue::newGood( 'ignored' ),
$provider->providerAllowsAuthenticationDataChange( $req, true ),
'loginOnly mode should claim to ignore all changes'
);
}
public static function provideProviderAllowsAuthenticationDataChange() {
$err = \StatusValue::newGood();
$err->error( 'arbitrary-warning' );
return [
[ AuthenticationRequest::class, 'UTSysop', \Status::newGood(),
\StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ],
[ PasswordAuthenticationRequest::class, 'UTSysop', \Status::newGood(),
\StatusValue::newGood(), \StatusValue::newGood() ],
[ PasswordAuthenticationRequest::class, 'uTSysop', \Status::newGood(),
\StatusValue::newGood(), \StatusValue::newGood() ],
[ PasswordAuthenticationRequest::class, 'UTSysop', \Status::wrap( $err ),
\StatusValue::newGood(), $err ],
[ PasswordAuthenticationRequest::class, 'UTSysop', \Status::newFatal( 'arbitrary-error' ),
\StatusValue::newGood(), \StatusValue::newFatal( 'arbitrary-error' ) ],
[ PasswordAuthenticationRequest::class, 'DoesNotExist', \Status::newGood(),
\StatusValue::newGood(), \StatusValue::newGood( 'ignored' ) ],
[ PasswordDomainAuthenticationRequest::class, 'UTSysop', \Status::newGood(),
\StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ],
];
}
/**
* @dataProvider provideProviderChangeAuthenticationData
* @param callable|bool $usernameTransform
* @param string $type
* @param bool $loginOnly
* @param bool $changed
*/
public function testProviderChangeAuthenticationData(
$usernameTransform, $type, $loginOnly, $changed ) {
$testUser = $this->getMutableTestUser();
$user = $testUser->getUser()->getName();
if ( is_callable( $usernameTransform ) ) {
$user = $usernameTransform( $user );
}
$cuser = ucfirst( $user );
$oldpass = $testUser->getPassword();
$newpass = 'NewPassword';
$dbw = wfGetDB( DB_PRIMARY );
$oldExpiry = $dbw->selectField( 'user', 'user_password_expires', [ 'user_name' => $cuser ] );
$this->mergeMwGlobalArrayValue( 'wgHooks', [
'ResetPasswordExpiration' => [ static function ( $user, &$expires ) {
$expires = '30001231235959';
} ]
] );
$provider = $this->getProvider( $loginOnly );
// Sanity check
$loginReq = new PasswordAuthenticationRequest();
$loginReq->action = AuthManager::ACTION_LOGIN;
$loginReq->username = $user;
$loginReq->password = $oldpass;
$loginReqs = [ PasswordAuthenticationRequest::class => $loginReq ];
$this->assertEquals(
AuthenticationResponse::newPass( $cuser ),
$provider->beginPrimaryAuthentication( $loginReqs ),
'Sanity check'
);
if ( $type === PasswordAuthenticationRequest::class ) {
$changeReq = new $type();
} else {
$changeReq = $this->createMock( $type );
}
$changeReq->action = AuthManager::ACTION_CHANGE;
$changeReq->username = $user;
$changeReq->password = $newpass;
$provider->providerChangeAuthenticationData( $changeReq );
if ( $loginOnly && $changed ) {
$old = 'fail';
$new = 'fail';
$expectExpiry = null;
} elseif ( $changed ) {
$old = 'fail';
$new = 'pass';
$expectExpiry = '30001231235959';
} else {
$old = 'pass';
$new = 'fail';
$expectExpiry = $oldExpiry;
}
$loginReq->password = $oldpass;
$ret = $provider->beginPrimaryAuthentication( $loginReqs );
if ( $old === 'pass' ) {
$this->assertEquals(
AuthenticationResponse::newPass( $cuser ),
$ret,
'old password should pass'
);
} else {
$this->assertEquals(
AuthenticationResponse::FAIL,
$ret->status,
'old password should fail'
);
$this->assertEquals(
'wrongpassword',
$ret->message->getKey(),
'old password should fail'
);
}
$loginReq->password = $newpass;
$ret = $provider->beginPrimaryAuthentication( $loginReqs );
if ( $new === 'pass' ) {
$this->assertEquals(
AuthenticationResponse::newPass( $cuser ),
$ret,
'new password should pass'
);
} else {
$this->assertEquals(
AuthenticationResponse::FAIL,
$ret->status,
'new password should fail'
);
$this->assertEquals(
'wrongpassword',
$ret->message->getKey(),
'new password should fail'
);
}
$this->assertSame(
$expectExpiry,
wfTimestampOrNull(
TS_MW,
$dbw->selectField( 'user', 'user_password_expires', [ 'user_name' => $cuser ] )
)
);
}
public static function provideProviderChangeAuthenticationData() {
return [
[ false, AuthenticationRequest::class, false, false ],
[ false, PasswordAuthenticationRequest::class, false, true ],
[ false, AuthenticationRequest::class, true, false ],
[ false, PasswordAuthenticationRequest::class, true, true ],
[ 'ucfirst', PasswordAuthenticationRequest::class, false, true ],
[ 'ucfirst', PasswordAuthenticationRequest::class, true, true ],
];
}
public function testTestForAccountCreation() {
$user = \User::newFromName( 'foo' );
$req = new PasswordAuthenticationRequest();
$req->action = AuthManager::ACTION_CREATE;
$req->username = 'Foo';
$req->password = 'Bar';
$req->retype = 'Bar';
$reqs = [ PasswordAuthenticationRequest::class => $req ];
$provider = $this->getProvider();
$this->assertEquals(
\StatusValue::newGood(),
$provider->testForAccountCreation( $user, $user, [] ),
'No password request'
);
$this->assertEquals(
\StatusValue::newGood(),
$provider->testForAccountCreation( $user, $user, $reqs ),
'Password request, validated'
);
$req->retype = 'Baz';
$this->assertEquals(
\StatusValue::newFatal( 'badretype' ),
$provider->testForAccountCreation( $user, $user, $reqs ),
'Password request, bad retype'
);
$req->retype = 'Bar';
$this->validity->error( 'arbitrary warning' );
$expect = \StatusValue::newGood();
$expect->error( 'arbitrary warning' );
$this->assertEquals(
$expect,
$provider->testForAccountCreation( $user, $user, $reqs ),
'Password request, not validated'
);
$provider = $this->getProvider( true );
$this->validity->error( 'arbitrary warning' );
$this->assertEquals(
\StatusValue::newGood(),
$provider->testForAccountCreation( $user, $user, $reqs ),
'Password request, not validated, loginOnly'
);
}
public function testAccountCreation() {
$user = \User::newFromName( 'Foo' );
$req = new PasswordAuthenticationRequest();
$req->action = AuthManager::ACTION_CREATE;
$reqs = [ PasswordAuthenticationRequest::class => $req ];
$provider = $this->getProvider( true );
try {
$provider->beginPrimaryAccountCreation( $user, $user, [] );
$this->fail( 'Expected exception was not thrown' );
} catch ( \BadMethodCallException $ex ) {
$this->assertSame(
'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage()
);
}
try {
$provider->finishAccountCreation( $user, $user, AuthenticationResponse::newPass() );
$this->fail( 'Expected exception was not thrown' );
} catch ( \BadMethodCallException $ex ) {
$this->assertSame(
'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage()
);
}
$provider = $this->getProvider( false );
$this->assertEquals(
AuthenticationResponse::newAbstain(),
$provider->beginPrimaryAccountCreation( $user, $user, [] )
);
$req->username = 'foo';
$req->password = null;
$this->assertEquals(
AuthenticationResponse::newAbstain(),
$provider->beginPrimaryAccountCreation( $user, $user, $reqs )
);
$req->username = null;
$req->password = 'bar';
$this->assertEquals(
AuthenticationResponse::newAbstain(),
$provider->beginPrimaryAccountCreation( $user, $user, $reqs )
);
$req->username = 'foo';
$req->password = 'bar';
$expect = AuthenticationResponse::newPass( 'Foo' );
$expect->createRequest = clone $req;
$expect->createRequest->username = 'Foo';
$this->assertEquals( $expect, $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) );
// We have to cheat a bit to avoid having to add a new user to
// the database to test the actual setting of the password works right
$dbw = wfGetDB( DB_PRIMARY );
$user = \User::newFromName( 'UTSysop' );
$req->username = $user->getName();
$req->password = 'NewPassword';
$expect = AuthenticationResponse::newPass( 'UTSysop' );
$expect->createRequest = $req;
$res2 = $provider->beginPrimaryAccountCreation( $user, $user, $reqs );
$this->assertEquals( $expect, $res2, 'Sanity check' );
$ret = $provider->beginPrimaryAuthentication( $reqs );
$this->assertEquals( AuthenticationResponse::FAIL, $ret->status, 'sanity check' );
$this->assertNull( $provider->finishAccountCreation( $user, $user, $res2 ) );
$ret = $provider->beginPrimaryAuthentication( $reqs );
$this->assertEquals( AuthenticationResponse::PASS, $ret->status, 'new password is set' );
}
}