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\Permissions\PermissionManager
;
10 use Psr\Log\NullLogger
;
11 use Wikimedia\Rdbms\ILoadBalancer
;
14 * @covers PasswordReset
17 class PasswordResetTest
extends MediaWikiTestCase
{
18 const VALID_IP
= '1.2.3.4';
19 const VALID_EMAIL
= 'foo@bar.baz';
22 * @dataProvider provideIsAllowed
24 public function testIsAllowed( $passwordResetRoutes, $enableEmail,
25 $allowsAuthenticationDataChange, $canEditPrivate, $block, $globalBlock, $isAllowed
27 $config = $this->makeConfig( $enableEmail, $passwordResetRoutes, false );
29 $authManager = $this->getMockBuilder( AuthManager
::class )->disableOriginalConstructor()
31 $authManager->expects( $this->any() )->method( 'allowsAuthenticationDataChange' )
32 ->willReturn( $allowsAuthenticationDataChange ? Status
::newGood() : Status
::newFatal( 'foo' ) );
34 $user = $this->getMockBuilder( User
::class )->getMock();
35 $user->expects( $this->any() )->method( 'getName' )->willReturn( 'Foo' );
36 $user->expects( $this->any() )->method( 'getBlock' )->willReturn( $block );
37 $user->expects( $this->any() )->method( 'getGlobalBlock' )->willReturn( $globalBlock );
39 $permissionManager = $this->getMockBuilder( PermissionManager
::class )
40 ->disableOriginalConstructor()
42 $permissionManager->method( 'userHasRight' )
43 ->with( $user, 'editmyprivateinfo' )
44 ->willReturn( $canEditPrivate );
46 $loadBalancer = $this->createMock( ILoadBalancer
::class );
48 $passwordReset = new PasswordReset(
56 $this->assertSame( $isAllowed, $passwordReset->isAllowed( $user )->isGood() );
59 public function provideIsAllowed() {
62 'passwordResetRoutes' => [],
63 'enableEmail' => true,
64 'allowsAuthenticationDataChange' => true,
65 'canEditPrivate' => true,
67 'globalBlock' => null,
71 'passwordResetRoutes' => [ 'username' => true ],
72 'enableEmail' => false,
73 'allowsAuthenticationDataChange' => true,
74 'canEditPrivate' => true,
76 'globalBlock' => null,
79 'auth data change disabled' => [
80 'passwordResetRoutes' => [ 'username' => true ],
81 'enableEmail' => true,
82 'allowsAuthenticationDataChange' => false,
83 'canEditPrivate' => true,
85 'globalBlock' => null,
88 'cannot edit private data' => [
89 'passwordResetRoutes' => [ 'username' => true ],
90 'enableEmail' => true,
91 'allowsAuthenticationDataChange' => true,
92 'canEditPrivate' => false,
94 'globalBlock' => null,
97 'blocked with account creation disabled' => [
98 'passwordResetRoutes' => [ 'username' => true ],
99 'enableEmail' => true,
100 'allowsAuthenticationDataChange' => true,
101 'canEditPrivate' => true,
102 'block' => new DatabaseBlock( [ 'createAccount' => true ] ),
103 'globalBlock' => null,
104 'isAllowed' => false,
106 'blocked w/o account creation disabled' => [
107 'passwordResetRoutes' => [ 'username' => true ],
108 'enableEmail' => true,
109 'allowsAuthenticationDataChange' => true,
110 'canEditPrivate' => true,
111 'block' => new DatabaseBlock( [] ),
112 'globalBlock' => null,
115 'using blocked proxy' => [
116 'passwordResetRoutes' => [ 'username' => true ],
117 'enableEmail' => true,
118 'allowsAuthenticationDataChange' => true,
119 'canEditPrivate' => true,
120 'block' => new SystemBlock(
121 [ 'systemBlock' => 'proxy' ]
123 'globalBlock' => null,
124 'isAllowed' => false,
126 'globally blocked with account creation not disabled' => [
127 'passwordResetRoutes' => [ 'username' => true ],
128 'enableEmail' => true,
129 'allowsAuthenticationDataChange' => true,
130 'canEditPrivate' => true,
132 'globalBlock' => new SystemBlock(
133 [ 'systemBlock' => 'global-block' ]
137 'blocked via wgSoftBlockRanges' => [
138 'passwordResetRoutes' => [ 'username' => true ],
139 'enableEmail' => true,
140 'allowsAuthenticationDataChange' => true,
141 'canEditPrivate' => true,
142 'block' => new SystemBlock(
143 [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ]
145 'globalBlock' => null,
148 'blocked with an unknown system block type' => [
149 'passwordResetRoutes' => [ 'username' => true ],
150 'enableEmail' => true,
151 'allowsAuthenticationDataChange' => true,
152 'canEditPrivate' => true,
153 'block' => new SystemBlock( [ 'systemBlock' => 'unknown' ] ),
154 'globalBlock' => null,
155 'isAllowed' => false,
157 'blocked with multiple blocks, all allowing password reset' => [
158 'passwordResetRoutes' => [ 'username' => true ],
159 'enableEmail' => true,
160 'allowsAuthenticationDataChange' => true,
161 'canEditPrivate' => true,
162 'block' => new CompositeBlock( [
163 'originalBlocks' => [
164 new SystemBlock( [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ] ),
168 'globalBlock' => null,
171 'blocked with multiple blocks, not all allowing password reset' => [
172 'passwordResetRoutes' => [ 'username' => true ],
173 'enableEmail' => true,
174 'allowsAuthenticationDataChange' => true,
175 'canEditPrivate' => true,
176 'block' => new CompositeBlock( [
177 'originalBlocks' => [
178 new SystemBlock( [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ] ),
179 new SystemBlock( [ 'systemBlock' => 'proxy' ] ),
182 'globalBlock' => null,
183 'isAllowed' => false,
186 'passwordResetRoutes' => [ 'username' => true ],
187 'enableEmail' => true,
188 'allowsAuthenticationDataChange' => true,
189 'canEditPrivate' => true,
191 'globalBlock' => null,
197 public function testExecute_notAllowed() {
198 $user = $this->createMock( User
::class );
199 /** @var User $user */
201 $passwordReset = $this->getMockBuilder( PasswordReset
::class )
202 ->disableOriginalConstructor()
203 ->setMethods( [ 'isAllowed' ] )
205 $passwordReset->expects( $this->any() )
206 ->method( 'isAllowed' )
208 ->willReturn( Status
::newFatal( 'somestatuscode' ) );
209 /** @var PasswordReset $passwordReset */
211 $this->expectException( \LogicException
::class );
212 $passwordReset->execute( $user );
216 * @dataProvider provideExecute
217 * @param string|bool $expectedError
218 * @param ServiceOptions $config
219 * @param User $performingUser
220 * @param PermissionManager $permissionManager
221 * @param AuthManager $authManager
222 * @param string|null $username
223 * @param string|null $email
224 * @param User[] $usersWithEmail
225 * @covers SendPasswordResetEmailUpdate
227 public function testExecute(
229 ServiceOptions
$config,
230 User
$performingUser,
231 PermissionManager
$permissionManager,
232 AuthManager
$authManager,
235 array $usersWithEmail = []
237 // Unregister the hooks for proper unit testing
238 $this->mergeMwGlobalArrayValue( 'wgHooks', [
239 'User::mailPasswordInternal' => [],
240 'SpecialPasswordResetOnSubmit' => [],
243 $loadBalancer = $this->createMock( ILoadBalancer
::class );
245 $users = $this->makeUsers();
247 $lookupUser = function ( $username ) use ( $users ) {
248 return $users[ $username ] ??
false;
251 $passwordReset = $this->getMockBuilder( PasswordReset
::class )
252 ->setMethods( [ 'getUsersByEmail', 'isAllowed', 'lookupUser' ] )
253 ->setConstructorArgs( [
261 $passwordReset->method( 'getUsersByEmail' )->with( $email )
262 ->willReturn( array_map( $lookupUser, $usersWithEmail ) );
263 $passwordReset->method( 'isAllowed' )
264 ->willReturn( Status
::newGood() );
265 $passwordReset->method( 'lookupUser' )
266 ->willReturnCallback( $lookupUser );
268 /** @var PasswordReset $passwordReset */
269 $status = $passwordReset->execute( $performingUser, $username, $email );
270 $this->assertStatus( $status, $expectedError );
273 public function provideExecute() {
274 $defaultConfig = $this->makeConfig( true, [ 'username' => true, 'email' => true ], false );
275 $emailRequiredConfig = $this->makeConfig( true, [ 'username' => true, 'email' => true ], true );
276 $performingUser = $this->makePerformingUser( self
::VALID_IP
, false );
277 $throttledUser = $this->makePerformingUser( self
::VALID_IP
, true );
278 $permissionManager = $this->makePermissionManager( $performingUser, true );
281 'Throttled, pretend everything is ok' => [
282 'expectedError' => false,
283 'config' => $defaultConfig,
284 'performingUser' => $throttledUser,
285 'permissionManager' => $permissionManager,
286 'authManager' => $this->makeAuthManager(),
287 'username' => 'User1',
289 'usersWithEmail' => [],
291 'Throttled, email required for resets, is invalid, pretend everything is ok' => [
292 'expectedError' => false,
293 'config' => $emailRequiredConfig,
294 'performingUser' => $throttledUser,
295 'permissionManager' => $permissionManager,
296 'authManager' => $this->makeAuthManager(),
297 'username' => 'User1',
298 'email' => '[invalid email]',
299 'usersWithEmail' => [],
301 'Invalid email, pretend everything is OK' => [
302 'expectedError' => false,
303 'config' => $defaultConfig,
304 'performingUser' => $performingUser,
305 'permissionManager' => $permissionManager,
306 'authManager' => $this->makeAuthManager(),
308 'email' => '[invalid email]',
309 'usersWithEmail' => [],
311 'No username, no email' => [
312 'expectedError' => 'passwordreset-nodata',
313 'config' => $defaultConfig,
314 'performingUser' => $performingUser,
315 'permissionManager' => $permissionManager,
316 'authManager' => $this->makeAuthManager(),
319 'usersWithEmail' => [],
321 'Email route not enabled' => [
322 'expectedError' => 'passwordreset-nodata',
323 'config' => $this->makeConfig( true, [ 'username' => true ], false ),
324 'performingUser' => $performingUser,
325 'permissionManager' => $permissionManager,
326 'authManager' => $this->makeAuthManager(),
328 'email' => self
::VALID_EMAIL
,
329 'usersWithEmail' => [],
331 'Username route not enabled' => [
332 'expectedError' => 'passwordreset-nodata',
333 'config' => $this->makeConfig( true, [ 'email' => true ], false ),
334 'performingUser' => $performingUser,
335 'permissionManager' => $permissionManager,
336 'authManager' => $this->makeAuthManager(),
337 'username' => 'User1',
339 'usersWithEmail' => [],
341 'No routes enabled' => [
342 'expectedError' => 'passwordreset-nodata',
343 'config' => $this->makeConfig( true, [], false ),
344 'performingUser' => $performingUser,
345 'permissionManager' => $permissionManager,
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' => $emailRequiredConfig,
354 'performingUser' => $performingUser,
355 'permissionManager' => $permissionManager,
356 'authManager' => $this->makeAuthManager(),
357 'username' => 'User1',
359 'usersWithEmail' => [],
361 'Email required for resets but is invalid, pretend everything is OK' => [
362 'expectedError' => false,
363 'config' => $emailRequiredConfig,
364 'performingUser' => $performingUser,
365 'permissionManager' => $permissionManager,
366 'authManager' => $this->makeAuthManager(),
367 'username' => 'User1',
368 'email' => '[invalid email]',
369 'usersWithEmail' => [],
371 'Password email already sent within 24 hours, pretend everything is ok' => [
372 'expectedError' => false,
373 'config' => $defaultConfig,
374 'performingUser' => $performingUser,
375 'permissionManager' => $permissionManager,
376 'authManager' => $this->makeAuthManager( [ 'User1' ], 0, [], [ 'User1' ] ),
377 'username' => 'User1',
379 'usersWithEmail' => [ 'User1' ],
381 'No user by this username, pretend everything is OK' => [
382 'expectedError' => false,
383 'config' => $defaultConfig,
384 'performingUser' => $performingUser,
385 'permissionManager' => $permissionManager,
386 'authManager' => $this->makeAuthManager(),
387 'username' => 'Nonexistent user',
389 'usersWithEmail' => [],
391 'Username is not valid' => [
392 'expectedError' => 'noname',
393 'config' => $defaultConfig,
394 'performingUser' => $performingUser,
395 'permissionManager' => $permissionManager,
396 'authManager' => $this->makeAuthManager(),
397 'username' => 'Invalid|username',
399 'usersWithEmail' => [],
401 'If no users with this email found, pretend everything is OK' => [
402 'expectedError' => false,
403 'config' => $defaultConfig,
404 'performingUser' => $performingUser,
405 'permissionManager' => $permissionManager,
406 'authManager' => $this->makeAuthManager(),
408 'email' => 'some@not.found.email',
409 'usersWithEmail' => [],
411 'No email for the user, pretend everything is OK' => [
412 'expectedError' => false,
413 'config' => $defaultConfig,
414 'performingUser' => $performingUser,
415 'permissionManager' => $permissionManager,
416 'authManager' => $this->makeAuthManager(),
417 'username' => 'BadUser',
419 'usersWithEmail' => [],
421 'Email required for resets, no match' => [
422 'expectedError' => false,
423 'config' => $emailRequiredConfig,
424 'performingUser' => $performingUser,
425 'permissionManager' => $permissionManager,
426 'authManager' => $this->makeAuthManager(),
427 'username' => 'User1',
428 'email' => 'some@other.email',
429 'usersWithEmail' => [],
431 "Couldn't determine the performing user's IP" => [
432 'expectedError' => 'badipaddress',
433 'config' => $defaultConfig,
434 'performingUser' => $this->makePerformingUser( null, false ),
435 'permissionManager' => $permissionManager,
436 'authManager' => $this->makeAuthManager(),
437 'username' => 'User1',
439 'usersWithEmail' => [],
441 'User is allowed, but ignored' => [
442 'expectedError' => 'passwordreset-ignored',
443 'config' => $defaultConfig,
444 'performingUser' => $performingUser,
445 'permissionManager' => $permissionManager,
446 'authManager' => $this->makeAuthManager( [ 'User1' ], 0, [ 'User1' ] ),
447 'username' => 'User1',
449 'usersWithEmail' => [],
451 'One of users is ignored' => [
452 'expectedError' => 'passwordreset-ignored',
453 'config' => $defaultConfig,
454 'performingUser' => $performingUser,
455 'permissionManager' => $permissionManager,
456 'authManager' => $this->makeAuthManager( [ 'User1', 'User2' ], 0, [ 'User2' ] ),
458 'email' => self
::VALID_EMAIL
,
459 'usersWithEmail' => [ 'User1', 'User2' ],
461 'User is rejected' => [
462 'expectedError' => 'rejected by test mock',
463 'config' => $defaultConfig,
464 'performingUser' => $performingUser,
465 'permissionManager' => $permissionManager,
466 'authManager' => $this->makeAuthManager(),
467 'username' => 'User1',
469 'usersWithEmail' => [],
471 'One of users is rejected' => [
472 'expectedError' => 'rejected by test mock',
473 'config' => $defaultConfig,
474 'performingUser' => $performingUser,
475 'permissionManager' => $permissionManager,
476 'authManager' => $this->makeAuthManager( [ 'User1' ] ),
478 'email' => self
::VALID_EMAIL
,
479 'usersWithEmail' => [ 'User1', 'User2' ],
481 'Reset one user via password' => [
482 'expectedError' => false,
483 'config' => $defaultConfig,
484 'performingUser' => $performingUser,
485 'permissionManager' => $permissionManager,
486 'authManager' => $this->makeAuthManager( [ 'User1' ], 1 ),
487 'username' => 'User1',
488 'email' => self
::VALID_EMAIL
,
489 // Make sure that only the user specified by username is reset
490 'usersWithEmail' => [ 'User1', 'User2' ],
492 'Reset one user via email' => [
493 'expectedError' => false,
494 'config' => $defaultConfig,
495 'performingUser' => $performingUser,
496 'permissionManager' => $permissionManager,
497 'authManager' => $this->makeAuthManager( [ 'User1' ], 1 ),
499 'email' => self
::VALID_EMAIL
,
500 'usersWithEmail' => [ 'User1' ],
502 'Reset multiple users via email' => [
503 'expectedError' => false,
504 'config' => $defaultConfig,
505 'performingUser' => $performingUser,
506 'permissionManager' => $permissionManager,
507 'authManager' => $this->makeAuthManager( [ 'User1', 'User2' ], 2 ),
509 'email' => self
::VALID_EMAIL
,
510 'usersWithEmail' => [ 'User1', 'User2' ],
512 "Email is required for resets, user didn't opt in" => [
513 'expectedError' => false,
514 'config' => $emailRequiredConfig,
515 'performingUser' => $performingUser,
516 'permissionManager' => $permissionManager,
517 'authManager' => $this->makeAuthManager( [ 'User2' ], 1 ),
518 'username' => 'User2',
519 'email' => self
::VALID_EMAIL
,
520 'usersWithEmail' => [ 'User2' ],
522 'Reset three users via email that did not opt in, multiple users with same email' => [
523 'expectedError' => false,
524 'config' => $emailRequiredConfig,
525 'performingUser' => $performingUser,
526 'permissionManager' => $permissionManager,
527 'authManager' => $this->makeAuthManager( [ 'User2', 'User3', 'User4' ], 3, [ 'User1' ] ),
529 'email' => self
::VALID_EMAIL
,
530 'usersWithEmail' => [ 'User1', 'User2', 'User3', 'User4' ],
535 private function assertStatus( StatusValue
$status, $error = false ) {
536 if ( $error === false ) {
537 $this->assertTrue( $status->isGood(), 'Expected status to be good' );
539 $this->assertFalse( $status->isGood(), 'Expected status to not be good' );
540 if ( is_string( $error ) ) {
541 $this->assertNotEmpty( $status->getErrors() );
542 $message = $status->getErrors()[0]['message'];
543 if ( $message instanceof MessageSpecifier
) {
544 $message = $message->getKey();
546 $this->assertSame( $error, $message );
551 private function makeConfig( $enableEmail, array $passwordResetRoutes, $emailForResets ) {
553 'AllowRequiringEmailForResets' => $emailForResets,
554 'EnableEmail' => $enableEmail,
555 'PasswordResetRoutes' => $passwordResetRoutes,
558 return new ServiceOptions( PasswordReset
::CONSTRUCTOR_OPTIONS
, $hash );
562 * @param string|null $ip
563 * @param bool $pingLimited
566 private function makePerformingUser( $ip, $pingLimited ) : User
{
567 $request = $this->getMockBuilder( WebRequest
::class )
569 $request->method( 'getIP' )
571 /** @var WebRequest $request */
573 $user = $this->getMockBuilder( User
::class )
574 ->setMethods( [ 'getName', 'pingLimiter', 'getRequest' ] )
577 $user->method( 'getName' )
578 ->willReturn( 'SomeUser' );
579 $user->method( 'pingLimiter' )
580 ->with( 'mailpassword' )
581 ->willReturn( $pingLimited );
582 $user->method( 'getRequest' )
583 ->willReturn( $request );
585 /** @var User $user */
589 private function makePermissionManager( User
$performingUser, $isAllowed ) : PermissionManager
{
590 $permissionManager = $this->getMockBuilder( PermissionManager
::class )
591 ->disableOriginalConstructor()
593 $permissionManager->method( 'userHasRight' )
594 ->with( $performingUser, 'editmyprivateinfo' )
595 ->willReturn( $isAllowed );
597 /** @var PermissionManager $permissionManager */
598 return $permissionManager;
602 * @param string[] $allowed Usernames that are allowed to send password reset email
603 * by AuthManager's allowsAuthenticationDataChange method.
604 * @param int $numUsersToAuth Number of users that will receive email
605 * @param string[] $ignored Usernames that are allowed but ignored by AuthManager's
606 * allowsAuthenticationDataChange method and will not receive password reset email.
607 * @param string[] $mailThrottledLimited Usernames that have already
608 * received the password reset email within a given time, and AuthManager
609 * changeAuthenticationData method will mark them as 'throttled-mailpassword.'
610 * @return AuthManager
612 private function makeAuthManager(
616 array $mailThrottledLimited = []
618 $authManager = $this->getMockBuilder( AuthManager
::class )
619 ->disableOriginalConstructor()
621 $authManager->method( 'allowsAuthenticationDataChange' )
622 ->willReturnCallback(
623 function ( TemporaryPasswordAuthenticationRequest
$req )
624 use ( $allowed, $ignored, $mailThrottledLimited ) {
625 if ( in_array( $req->username
, $mailThrottledLimited, true ) ) {
626 return Status
::newGood( 'throttled-mailpassword' );
629 $value = in_array( $req->username
, $ignored, true )
633 return in_array( $req->username
, $allowed, true )
634 ? Status
::newGood( $value )
635 : Status
::newFatal( 'rejected by test mock' );
637 // changeAuthenticationData is executed in the deferred update class
638 // SendPasswordResetEmailUpdate
639 $authManager->expects( $this->exactly( $numUsersToAuth ) )
640 ->method( 'changeAuthenticationData' );
642 /** @var AuthManager $authManager */
649 private function makeUsers() {
650 $user1 = $this->getMockBuilder( User
::class )->getMock();
651 $user2 = $this->getMockBuilder( User
::class )->getMock();
652 $user3 = $this->getMockBuilder( User
::class )->getMock();
653 $user4 = $this->getMockBuilder( User
::class )->getMock();
654 $user1->method( 'getName' )->willReturn( 'User1' );
655 $user2->method( 'getName' )->willReturn( 'User2' );
656 $user3->method( 'getName' )->willReturn( 'User3' );
657 $user4->method( 'getName' )->willReturn( 'User4' );
658 $user1->method( 'getId' )->willReturn( 1 );
659 $user2->method( 'getId' )->willReturn( 2 );
660 $user3->method( 'getId' )->willReturn( 3 );
661 $user4->method( 'getId' )->willReturn( 4 );
662 $user1->method( 'getEmail' )->willReturn( self
::VALID_EMAIL
);
663 $user2->method( 'getEmail' )->willReturn( self
::VALID_EMAIL
);
664 $user3->method( 'getEmail' )->willReturn( self
::VALID_EMAIL
);
665 $user4->method( 'getEmail' )->willReturn( self
::VALID_EMAIL
);
667 $user1->method( 'getBoolOption' )
668 ->with( 'requireemail' )
669 ->willReturn( true );
671 $badUser = $this->getMockBuilder( User
::class )->getMock();
672 $badUser->method( 'getName' )->willReturn( 'BadUser' );
673 $badUser->method( 'getId' )->willReturn( 5 );
674 $badUser->method( 'getEmail' )->willReturn( null );
681 'BadUser' => $badUser,