wiki.techinc.nl/tests/phpunit/includes/auth/TemporaryPasswordPrimaryAuthenticationProviderTest.php

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1065 lines
33 KiB
PHP
Raw Normal View History

<?php
namespace MediaWiki\Tests\Auth;
use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Auth\AuthenticationResponse;
use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\PasswordAuthenticationRequest;
use MediaWiki\Auth\PrimaryAuthenticationProvider;
use MediaWiki\Auth\TemporaryPasswordAuthenticationRequest;
use MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider;
use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Password\PasswordFactory;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Status\Status;
use MediaWiki\Tests\Unit\Auth\AuthenticationProviderTestTrait;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserNameUtils;
use MediaWikiIntegrationTestCase;
use MessageSpecifier;
use StatusValue;
use Wikimedia\ScopedCallback;
use Wikimedia\TestingAccessWrapper;
/**
* TODO clean up and reduce duplication
*
* @group AuthManager
* @group Database
* @covers \MediaWiki\Auth\AbstractTemporaryPasswordPrimaryAuthenticationProvider
* @covers \MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider
*/
class TemporaryPasswordPrimaryAuthenticationProviderTest extends MediaWikiIntegrationTestCase {
use AuthenticationProviderTestTrait;
use DummyServicesTrait;
private AuthManager $manager;
private Status $validity;
private PasswordFactory $testPasswordFactory;
protected function setUp(): void {
parent::setUp();
$mwServices = $this->getServiceContainer();
$hookContainer = $this->createHookContainer();
$this->manager = new AuthManager(
new FauxRequest(),
$mwServices->getMainConfig(),
$this->getDummyObjectFactory(),
$hookContainer,
$mwServices->getReadOnlyMode(),
$this->createNoOpMock( UserNameUtils::class ),
$mwServices->getBlockManager(),
$mwServices->getWatchlistManager(),
$mwServices->getDBLoadBalancer(),
$mwServices->getContentLanguage(),
$mwServices->getLanguageConverterFactory(),
$mwServices->getBotPasswordStore(),
$mwServices->getUserFactory(),
$mwServices->getUserIdentityLookup(),
$mwServices->getUserOptionsManager()
);
$this->validity = Status::newGood();
// A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
$this->testPasswordFactory = new PasswordFactory(
$this->getConfVar( MainConfigNames::PasswordConfig ),
'A'
);
}
/**
* Get an instance of the provider
*
* $provider->checkPasswordValidity is mocked to return $this->validity,
* because we don't need to test that here.
*
* @param array $params
* @param UserNameUtils|null $userNameUtils
* @return TemporaryPasswordPrimaryAuthenticationProvider
*/
protected function getProvider( array $params = [], ?UserNameUtils $userNameUtils = null ) {
$userNameUtils ??= $this->getServiceContainer()->getUserNameUtils();
$mwServices = $this->getServiceContainer();
$mockedMethods[] = 'checkPasswordValidity';
$provider = $this->getMockBuilder( TemporaryPasswordPrimaryAuthenticationProvider::class )
->onlyMethods( $mockedMethods )
->setConstructorArgs( [
$mwServices->getConnectionProvider(),
$mwServices->getUserOptionsLookup(),
$params,
] )
->getMock();
$provider->method( 'checkPasswordValidity' )
->willReturnCallback( function () {
return $this->validity;
} );
$this->initProvider(
$provider, $mwServices->getMainConfig(), null, $this->manager, null, $userNameUtils
);
return $provider;
}
protected function hookMailer( $func = null ) {
$hookContainer = $this->getServiceContainer()->getHookContainer();
$this->clearHook( 'AlternateUserMailer' );
if ( $func ) {
$reset = $hookContainer->scopedRegister( 'AlternateUserMailer', $func );
} else {
Hook Container New classes and modificatons to existing classes to support the new Hooks system. All changes are documented in RFC https://phabricator.wikimedia.org/T240307. - HookContainer.php: Class for doing much of what Hooks.php has historically done, but enabling new-style hooks to be processed and registered. Changes include new ways of defining hook handler functions as an object with defined dependencies in extension.json, removing runWithoutAbort() and addit it to an $options parameter to be passed to HookContainer::run(), being able to decipher whether a hook handler is legacy or non-legacy style and run them in the appropriate way, etc. - DeprecatedHooks.php: For marking hooks deprecated and verifying if one is deprecated - DeprecatedHooksTest.php: Unit tests for DeprecatedHooks.php - Hooks.php: register() will now additionally register hooks with handlers in new HooksContainer.php. getHandlers() will be a legacy wrapper for calling the newer HookContainer::getHandlers() - MediaWikiServices.php: Added getHookContainer() for retrieving HookContainer singleton - ExtensionProcessor.php: modified extractHooks() to be able to extract new style handler objects being registered in extension.json - ServiceWiring.php: Added HookContainer to list of services to return - HookContainerTest.php: Unit tests for HookContainer.php - ExtensionProcessorTest.php: Moved file out of /unit folder and now extends MediaWikiTestCase instead of MediaWikiUnitTestCase (as the tests are not truly unit tests). Modified existing tests for ExtensionProcessor::extractHooks() to include a test case for new style handler Bug: T240307 Change-Id: I432861d8995cfd7180e77e115251d8055b7eceec
2020-02-10 14:47:46 +00:00
$reset = $hookContainer->scopedRegister( 'AlternateUserMailer', function () {
$this->fail( 'AlternateUserMailer hook called unexpectedly' );
return false;
} );
}
Hook Container New classes and modificatons to existing classes to support the new Hooks system. All changes are documented in RFC https://phabricator.wikimedia.org/T240307. - HookContainer.php: Class for doing much of what Hooks.php has historically done, but enabling new-style hooks to be processed and registered. Changes include new ways of defining hook handler functions as an object with defined dependencies in extension.json, removing runWithoutAbort() and addit it to an $options parameter to be passed to HookContainer::run(), being able to decipher whether a hook handler is legacy or non-legacy style and run them in the appropriate way, etc. - DeprecatedHooks.php: For marking hooks deprecated and verifying if one is deprecated - DeprecatedHooksTest.php: Unit tests for DeprecatedHooks.php - Hooks.php: register() will now additionally register hooks with handlers in new HooksContainer.php. getHandlers() will be a legacy wrapper for calling the newer HookContainer::getHandlers() - MediaWikiServices.php: Added getHookContainer() for retrieving HookContainer singleton - ExtensionProcessor.php: modified extractHooks() to be able to extract new style handler objects being registered in extension.json - ServiceWiring.php: Added HookContainer to list of services to return - HookContainerTest.php: Unit tests for HookContainer.php - ExtensionProcessorTest.php: Moved file out of /unit folder and now extends MediaWikiTestCase instead of MediaWikiUnitTestCase (as the tests are not truly unit tests). Modified existing tests for ExtensionProcessor::extractHooks() to include a test case for new style handler Bug: T240307 Change-Id: I432861d8995cfd7180e77e115251d8055b7eceec
2020-02-10 14:47:46 +00:00
return $reset;
}
/**
* Set the new password (i.e. single use temporary password)
* hash for the given user, with an optional expiry time.
*
* @param UserIdentity $user The user to update the new password for.
* @param string $hash Password hash to store.
* @param int|null $expiry UNIX timestamp at which the new password expires, or `null` for no expiry.
*/
private function setNewPassword(
UserIdentity $user,
string $hash,
?int $expiry = null
): void {
$dbw = $this->getDb();
$dbw->newUpdateQueryBuilder()
->update( 'user' )
->set( [
'user_newpassword' => $hash,
'user_newpass_time' => $expiry ? $dbw->timestamp( $expiry ) : null
] )
->where( [ 'user_id' => $user->getId() ] )
->execute();
}
public function testBasics() {
$provider = $this->getProvider();
$this->assertSame(
PrimaryAuthenticationProvider::TYPE_CREATE,
$provider->accountCreationType()
);
$existingUserName = $this->getTestUser()->getUserIdentity()->getName();
$this->assertTrue( $provider->testUserExists( $existingUserName ) );
$this->assertTrue( $provider->testUserExists( lcfirst( $existingUserName ) ) );
$this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) );
$this->assertFalse( $provider->testUserExists( '<invalid>' ) );
$req = new PasswordAuthenticationRequest;
$req->action = AuthManager::ACTION_CHANGE;
$req->username = '<invalid>';
$provider->providerChangeAuthenticationData( $req );
}
public function testConfig() {
$config = new HashConfig( [
MainConfigNames::EnableEmail => false,
MainConfigNames::NewPasswordExpiry => 100,
MainConfigNames::PasswordReminderResendTime => 101,
] );
$provider = new TemporaryPasswordPrimaryAuthenticationProvider(
$this->getServiceContainer()->getConnectionProvider(),
$this->getServiceContainer()->getUserOptionsLookup()
);
$providerPriv = TestingAccessWrapper::newFromObject( $provider );
$this->initProvider( $provider, $config );
$this->assertSame( false, $providerPriv->emailEnabled );
$this->assertSame( 100, $providerPriv->newPasswordExpiry );
$this->assertSame( 101, $providerPriv->passwordReminderResendTime );
$provider = new TemporaryPasswordPrimaryAuthenticationProvider(
$this->getServiceContainer()->getConnectionProvider(),
$this->getServiceContainer()->getUserOptionsLookup(),
[
'emailEnabled' => true,
'newPasswordExpiry' => 42,
'passwordReminderResendTime' => 43,
]
);
$providerPriv = TestingAccessWrapper::newFromObject( $provider );
$this->initProvider( $provider, $config );
$this->assertSame( true, $providerPriv->emailEnabled );
$this->assertSame( 42, $providerPriv->newPasswordExpiry );
$this->assertSame( 43, $providerPriv->passwordReminderResendTime );
}
/**
* @dataProvider provideTestUserCanAuthenticateErrorCases
*
* @param string|null $userName The user name to check, or `null` to use the user name of the test user
* @param callable|null $passwordProvider Optional callable that takes a `PasswordFactory` and produces
* a password hash override to set for the test user
* @param int|null $passwordExpiry Expiry to set for the password returned by `$passwordProvider`, or
* `null` to set no expiry.
* @return void
*/
public function testTestUserCanAuthenticateErrorCases(
?string $userName = null,
?callable $passwordProvider = null,
?int $passwordExpiry = null
): void {
$user = self::getMutableTestUser()->getUser();
if ( $passwordProvider !== null ) {
$this->setNewPassword(
$user,
$passwordProvider( $this->testPasswordFactory ),
$passwordExpiry
);
}
$userName ??= $user->getName();
$result = $this->getProvider( [ 'newPasswordExpiry' => 100 ] )->testUserCanAuthenticate( $userName );
$this->assertFalse( $result );
}
public function provideTestUserCanAuthenticateErrorCases(): iterable {
yield 'invalid user name' => [ '<invalid>' ];
yield 'nonexistent user' => [ 'DoesNotExist' ];
yield 'user with invalid password' => [
null,
fn () => PasswordFactory::newInvalidPassword()->toString()
];
yield 'user with expired password' => [
null,
fn ( PasswordFactory $passwordFactory ) => $passwordFactory->newFromPlaintext( 'password' )->toString(),
time() - 3_600
];
}
public function testTestUserCanAuthenticateSimple(): void {
$user = self::getMutableTestUser()->getUser();
$this->setNewPassword(
$user,
$this->testPasswordFactory->newFromPlaintext( 'password' )->toString()
);
$result = $this->getProvider()->testUserCanAuthenticate( $user->getName() );
$this->assertTrue( $result );
}
public function testTestUserCanAuthenticateCaseInsensitive(): void {
$user = self::getMutableTestUser()->getUser();
$this->setNewPassword(
$user,
$this->testPasswordFactory->newFromPlaintext( 'password' )->toString()
);
$result = $this->getProvider()->testUserCanAuthenticate( lcfirst( $user->getName() ) );
$this->assertTrue( $result );
}
public function testTestUserCanAuthenticateWithNonExpiredTemporaryPassword(): void {
$user = self::getMutableTestUser()->getUser();
$this->setNewPassword(
$user,
$this->testPasswordFactory->newFromPlaintext( 'password' )->toString(),
time() - 100
);
$result = $this->getProvider( [ 'newPasswordExpiry' => 3600 ] )->testUserCanAuthenticate( $user->getName() );
$this->assertTrue( $result );
}
/**
* @dataProvider provideGetAuthenticationRequests
* @param string $action
* @param bool $registered
* @param bool $temporary
* @param AuthenticationRequest[] $expected
*/
public function testGetAuthenticationRequests(
string $action,
bool $registered,
bool $temporary,
array $expected
) {
$username = $registered ? 'TestGetAuthenticationRequests' : null;
$options = [ 'username' => $username ];
$userNameUtils = $this->createMock( UserNameUtils::class );
$userNameUtils->method( 'isTemp' )
->with( $username )
->willReturn( $temporary );
$actual = $this->getProvider( [ 'emailEnabled' => true ], $userNameUtils )
->getAuthenticationRequests( $action, $options );
foreach ( $actual as $req ) {
if ( $req instanceof TemporaryPasswordAuthenticationRequest && $req->password !== null ) {
$req->password = 'random';
}
}
$this->assertEquals( $expected, $actual );
}
public static function provideGetAuthenticationRequests(): iterable {
yield 'login attempt as anonymous user' => [
AuthManager::ACTION_LOGIN, false, false, [ new PasswordAuthenticationRequest ]
];
yield 'login attempt as named user' => [
AuthManager::ACTION_LOGIN, true, false, [ new PasswordAuthenticationRequest ]
];
yield 'login attempt as temporary user' => [
AuthManager::ACTION_LOGIN, true, true, [ new PasswordAuthenticationRequest ]
];
yield 'signup attempt as anonymous user' => [
AuthManager::ACTION_CREATE, false, false, []
];
yield 'signup attempt as named user' => [
AuthManager::ACTION_CREATE, true, false, [ new TemporaryPasswordAuthenticationRequest( 'random' ) ]
];
yield 'signup attempt as temporary user' => [
AuthManager::ACTION_CREATE, true, true, []
];
yield 'account linking attempt as anonymous user' => [
AuthManager::ACTION_LINK, false, false, []
];
yield 'account linking attempt as named user' => [
AuthManager::ACTION_LINK, true, false, []
];
yield 'account linking attempt as temporary user' => [
AuthManager::ACTION_LINK, true, true, []
];
yield 'credential change attempt as anonymous user' => [
AuthManager::ACTION_CHANGE, false, false, [ new TemporaryPasswordAuthenticationRequest( 'random' ) ]
];
yield 'credential change attempt as named user' => [
AuthManager::ACTION_CHANGE, true, false, [ new TemporaryPasswordAuthenticationRequest( 'random' ) ]
];
yield 'credential change attempt as temporary user' => [
AuthManager::ACTION_CHANGE, true, true, [ new TemporaryPasswordAuthenticationRequest( 'random' ) ]
];
yield 'credential remove attempt as anonymous user' => [
AuthManager::ACTION_REMOVE, false, false, [ new TemporaryPasswordAuthenticationRequest() ]
];
yield 'credential remove attempt as named user' => [
AuthManager::ACTION_REMOVE, true, false, [ new TemporaryPasswordAuthenticationRequest() ]
];
yield 'credential remove attempt as temporary user' => [
AuthManager::ACTION_REMOVE, true, true, [ new TemporaryPasswordAuthenticationRequest() ]
];
}
/**
* @dataProvider provideAuthenticationErrorCases
* @param string $password
* @param string $expectedErrorMessage
* @param int $newPasswordExpiry
* @param StatusValue|null $validationError
* @return void
*/
public function testAuthenticationErrorCases(
string $password,
string $expectedErrorMessage,
int $newPasswordExpiry = 100,
?StatusValue $validationError = null
) {
$user = self::getMutableTestUser()->getUser();
$validPassword = 'TemporaryPassword';
$hash = ':A:' . md5( $validPassword );
$this->setNewPassword( $user, $hash, time() - 10 );
$req = self::makePasswordAuthenticationRequest( $user->getName(), $password );
$reqs = [ PasswordAuthenticationRequest::class => $req ];
$provider = $this->getProvider( [ 'newPasswordExpiry' => $newPasswordExpiry ] );
$this->validity = $validationError ?? Status::newGood();
$response = $provider->beginPrimaryAuthentication( $reqs );
$this->assertSame( AuthenticationResponse::FAIL, $response->status );
if ( $validationError !== null ) {
$this->assertSame(
$validationError->getMessages()[0]->getKey(),
$response->message->getParams()[0]->getKey()
);
}
}
public static function provideAuthenticationErrorCases(): iterable {
yield 'validation failure' => [
'TemporaryPassword',
'fatalpassworderror',
100,
Status::newFatal( 'arbitrary-failure' )
];
yield 'expired password' => [
'TemporaryPassword',
'wrongpassword',
1
];
yield 'wrong password' => [
'Wrong',
'wrongpassword'
];
}
/**
* @dataProvider provideAuthenticationAbstainCases
* @param PasswordAuthenticationRequest|null $req The authentication request to send,
* or `null` to send no requests
* @return void
*/
public function testAuthenticationAbstainCases( ?PasswordAuthenticationRequest $req ): void {
$reqs = $req ? [ PasswordAuthenticationRequest::class => $req ] : [];
$response = $this->getProvider()->beginPrimaryAuthentication( $reqs );
$this->assertEquals( AuthenticationResponse::newAbstain(), $response );
}
public static function provideAuthenticationAbstainCases(): iterable {
yield 'no requests' => [ null ];
yield 'no user name' => [ self::makePasswordAuthenticationRequest( null, 'bar' ) ];
yield 'no password' => [ self::makePasswordAuthenticationRequest( 'foo' ) ];
yield 'invalid user name' => [ self::makePasswordAuthenticationRequest( '<invalid>', 'bar' ) ];
yield 'nonexistent user' => [ self::makePasswordAuthenticationRequest( 'DoesNotExist', 'bar' ) ];
}
private static function makePasswordAuthenticationRequest(
?string $userName = null,
?string $password = null
): PasswordAuthenticationRequest {
$req = new PasswordAuthenticationRequest();
$req->action = AuthManager::ACTION_LOGIN;
$req->username = $userName;
$req->password = $password;
return $req;
}
public function testAuthenticationSuccess(): void {
$user = self::getMutableTestUser()->getUser();
$password = 'TemporaryPassword';
$hash = ':A:' . md5( $password );
$this->setNewPassword( $user, $hash, time() - 10 );
$req = self::makePasswordAuthenticationRequest( $user->getName(), $password );
$reqs = [ PasswordAuthenticationRequest::class => $req ];
$provider = $this->getProvider();
$this->manager->removeAuthenticationSessionData( null );
$this->validity = Status::newGood();
$this->assertEquals(
AuthenticationResponse::newPass( $user->getName() ),
$provider->beginPrimaryAuthentication( $reqs )
);
$this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
}
public function testAuthenticationSuccessCaseInsensitive(): void {
$user = self::getMutableTestUser()->getUser();
$password = 'TemporaryPassword';
$hash = ':A:' . md5( $password );
$this->setNewPassword( $user, $hash, time() - 10 );
$req = self::makePasswordAuthenticationRequest( lcfirst( $user->getName() ), $password );
$reqs = [ PasswordAuthenticationRequest::class => $req ];
$provider = $this->getProvider();
$this->manager->removeAuthenticationSessionData( null );
$this->validity = Status::newGood();
$this->assertEquals(
AuthenticationResponse::newPass( $user->getName() ),
$provider->beginPrimaryAuthentication( $reqs )
);
$this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
}
/**
* @dataProvider provideProviderAllowsAuthenticationDataChange
*
* @param string $type
* @param callable $usernameGetter Function that takes the username of a sysop user and returns the username to
* use for testing.
* @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, callable $usernameGetter,
Status $validity,
StatusValue $expect1, StatusValue $expect2
) {
$user = $usernameGetter( $this->getTestSysop()->getUserIdentity()->getName() );
if ( $type === PasswordAuthenticationRequest::class ||
$type === TemporaryPasswordAuthenticationRequest::class
) {
$req = new $type();
$req->password = 'NewPassword';
} else {
$req = $this->createMock( $type );
}
$req->action = AuthManager::ACTION_CHANGE;
$req->username = $user;
$provider = $this->getProvider();
$this->validity = $validity;
$this->assertEquals( $expect1, $provider->providerAllowsAuthenticationDataChange( $req, false ) );
$this->assertEquals( $expect2, $provider->providerAllowsAuthenticationDataChange( $req, true ) );
}
public static function provideProviderAllowsAuthenticationDataChange() {
$err = StatusValue::newGood();
$err->error( 'arbitrary-warning' );
return [
[
AuthenticationRequest::class,
static fn ( $sysopUsername ) => $sysopUsername,
Status::newGood(),
StatusValue::newGood( 'ignored' ),
StatusValue::newGood( 'ignored' ),
],
[
PasswordAuthenticationRequest::class,
static fn ( $sysopUsername ) => $sysopUsername,
Status::newGood(),
StatusValue::newGood( 'ignored' ),
StatusValue::newGood( 'ignored' ),
],
[
TemporaryPasswordAuthenticationRequest::class,
static fn ( $sysopUsername ) => $sysopUsername,
Status::newGood(),
StatusValue::newGood(),
StatusValue::newGood(),
],
[
TemporaryPasswordAuthenticationRequest::class,
'lcfirst',
Status::newGood(),
StatusValue::newGood(),
StatusValue::newGood(),
],
[
TemporaryPasswordAuthenticationRequest::class,
static fn ( $sysopUsername ) => $sysopUsername,
Status::wrap( $err ),
StatusValue::newGood(),
$err,
],
[
TemporaryPasswordAuthenticationRequest::class,
static fn ( $sysopUsername ) => $sysopUsername,
Status::newFatal( 'arbitrary-error' ),
StatusValue::newGood(),
StatusValue::newFatal( 'arbitrary-error' ),
],
[
TemporaryPasswordAuthenticationRequest::class,
static fn () => 'DoesNotExist',
Status::newGood(),
StatusValue::newGood(),
StatusValue::newGood( 'ignored' ),
],
[
TemporaryPasswordAuthenticationRequest::class,
static fn () => '<invalid>',
Status::newGood(),
StatusValue::newGood(),
StatusValue::newGood( 'ignored' ),
],
];
}
/**
* @dataProvider provideProviderChangeAuthenticationData
* @param string $type
* @param bool $changed
*/
public function testProviderChangeAuthenticationData( $type, $changed ) {
$user = $this->getTestSysop()->getUserIdentity()->getName();
$oldpass = 'OldTempPassword';
$newpass = 'NewTempPassword';
$dbw = $this->getDb();
$oldHash = $dbw->newSelectQueryBuilder()
->select( 'user_newpassword' )
->from( 'user' )
->where( [ 'user_name' => $user ] )
->fetchField();
$cb = new ScopedCallback( static function () use ( $dbw, $user, $oldHash ) {
$dbw->newUpdateQueryBuilder()
->update( 'user' )
->set( [ 'user_newpassword' => $oldHash ] )
->where( [ 'user_name' => $user ] )
->execute();
} );
$hash = ':A:' . md5( $oldpass );
$dbw->newUpdateQueryBuilder()
->update( 'user' )
->set( [ 'user_newpassword' => $hash, 'user_newpass_time' => $dbw->timestamp( time() + 1000 ) ] )
->where( [ 'user_name' => $user ] )
->execute();
$provider = $this->getProvider();
$loginReq = new PasswordAuthenticationRequest();
$loginReq->action = AuthManager::ACTION_CHANGE;
$loginReq->username = $user;
$loginReq->password = $oldpass;
$loginReqs = [ PasswordAuthenticationRequest::class => $loginReq ];
$this->assertEquals(
AuthenticationResponse::newPass( $user ),
$provider->beginPrimaryAuthentication( $loginReqs )
);
if ( $type === PasswordAuthenticationRequest::class ||
$type === TemporaryPasswordAuthenticationRequest::class
) {
$changeReq = new $type();
$changeReq->password = $newpass;
} else {
$changeReq = $this->createMock( $type );
}
$changeReq->action = AuthManager::ACTION_CHANGE;
$changeReq->username = $user;
$resetMailer = $this->hookMailer();
$provider->providerChangeAuthenticationData( $changeReq );
ScopedCallback::consume( $resetMailer );
$loginReq->password = $oldpass;
$ret = $provider->beginPrimaryAuthentication( $loginReqs );
$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 ( $changed ) {
$this->assertEquals(
AuthenticationResponse::newPass( $user ),
$ret,
'new password should pass'
);
$this->assertNotNull(
$dbw->newSelectQueryBuilder()
->select( 'user_newpass_time' )
->from( 'user' )
->where( [ 'user_name' => $user ] )
->fetchField()
);
} else {
$this->assertEquals(
AuthenticationResponse::FAIL,
$ret->status,
'new password should fail'
);
$this->assertEquals(
'wrongpassword',
$ret->message->getKey(),
'new password should fail'
);
$this->assertNull(
$dbw->newSelectQueryBuilder()
->select( 'user_newpass_time' )
->from( 'user' )
->where( [ 'user_name' => $user ] )
->fetchField()
);
}
}
public static function provideProviderChangeAuthenticationData() {
return [
[ AuthenticationRequest::class, false ],
[ PasswordAuthenticationRequest::class, false ],
[ TemporaryPasswordAuthenticationRequest::class, true ],
];
}
/**
* @dataProvider provideChangeAuthenticationDataEmailErrorCases
*
* @param array $providerConfig Configuration to pass on to the auth provider
* @param string|null $caller Caller on behalf of which the request is sent
* @param string $expectedError Expected error message key
*/
public function testProviderChangeAuthenticationDataEmailError(
array $providerConfig,
?string $caller,
string $expectedError
): void {
$user = self::getMutableTestUser()->getUser();
$dbw = $this->getDb();
$dbw->newUpdateQueryBuilder()
->update( 'user' )
->set( [ 'user_newpass_time' => $dbw->timestamp( time() - 5 * 3600 ) ] )
->where( [ 'user_id' => $user->getId() ] )
->execute();
$req = TemporaryPasswordAuthenticationRequest::newRandom();
$req->username = $user->getName();
$req->mailpassword = true;
$req->caller = $caller;
$provider = $this->getProvider( $providerConfig );
$status = $provider->providerAllowsAuthenticationDataChange( $req );
$this->assertFalse( $status->isGood() );
$this->assertSame(
[ $expectedError ],
array_map( fn ( MessageSpecifier $spec ) => $spec->getKey(), $status->getMessages() )
);
}
public static function provideChangeAuthenticationDataEmailErrorCases(): iterable {
yield 'email disabled' => [
[ 'emailEnabled' => false ],
'127.0.0.1',
'passwordreset-emaildisabled'
];
yield 'password reset rate limited' => [
[ 'emailEnabled' => true, 'passwordReminderResendTime' => 10 ],
'127.0.0.1',
'throttled-mailpassword'
];
yield 'missing caller' => [
[ 'emailEnabled' => true, 'passwordReminderResendTime' => 0 ],
null,
'passwordreset-nocaller'
];
yield 'invalid IP caller' => [
[ 'emailEnabled' => true, 'passwordReminderResendTime' => 0 ],
'127.0.0.256',
'passwordreset-nosuchcaller'
];
yield 'invalid registered caller' => [
[ 'emailEnabled' => true, 'passwordReminderResendTime' => 0 ],
'<Invalid>',
'passwordreset-nosuchcaller'
];
}
/**
* @dataProvider provideChangeAuthenticationDataEmailSuccessCases
* @param string $caller Caller on behalf of which the request is sent
*/
public function testProviderChangeAuthenticationDataEmailSuccess( string $caller ) {
$user = self::getMutableTestUser()->getUser();
$dbw = $this->getDb();
$dbw->newUpdateQueryBuilder()
->update( 'user' )
->set( [ 'user_newpass_time' => $dbw->timestamp( time() + 5 * 3600 ) ] )
->where( [ 'user_id' => $user->getId() ] )
->execute();
$req = TemporaryPasswordAuthenticationRequest::newRandom();
$req->username = $user->getName();
$req->mailpassword = true;
$req->caller = $caller;
$provider = $this->getProvider( [ 'emailEnabled' => true, 'passwordReminderResendTime' => 0 ] );
$status = $provider->providerAllowsAuthenticationDataChange( $req, true );
$this->assertEquals( StatusValue::newGood(), $status );
$mailed = false;
$resetMailer = $this->hookMailer( function ( $headers, $to, $from, $subject, $body )
use ( &$mailed, $req, $user )
{
$mailed = true;
$this->assertSame( $user->getEmail(), $to[0]->address );
$this->assertStringContainsString( $req->password, $body );
return false;
} );
$provider->providerChangeAuthenticationData( $req );
ScopedCallback::consume( $resetMailer );
$this->assertTrue( $mailed );
}
public static function provideChangeAuthenticationDataEmailSuccessCases(): iterable {
yield 'anonymous caller' => [ '127.0.0.1' ];
yield 'registered caller' => [ 'TestUser' ];
}
/**
* @dataProvider provideAccountCreationSuccessCases
* @param AuthenticationRequest[] $reqs
*/
public function testTestForAccountCreationSuccess( array $reqs ) {
$user = $this->getServiceContainer()->getUserFactory()->newFromName( 'foo' );
$status = $this->getProvider()->testForAccountCreation( $user, $user, $reqs );
$this->assertTrue( $status->isGood() );
}
public static function provideAccountCreationSuccessCases(): iterable {
$req = new TemporaryPasswordAuthenticationRequest();
$req->username = 'Foo';
$req->password = 'Bar';
yield 'no password request' => [
[],
];
yield 'validated password request' => [
[ TemporaryPasswordAuthenticationRequest::class => $req ],
];
}
public function testTestForAccountCreationError(): void {
$req = new TemporaryPasswordAuthenticationRequest();
$req->username = 'Foo';
$req->password = 'Bar';
$user = $this->getServiceContainer()->getUserFactory()->newFromName( 'foo' );
$provider = $this->getProvider();
$this->validity->error( 'arbitrary warning' );
$status = $provider->testForAccountCreation(
$user, $user, [ TemporaryPasswordAuthenticationRequest::class => $req ]
);
$this->assertFalse( $status->isGood() );
$this->assertTrue( $status->hasMessage( 'arbitrary warning' ) );
}
/**
* @dataProvider provideAccountCreationAbstainCases
* @param TemporaryPasswordAuthenticationRequest|null $req
* @return void
*/
public function testAccountCreationAbstain( ?TemporaryPasswordAuthenticationRequest $req ) {
$resetMailer = $this->hookMailer();
$user = $this->getServiceContainer()->getUserFactory()->newFromName( 'Foo' );
$reqs = $req ? [ TemporaryPasswordAuthenticationRequest::class => $req ] : [];
$provider = $this->getProvider();
$response = $provider->beginPrimaryAccountCreation( $user, $user, $reqs );
$this->assertSame( AuthenticationResponse::ABSTAIN, $response->status );
}
public static function provideAccountCreationAbstainCases(): iterable {
yield 'no authentication requests' => [
null,
];
yield 'request without password' => [
self::makeTemporaryPasswordAuthenticationRequest( 'foo' ),
];
yield 'request without username' => [
self::makeTemporaryPasswordAuthenticationRequest( null, 'bar' ),
];
}
public function testAccountCreationPassForUserNameWithDifferentCase(): void {
$user = $this->getServiceContainer()->getUserFactory()->newFromName( 'Foo' );
$pass = 'NewPassword';
$req = self::makeTemporaryPasswordAuthenticationRequest( 'foo', $pass );
$reqs = [ TemporaryPasswordAuthenticationRequest::class => $req ];
$provider = $this->getProvider();
$response = $provider->beginPrimaryAccountCreation( $user, $user, $reqs );
$this->assertSame( AuthenticationResponse::PASS, $response->status );
$this->assertSame( $response->username, $user->getName() );
$this->assertSame(
$response->createRequest->username,
$user->getName()
);
}
public function testAccountCreationPass(): void {
$resetMailer = $this->hookMailer();
$user = self::getMutableTestUser()->getUser();
$pass = 'NewPassword';
$req = self::makeTemporaryPasswordAuthenticationRequest( $user->getName(), $pass );
$reqs = [ TemporaryPasswordAuthenticationRequest::class => $req ];
$provider = $this->getProvider();
$response = $provider->beginPrimaryAccountCreation( $user, $user, $reqs );
$this->assertSame( AuthenticationResponse::PASS, $response->status );
$this->assertSame( $response->username, $user->getName() );
$this->assertSame(
$response->createRequest->username,
$user->getName()
);
$this->assertNull( $this->manager->getAuthenticationSessionData( 'no-email' ) );
$authreq = new PasswordAuthenticationRequest();
$authreq->action = AuthManager::ACTION_CREATE;
$authreq->username = $user->getName();
$authreq->password = $pass;
$authreqs = [ PasswordAuthenticationRequest::class => $authreq ];
$failedAttemptResponse = $provider->beginPrimaryAuthentication( $authreqs );
$this->assertSame( AuthenticationResponse::FAIL, $failedAttemptResponse->status, 'account creation not finished yet' );
$this->assertSame( null, $provider->finishAccountCreation( $user, $user, $response ) );
$response = $provider->beginPrimaryAuthentication( $authreqs );
$this->assertSame( AuthenticationResponse::PASS, $response->status, 'new password is set' );
}
private static function makeTemporaryPasswordAuthenticationRequest(
?string $userName = null,
?string $password = null
): TemporaryPasswordAuthenticationRequest {
$req = new TemporaryPasswordAuthenticationRequest();
$req->username = $userName;
$req->password = $password;
return $req;
}
/**
* @dataProvider provideAccountCreationEmailErrorCases
*
* @param array $providerConfig Configuration to pass on to the auth provider
* @param string $userEmail Email to set for the user being tested
* @param string $expectedError Expected error message key
*/
public function testAccountCreationEmailErrorCases(
array $providerConfig,
string $userEmail,
string $expectedError
): void {
$creator = $this->getServiceContainer()->getUserFactory()->newFromName( 'Foo' );
$user = self::getMutableTestUser()->getUser();
$user->setEmail( $userEmail );
$req = TemporaryPasswordAuthenticationRequest::newRandom();
$req->username = $user->getName();
$req->mailpassword = true;
$provider = $this->getProvider( $providerConfig );
$status = $provider->testForAccountCreation( $user, $creator, [ $req ] );
$this->assertEquals( StatusValue::newFatal( $expectedError ), $status );
}
public static function provideAccountCreationEmailErrorCases(): iterable {
yield 'email disabled' => [
[ 'emailEnabled' => false ],
'test@localhost.localdomain',
'emaildisabled'
];
yield 'missing user email' => [
[ 'emailEnabled' => true ],
'',
'noemailcreate'
];
}
public function testAccountCreationEmailSuccess(): void {
$creator = $this->getServiceContainer()->getUserFactory()->newFromName( 'Foo' );
$user = self::getMutableTestUser()->getUser();
$user->setEmail( 'test@localhost.localdomain' );
$req = TemporaryPasswordAuthenticationRequest::newRandom();
$req->username = $user->getName();
$req->mailpassword = true;
$provider = $this->getProvider( [ 'emailEnabled' => true ] );
$status = $provider->testForAccountCreation( $user, $creator, [ $req ] );
$this->assertEquals( StatusValue::newGood(), $status );
$mailed = false;
$resetMailer = $this->hookMailer( function ( $headers, $to, $from, $subject, $body )
use ( &$mailed, $req )
{
$mailed = true;
$this->assertSame( 'test@localhost.localdomain', $to[0]->address );
$this->assertStringContainsString( $req->password, $body );
return false;
} );
$expect = AuthenticationResponse::newPass( $user->getName() );
$expect->createRequest = clone $req;
$expect->createRequest->username = $user->getName();
$res = $provider->beginPrimaryAccountCreation( $user, $creator, [ $req ] );
$this->assertEquals( $expect, $res );
$this->assertTrue( $this->manager->getAuthenticationSessionData( 'no-email' ) );
$this->assertFalse( $mailed );
$this->assertSame( 'byemail', $provider->finishAccountCreation( $user, $creator, $res ) );
$this->assertTrue( $mailed );
ScopedCallback::consume( $resetMailer );
$this->assertTrue( $mailed );
}
}