3 use MediaWiki\Auth\AuthManager
;
4 use MediaWiki\Auth\TemporaryPasswordAuthenticationRequest
;
5 use MediaWiki\Block\CompositeBlock
;
6 use MediaWiki\Block\DatabaseBlock
;
7 use MediaWiki\Block\SystemBlock
;
8 use MediaWiki\Config\ServiceOptions
;
9 use MediaWiki\MainConfigNames
;
10 use MediaWiki\Request\WebRequest
;
11 use MediaWiki\Status\Status
;
12 use MediaWiki\Tests\Unit\DummyServicesTrait
;
13 use MediaWiki\User\Options\StaticUserOptionsLookup
;
14 use MediaWiki\User\Options\UserOptionsLookup
;
15 use MediaWiki\User\PasswordReset
;
16 use MediaWiki\User\User
;
17 use MediaWiki\User\UserFactory
;
18 use MediaWiki\User\UserIdentityLookup
;
19 use MediaWiki\User\UserNameUtils
;
20 use Psr\Log\NullLogger
;
23 * TODO make this a unit test, all dependencies are injected, but DatabaseBlock::__construct()
24 * can't be used in unit tests.
26 * @covers \MediaWiki\User\PasswordReset
29 class PasswordResetTest
extends MediaWikiIntegrationTestCase
{
30 use DummyServicesTrait
;
32 private const VALID_IP
= '1.2.3.4';
33 private const VALID_EMAIL
= 'foo@bar.baz';
36 * @dataProvider provideIsAllowed
38 public function testIsAllowed( $passwordResetRoutes, $enableEmail,
39 $allowsAuthenticationDataChange, $canEditPrivate, $block, $isAllowed
41 $config = $this->makeConfig( $enableEmail, $passwordResetRoutes );
43 $authManager = $this->createMock( AuthManager
::class );
44 $authManager->method( 'allowsAuthenticationDataChange' )
45 ->willReturn( $allowsAuthenticationDataChange ? Status
::newGood() : Status
::newFatal( 'foo' ) );
47 $user = $this->createMock( User
::class );
48 $user->method( 'getName' )->willReturn( 'Foo' );
49 $user->method( 'getBlock' )->willReturn( $block );
50 $user->method( 'isAllowed' )->with( 'editmyprivateinfo' )->willReturn( $canEditPrivate );
52 $passwordReset = new PasswordReset(
56 $this->createHookContainer(),
57 $this->createNoOpMock( UserIdentityLookup
::class ),
58 $this->createNoOpMock( UserFactory
::class ),
59 $this->createNoOpMock( UserNameUtils
::class ),
60 $this->createNoOpMock( UserOptionsLookup
::class )
63 $this->assertSame( $isAllowed, $passwordReset->isAllowed( $user )->isGood() );
66 public static function provideIsAllowed() {
69 'passwordResetRoutes' => [],
70 'enableEmail' => true,
71 'allowsAuthenticationDataChange' => true,
72 'canEditPrivate' => true,
77 'passwordResetRoutes' => [ 'username' => true ],
78 'enableEmail' => false,
79 'allowsAuthenticationDataChange' => true,
80 'canEditPrivate' => true,
84 'auth data change disabled' => [
85 'passwordResetRoutes' => [ 'username' => true ],
86 'enableEmail' => true,
87 'allowsAuthenticationDataChange' => false,
88 'canEditPrivate' => true,
92 'cannot edit private data' => [
93 'passwordResetRoutes' => [ 'username' => true ],
94 'enableEmail' => true,
95 'allowsAuthenticationDataChange' => true,
96 'canEditPrivate' => false,
100 'blocked with account creation disabled' => [
101 'passwordResetRoutes' => [ 'username' => true ],
102 'enableEmail' => true,
103 'allowsAuthenticationDataChange' => true,
104 'canEditPrivate' => true,
105 'block' => new DatabaseBlock( [ 'createAccount' => true ] ),
106 'isAllowed' => false,
108 'blocked w/o account creation disabled' => [
109 'passwordResetRoutes' => [ 'username' => true ],
110 'enableEmail' => true,
111 'allowsAuthenticationDataChange' => true,
112 'canEditPrivate' => true,
113 'block' => new DatabaseBlock( [] ),
116 'using blocked proxy' => [
117 'passwordResetRoutes' => [ 'username' => true ],
118 'enableEmail' => true,
119 'allowsAuthenticationDataChange' => true,
120 'canEditPrivate' => true,
121 'block' => new SystemBlock(
122 [ 'systemBlock' => 'proxy' ]
124 'isAllowed' => false,
126 'globally blocked with account creation not disabled' => [
127 'passwordResetRoutes' => [ 'username' => true ],
128 'enableEmail' => true,
129 'allowsAuthenticationDataChange' => true,
130 'canEditPrivate' => true,
134 'blocked via wgSoftBlockRanges' => [
135 'passwordResetRoutes' => [ 'username' => true ],
136 'enableEmail' => true,
137 'allowsAuthenticationDataChange' => true,
138 'canEditPrivate' => true,
139 'block' => new SystemBlock(
140 [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ]
144 'blocked with an unknown system block type' => [
145 'passwordResetRoutes' => [ 'username' => true ],
146 'enableEmail' => true,
147 'allowsAuthenticationDataChange' => true,
148 'canEditPrivate' => true,
149 'block' => new SystemBlock( [ 'systemBlock' => 'unknown' ] ),
150 'isAllowed' => false,
152 'blocked with multiple blocks, all allowing password reset' => [
153 'passwordResetRoutes' => [ 'username' => true ],
154 'enableEmail' => true,
155 'allowsAuthenticationDataChange' => true,
156 'canEditPrivate' => true,
157 'block' => new CompositeBlock( [
158 'originalBlocks' => [
159 new SystemBlock( [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ] ),
160 new DatabaseBlock( [] ),
165 'blocked with multiple blocks, not all allowing password reset' => [
166 'passwordResetRoutes' => [ 'username' => true ],
167 'enableEmail' => true,
168 'allowsAuthenticationDataChange' => true,
169 'canEditPrivate' => true,
170 'block' => new CompositeBlock( [
171 'originalBlocks' => [
172 new SystemBlock( [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ] ),
173 new SystemBlock( [ 'systemBlock' => 'proxy' ] ),
176 'isAllowed' => false,
179 'passwordResetRoutes' => [ 'username' => true ],
180 'enableEmail' => true,
181 'allowsAuthenticationDataChange' => true,
182 'canEditPrivate' => true,
189 public function testExecute_notAllowed() {
190 $user = $this->createMock( User
::class );
191 /** @var User $user */
193 $passwordReset = $this->getMockBuilder( PasswordReset
::class )
194 ->disableOriginalConstructor()
195 ->onlyMethods( [ 'isAllowed' ] )
197 $passwordReset->method( 'isAllowed' )
199 ->willReturn( Status
::newFatal( 'somestatuscode' ) );
200 /** @var PasswordReset $passwordReset */
202 $this->expectException( LogicException
::class );
203 $passwordReset->execute( $user );
207 * @dataProvider provideExecute
208 * @param string|bool $expectedError
209 * @param ServiceOptions $config
210 * @param User $performingUser
211 * @param AuthManager $authManager
212 * @param string|null $username
213 * @param string|null $email
214 * @param User[] $usersWithEmail
215 * @covers \MediaWiki\Deferred\SendPasswordResetEmailUpdate
217 public function testExecute(
219 ServiceOptions
$config,
220 User
$performingUser,
221 AuthManager
$authManager,
224 array $usersWithEmail = []
226 $users = $this->makeUsers();
228 // Only User1 has `requireemail` true, everything else false (so that is the default)
229 $userOptionsLookup = new StaticUserOptionsLookup(
230 [ 'User1' => [ 'requireemail' => true ] ],
231 [ 'requireemail' => false ]
234 // Similar to $lookupUser callback, but with null instead of false
235 $userFactory = $this->createMock( UserFactory
::class );
236 $userFactory->method( 'newFromName' )
237 ->willReturnCallback(
238 static function ( $username ) use ( $users ) {
239 return $users[ $username ] ??
null;
243 $userIdentityLookup = $this->createMock( UserIdentityLookup
::class );
244 $userFactory->method( 'newFromUserIdentity' )
245 ->willReturnArgument( 0 );
247 $lookupUser = static function ( $username ) use ( $users ) {
248 return $users[ $username ] ??
false;
251 $passwordReset = $this->getMockBuilder( PasswordReset
::class )
252 ->onlyMethods( [ 'getUsersByEmail', 'isAllowed' ] )
253 ->setConstructorArgs( [
257 $this->createHookContainer(),
260 $this->getDummyUserNameUtils(),
264 $passwordReset->method( 'getUsersByEmail' )->with( $email )
265 ->willReturn( array_map( $lookupUser, $usersWithEmail ) );
266 $passwordReset->method( 'isAllowed' )
267 ->willReturn( Status
::newGood() );
269 /** @var PasswordReset $passwordReset */
270 $status = $passwordReset->execute( $performingUser, $username, $email );
272 if ( is_string( $expectedError ) ) {
273 $this->assertStatusError( $expectedError, $status );
274 } elseif ( $expectedError ) {
275 $this->assertStatusNotOk( $status );
277 $this->assertStatusGood( $status );
281 public function provideExecute() {
282 // 'User1' has the 'requireemail' preference set (see testExecute()). Other users do not.
283 $defaultConfig = $this->makeConfig( true, [ 'username' => true, 'email' => true ] );
284 $performingUser = $this->makePerformingUser( self
::VALID_IP
, false );
285 $throttledUser = $this->makePerformingUser( self
::VALID_IP
, true );
288 'Throttled, pretend everything is ok' => [
289 'expectedError' => false,
290 'config' => $defaultConfig,
291 'performingUser' => $throttledUser,
292 'authManager' => $this->makeAuthManager(),
293 'username' => 'User1',
295 'usersWithEmail' => [],
297 'Throttled, email required for resets, is invalid, pretend everything is ok' => [
298 'expectedError' => false,
299 'config' => $defaultConfig,
300 'performingUser' => $throttledUser,
301 'authManager' => $this->makeAuthManager(),
302 'username' => 'User1',
303 'email' => '[invalid email]',
304 'usersWithEmail' => [],
307 'expectedError' => 'passwordreset-invalidemail',
308 'config' => $defaultConfig,
309 'performingUser' => $performingUser,
310 'authManager' => $this->makeAuthManager(),
312 'email' => '[invalid email]',
313 'usersWithEmail' => [],
315 'No username, no email' => [
316 'expectedError' => 'passwordreset-nodata',
317 'config' => $defaultConfig,
318 'performingUser' => $performingUser,
319 'authManager' => $this->makeAuthManager(),
322 'usersWithEmail' => [],
324 'Email route not enabled' => [
325 'expectedError' => 'passwordreset-nodata',
326 'config' => $this->makeConfig( true, [ 'username' => true ] ),
327 'performingUser' => $performingUser,
328 'authManager' => $this->makeAuthManager(),
330 'email' => self
::VALID_EMAIL
,
331 'usersWithEmail' => [],
333 'Username route not enabled' => [
334 'expectedError' => 'passwordreset-nodata',
335 'config' => $this->makeConfig( true, [ 'email' => true ] ),
336 'performingUser' => $performingUser,
337 'authManager' => $this->makeAuthManager(),
338 'username' => 'User1',
340 'usersWithEmail' => [],
342 'No routes enabled' => [
343 'expectedError' => 'passwordreset-nodata',
344 'config' => $this->makeConfig( true, [] ),
345 'performingUser' => $performingUser,
346 'authManager' => $this->makeAuthManager(),
347 'username' => 'User1',
348 'email' => self
::VALID_EMAIL
,
349 'usersWithEmail' => [],
351 'Email required for resets but is empty, pretend everything is OK' => [
352 'expectedError' => false,
353 'config' => $defaultConfig,
354 'performingUser' => $performingUser,
355 'authManager' => $this->makeAuthManager(),
356 'username' => 'User1',
358 'usersWithEmail' => [],
360 'Email required for resets but is invalid' => [
361 'expectedError' => 'passwordreset-invalidemail',
362 'config' => $defaultConfig,
363 'performingUser' => $performingUser,
364 'authManager' => $this->makeAuthManager(),
365 'username' => 'User1',
366 'email' => '[invalid email]',
367 'usersWithEmail' => [],
369 'Password email already sent within 24 hours, pretend everything is ok' => [
370 'expectedError' => false,
371 'config' => $defaultConfig,
372 'performingUser' => $performingUser,
373 'authManager' => $this->makeAuthManager( [ 'User1' ], 0, [], [ 'User1' ] ),
374 'username' => 'User1',
376 'usersWithEmail' => [ 'User1' ],
378 'No user by this username, pretend everything is OK' => [
379 'expectedError' => false,
380 'config' => $defaultConfig,
381 'performingUser' => $performingUser,
382 'authManager' => $this->makeAuthManager(),
383 'username' => 'Nonexistent user',
385 'usersWithEmail' => [],
387 'Username is not valid' => [
388 'expectedError' => 'noname',
389 'config' => $defaultConfig,
390 'performingUser' => $performingUser,
391 'authManager' => $this->makeAuthManager(),
392 'username' => 'Invalid|username',
394 'usersWithEmail' => [],
396 'If no users with this email found, pretend everything is OK' => [
397 'expectedError' => false,
398 'config' => $defaultConfig,
399 'performingUser' => $performingUser,
400 'authManager' => $this->makeAuthManager(),
402 'email' => 'some@not.found.email',
403 'usersWithEmail' => [],
405 'No email for the user, pretend everything is OK' => [
406 'expectedError' => false,
407 'config' => $defaultConfig,
408 'performingUser' => $performingUser,
409 'authManager' => $this->makeAuthManager(),
410 'username' => 'BadUser',
412 'usersWithEmail' => [],
414 'Email required for resets, no match' => [
415 'expectedError' => false,
416 'config' => $defaultConfig,
417 'performingUser' => $performingUser,
418 'authManager' => $this->makeAuthManager(),
419 'username' => 'User1',
420 'email' => 'some@other.email',
421 'usersWithEmail' => [],
423 "Couldn't determine the performing user's IP" => [
424 'expectedError' => 'badipaddress',
425 'config' => $defaultConfig,
426 'performingUser' => $this->makePerformingUser( '', false ),
427 'authManager' => $this->makeAuthManager(),
428 'username' => 'User1',
430 'usersWithEmail' => [],
432 'User is allowed, but ignored' => [
433 'expectedError' => 'passwordreset-ignored',
434 'config' => $defaultConfig,
435 'performingUser' => $performingUser,
436 'authManager' => $this->makeAuthManager( [ 'User2' ], 0, [ 'User2' ] ),
437 'username' => 'User2',
439 'usersWithEmail' => [],
441 'One of users is ignored' => [
442 'expectedError' => 'passwordreset-ignored',
443 'config' => $defaultConfig,
444 'performingUser' => $performingUser,
445 'authManager' => $this->makeAuthManager( [ 'User1', 'User2' ], 0, [ 'User2' ] ),
447 'email' => self
::VALID_EMAIL
,
448 'usersWithEmail' => [ 'User1', 'User2' ],
450 'User is rejected' => [
451 'expectedError' => 'rejected by test mock',
452 'config' => $defaultConfig,
453 'performingUser' => $performingUser,
454 'authManager' => $this->makeAuthManager(),
455 'username' => 'User2',
457 'usersWithEmail' => [],
459 'One of users is rejected' => [
460 'expectedError' => 'rejected by test mock',
461 'config' => $defaultConfig,
462 'performingUser' => $performingUser,
463 'authManager' => $this->makeAuthManager( [ 'User1' ] ),
465 'email' => self
::VALID_EMAIL
,
466 'usersWithEmail' => [ 'User1', 'User2' ],
468 'Reset one user via password' => [
469 'expectedError' => false,
470 'config' => $defaultConfig,
471 'performingUser' => $performingUser,
472 'authManager' => $this->makeAuthManager( [ 'User1' ], 1 ),
473 'username' => 'User1',
474 'email' => self
::VALID_EMAIL
,
475 // Make sure that only the user specified by username is reset
476 'usersWithEmail' => [ 'User1', 'User2' ],
478 'Reset one user via email' => [
479 'expectedError' => false,
480 'config' => $defaultConfig,
481 'performingUser' => $performingUser,
482 'authManager' => $this->makeAuthManager( [ 'User2' ], 1 ),
484 'email' => self
::VALID_EMAIL
,
485 'usersWithEmail' => [ 'User2' ],
487 'Reset multiple users via email' => [
488 'expectedError' => false,
489 'config' => $defaultConfig,
490 'performingUser' => $performingUser,
491 'authManager' => $this->makeAuthManager( [ 'User2', 'User3' ], 2 ),
493 'email' => self
::VALID_EMAIL
,
494 'usersWithEmail' => [ 'User2', 'User3' ],
496 "Email is not required for resets, this user didn't opt in" => [
497 'expectedError' => false,
498 'config' => $defaultConfig,
499 'performingUser' => $performingUser,
500 'authManager' => $this->makeAuthManager( [ 'User2' ], 1 ),
501 'username' => 'User2',
502 'email' => self
::VALID_EMAIL
,
503 'usersWithEmail' => [ 'User2' ],
505 'Reset three users via email that did not opt in, multiple users with same email' => [
506 'expectedError' => false,
507 'config' => $defaultConfig,
508 'performingUser' => $performingUser,
509 'authManager' => $this->makeAuthManager( [ 'User2', 'User3', 'User4' ], 3, [ 'User1' ] ),
511 'email' => self
::VALID_EMAIL
,
512 'usersWithEmail' => [ 'User1', 'User2', 'User3', 'User4' ],
517 private function makeConfig( $enableEmail, array $passwordResetRoutes ) {
519 MainConfigNames
::EnableEmail
=> $enableEmail,
520 MainConfigNames
::PasswordResetRoutes
=> $passwordResetRoutes,
523 return new ServiceOptions( PasswordReset
::CONSTRUCTOR_OPTIONS
, $hash );
528 * @param bool $pingLimited
531 private function makePerformingUser( string $ip, $pingLimited ): User
{
532 $request = $this->createMock( WebRequest
::class );
533 $request->method( 'getIP' )
535 /** @var WebRequest $request */
537 $user = $this->getMockBuilder( User
::class )
538 ->onlyMethods( [ 'getName', 'pingLimiter', 'getRequest', 'isAllowed' ] )
541 $user->method( 'getName' )
542 ->willReturn( 'SomeUser' );
543 $user->method( 'pingLimiter' )
544 ->with( 'mailpassword' )
545 ->willReturn( $pingLimited );
546 $user->method( 'getRequest' )
547 ->willReturn( $request );
549 // Always has the relevant rights, just checking based on rate limits
550 $user->method( 'isAllowed' )->with( 'editmyprivateinfo' )->willReturn( true );
552 /** @var User $user */
557 * @param string[] $allowed Usernames that are allowed to send password reset email
558 * by AuthManager's allowsAuthenticationDataChange method.
559 * @param int $numUsersToAuth Number of users that will receive email
560 * @param string[] $ignored Usernames that are allowed but ignored by AuthManager's
561 * allowsAuthenticationDataChange method and will not receive password reset email.
562 * @param string[] $mailThrottledLimited Usernames that have already
563 * received the password reset email within a given time, and AuthManager
564 * changeAuthenticationData method will mark them as 'throttled-mailpassword.'
565 * @return AuthManager
567 private function makeAuthManager(
571 array $mailThrottledLimited = []
573 $authManager = $this->createMock( AuthManager
::class );
574 $authManager->method( 'allowsAuthenticationDataChange' )
575 ->willReturnCallback(
576 static function ( TemporaryPasswordAuthenticationRequest
$req )
577 use ( $allowed, $ignored, $mailThrottledLimited ) {
578 if ( in_array( $req->username
, $mailThrottledLimited, true ) ) {
579 return Status
::newGood( 'throttled-mailpassword' );
582 $value = in_array( $req->username
, $ignored, true )
586 return in_array( $req->username
, $allowed, true )
587 ? Status
::newGood( $value )
588 : Status
::newFatal( 'rejected by test mock' );
590 // changeAuthenticationData is executed in the deferred update class
591 // SendPasswordResetEmailUpdate
592 $authManager->expects( $this->exactly( $numUsersToAuth ) )
593 ->method( 'changeAuthenticationData' );
595 /** @var AuthManager $authManager */
602 private function makeUsers() {
603 $getGoodUserCb = function ( int $num ) {
604 $user = $this->createMock( User
::class );
605 $user->method( 'getName' )->willReturn( "User$num" );
606 $user->method( 'getId' )->willReturn( $num );
607 $user->method( 'isRegistered' )->willReturn( true );
608 $user->method( 'getEmail' )->willReturn( self
::VALID_EMAIL
);
611 $user1 = $getGoodUserCb( 1 );
612 $user2 = $getGoodUserCb( 2 );
613 $user3 = $getGoodUserCb( 3 );
614 $user4 = $getGoodUserCb( 4 );
616 $badUser = $this->createMock( User
::class );
617 $badUser->method( 'getName' )->willReturn( 'BadUser' );
618 $badUser->method( 'getId' )->willReturn( 5 );
619 $badUser->method( 'isRegistered' )->willReturn( true );
620 $badUser->method( 'getEmail' )->willReturn( '' );
622 $nonexistUser = $this->createMock( User
::class );
623 $nonexistUser->method( 'getName' )->willReturn( 'Nonexistent user' );
624 $nonexistUser->method( 'getId' )->willReturn( 0 );
625 $nonexistUser->method( 'isRegistered' )->willReturn( false );
626 $nonexistUser->method( 'getEmail' )->willReturn( '' );
633 'BadUser' => $badUser,
634 'Nonexistent user' => $nonexistUser,