Merge "Update docs/hooks.txt for ShowSearchHitTitle"
[mediawiki.git] / tests / phpunit / includes / auth / LocalPasswordPrimaryAuthenticationProviderTest.php
blob72a03c311a641ba7dce559ab414965b899246401
1 <?php
3 namespace MediaWiki\Auth;
5 use MediaWiki\MediaWikiServices;
7 /**
8 * @group AuthManager
9 * @group Database
10 * @covers MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider
12 class LocalPasswordPrimaryAuthenticationProviderTest extends \MediaWikiTestCase {
14 private $manager = null;
15 private $config = null;
16 private $validity = null;
18 /**
19 * Get an instance of the provider
21 * $provider->checkPasswordValidity is mocked to return $this->validity,
22 * because we don't need to test that here.
24 * @param bool $loginOnly
25 * @return LocalPasswordPrimaryAuthenticationProvider
27 protected function getProvider( $loginOnly = false ) {
28 if ( !$this->config ) {
29 $this->config = new \HashConfig();
31 $config = new \MultiConfig( [
32 $this->config,
33 MediaWikiServices::getInstance()->getMainConfig()
34 ] );
36 if ( !$this->manager ) {
37 $this->manager = new AuthManager( new \FauxRequest(), $config );
39 $this->validity = \Status::newGood();
41 $provider = $this->getMock(
42 LocalPasswordPrimaryAuthenticationProvider::class,
43 [ 'checkPasswordValidity' ],
44 [ [ 'loginOnly' => $loginOnly ] ]
46 $provider->expects( $this->any() )->method( 'checkPasswordValidity' )
47 ->will( $this->returnCallback( function () {
48 return $this->validity;
49 } ) );
50 $provider->setConfig( $config );
51 $provider->setLogger( new \Psr\Log\NullLogger() );
52 $provider->setManager( $this->manager );
54 return $provider;
57 public function testBasics() {
58 $user = $this->getMutableTestUser()->getUser();
59 $userName = $user->getName();
60 $lowerInitialUserName = mb_strtolower( $userName[0] ) . substr( $userName, 1 );
62 $provider = new LocalPasswordPrimaryAuthenticationProvider();
64 $this->assertSame(
65 PrimaryAuthenticationProvider::TYPE_CREATE,
66 $provider->accountCreationType()
69 $this->assertTrue( $provider->testUserExists( $userName ) );
70 $this->assertTrue( $provider->testUserExists( $lowerInitialUserName ) );
71 $this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) );
72 $this->assertFalse( $provider->testUserExists( '<invalid>' ) );
74 $provider = new LocalPasswordPrimaryAuthenticationProvider( [ 'loginOnly' => true ] );
76 $this->assertSame(
77 PrimaryAuthenticationProvider::TYPE_NONE,
78 $provider->accountCreationType()
81 $this->assertTrue( $provider->testUserExists( $userName ) );
82 $this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) );
84 $req = new PasswordAuthenticationRequest;
85 $req->action = AuthManager::ACTION_CHANGE;
86 $req->username = '<invalid>';
87 $provider->providerChangeAuthenticationData( $req );
90 public function testTestUserCanAuthenticate() {
91 $user = $this->getMutableTestUser()->getUser();
92 $userName = $user->getName();
93 $dbw = wfGetDB( DB_MASTER );
95 $provider = $this->getProvider();
97 $this->assertFalse( $provider->testUserCanAuthenticate( '<invalid>' ) );
99 $this->assertFalse( $provider->testUserCanAuthenticate( 'DoesNotExist' ) );
101 $this->assertTrue( $provider->testUserCanAuthenticate( $userName ) );
102 $lowerInitialUserName = mb_strtolower( $userName[0] ) . substr( $userName, 1 );
103 $this->assertTrue( $provider->testUserCanAuthenticate( $lowerInitialUserName ) );
105 $dbw->update(
106 'user',
107 [ 'user_password' => \PasswordFactory::newInvalidPassword()->toString() ],
108 [ 'user_name' => $userName ]
110 $this->assertFalse( $provider->testUserCanAuthenticate( $userName ) );
112 // Really old format
113 $dbw->update(
114 'user',
115 [ 'user_password' => '0123456789abcdef0123456789abcdef' ],
116 [ 'user_name' => $userName ]
118 $this->assertTrue( $provider->testUserCanAuthenticate( $userName ) );
121 public function testSetPasswordResetFlag() {
122 // Set instance vars
123 $this->getProvider();
125 /// @todo: Because we're currently using User, which uses the global config...
126 $this->setMwGlobals( [ 'wgPasswordExpireGrace' => 100 ] );
128 $this->config->set( 'PasswordExpireGrace', 100 );
129 $this->config->set( 'InvalidPasswordReset', true );
131 $provider = new LocalPasswordPrimaryAuthenticationProvider();
132 $provider->setConfig( $this->config );
133 $provider->setLogger( new \Psr\Log\NullLogger() );
134 $provider->setManager( $this->manager );
135 $providerPriv = \TestingAccessWrapper::newFromObject( $provider );
137 $user = $this->getMutableTestUser()->getUser();
138 $userName = $user->getName();
139 $dbw = wfGetDB( DB_MASTER );
140 $row = $dbw->selectRow(
141 'user',
142 '*',
143 [ 'user_name' => $userName ],
144 __METHOD__
147 $this->manager->removeAuthenticationSessionData( null );
148 $row->user_password_expires = wfTimestamp( TS_MW, time() + 200 );
149 $providerPriv->setPasswordResetFlag( $userName, \Status::newGood(), $row );
150 $this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
152 $this->manager->removeAuthenticationSessionData( null );
153 $row->user_password_expires = wfTimestamp( TS_MW, time() - 200 );
154 $providerPriv->setPasswordResetFlag( $userName, \Status::newGood(), $row );
155 $ret = $this->manager->getAuthenticationSessionData( 'reset-pass' );
156 $this->assertNotNull( $ret );
157 $this->assertSame( 'resetpass-expired', $ret->msg->getKey() );
158 $this->assertTrue( $ret->hard );
160 $this->manager->removeAuthenticationSessionData( null );
161 $row->user_password_expires = wfTimestamp( TS_MW, time() - 1 );
162 $providerPriv->setPasswordResetFlag( $userName, \Status::newGood(), $row );
163 $ret = $this->manager->getAuthenticationSessionData( 'reset-pass' );
164 $this->assertNotNull( $ret );
165 $this->assertSame( 'resetpass-expired-soft', $ret->msg->getKey() );
166 $this->assertFalse( $ret->hard );
168 $this->manager->removeAuthenticationSessionData( null );
169 $row->user_password_expires = null;
170 $status = \Status::newGood();
171 $status->error( 'testing' );
172 $providerPriv->setPasswordResetFlag( $userName, $status, $row );
173 $ret = $this->manager->getAuthenticationSessionData( 'reset-pass' );
174 $this->assertNotNull( $ret );
175 $this->assertSame( 'resetpass-validity-soft', $ret->msg->getKey() );
176 $this->assertFalse( $ret->hard );
179 public function testAuthentication() {
180 $testUser = $this->getMutableTestUser();
181 $userName = $testUser->getUser()->getName();
183 $dbw = wfGetDB( DB_MASTER );
184 $id = \User::idFromName( $userName );
186 $req = new PasswordAuthenticationRequest();
187 $req->action = AuthManager::ACTION_LOGIN;
188 $reqs = [ PasswordAuthenticationRequest::class => $req ];
190 $provider = $this->getProvider();
192 // General failures
193 $this->assertEquals(
194 AuthenticationResponse::newAbstain(),
195 $provider->beginPrimaryAuthentication( [] )
198 $req->username = 'foo';
199 $req->password = null;
200 $this->assertEquals(
201 AuthenticationResponse::newAbstain(),
202 $provider->beginPrimaryAuthentication( $reqs )
205 $req->username = null;
206 $req->password = 'bar';
207 $this->assertEquals(
208 AuthenticationResponse::newAbstain(),
209 $provider->beginPrimaryAuthentication( $reqs )
212 $req->username = '<invalid>';
213 $req->password = 'WhoCares';
214 $ret = $provider->beginPrimaryAuthentication( $reqs );
215 $this->assertEquals(
216 AuthenticationResponse::newAbstain(),
217 $provider->beginPrimaryAuthentication( $reqs )
220 $req->username = 'DoesNotExist';
221 $req->password = 'DoesNotExist';
222 $ret = $provider->beginPrimaryAuthentication( $reqs );
223 $this->assertEquals(
224 AuthenticationResponse::newAbstain(),
225 $provider->beginPrimaryAuthentication( $reqs )
228 // Validation failure
229 $req->username = $userName;
230 $req->password = $testUser->getPassword();
231 $this->validity = \Status::newFatal( 'arbitrary-failure' );
232 $ret = $provider->beginPrimaryAuthentication( $reqs );
233 $this->assertEquals(
234 AuthenticationResponse::FAIL,
235 $ret->status
237 $this->assertEquals(
238 'arbitrary-failure',
239 $ret->message->getKey()
242 // Successful auth
243 $this->manager->removeAuthenticationSessionData( null );
244 $this->validity = \Status::newGood();
245 $this->assertEquals(
246 AuthenticationResponse::newPass( $userName ),
247 $provider->beginPrimaryAuthentication( $reqs )
249 $this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
251 // Successful auth after normalizing name
252 $this->manager->removeAuthenticationSessionData( null );
253 $this->validity = \Status::newGood();
254 $req->username = mb_strtolower( $userName[0] ) . substr( $userName, 1 );
255 $this->assertEquals(
256 AuthenticationResponse::newPass( $userName ),
257 $provider->beginPrimaryAuthentication( $reqs )
259 $this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
260 $req->username = $userName;
262 // Successful auth with reset
263 $this->manager->removeAuthenticationSessionData( null );
264 $this->validity->error( 'arbitrary-warning' );
265 $this->assertEquals(
266 AuthenticationResponse::newPass( $userName ),
267 $provider->beginPrimaryAuthentication( $reqs )
269 $this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
271 // Wrong password
272 $this->validity = \Status::newGood();
273 $req->password = 'Wrong';
274 $ret = $provider->beginPrimaryAuthentication( $reqs );
275 $this->assertEquals(
276 AuthenticationResponse::FAIL,
277 $ret->status
279 $this->assertEquals(
280 'wrongpassword',
281 $ret->message->getKey()
284 // Correct handling of legacy encodings
285 $password = ':B:salt:' . md5( 'salt-' . md5( "\xe1\xe9\xed\xf3\xfa" ) );
286 $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => $userName ] );
287 $req->password = 'áéíóú';
288 $ret = $provider->beginPrimaryAuthentication( $reqs );
289 $this->assertEquals(
290 AuthenticationResponse::FAIL,
291 $ret->status
293 $this->assertEquals(
294 'wrongpassword',
295 $ret->message->getKey()
298 $this->config->set( 'LegacyEncoding', true );
299 $this->assertEquals(
300 AuthenticationResponse::newPass( $userName ),
301 $provider->beginPrimaryAuthentication( $reqs )
304 $req->password = 'áéíóú Wrong';
305 $ret = $provider->beginPrimaryAuthentication( $reqs );
306 $this->assertEquals(
307 AuthenticationResponse::FAIL,
308 $ret->status
310 $this->assertEquals(
311 'wrongpassword',
312 $ret->message->getKey()
315 // Correct handling of really old password hashes
316 $this->config->set( 'PasswordSalt', false );
317 $password = md5( 'FooBar' );
318 $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => $userName ] );
319 $req->password = 'FooBar';
320 $this->assertEquals(
321 AuthenticationResponse::newPass( $userName ),
322 $provider->beginPrimaryAuthentication( $reqs )
325 $this->config->set( 'PasswordSalt', true );
326 $password = md5( "$id-" . md5( 'FooBar' ) );
327 $dbw->update( 'user', [ 'user_password' => $password ], [ 'user_name' => $userName ] );
328 $req->password = 'FooBar';
329 $this->assertEquals(
330 AuthenticationResponse::newPass( $userName ),
331 $provider->beginPrimaryAuthentication( $reqs )
336 * @dataProvider provideProviderAllowsAuthenticationDataChange
337 * @param string $type
338 * @param string $user
339 * @param \Status $validity Result of the password validity check
340 * @param \StatusValue $expect1 Expected result with $checkData = false
341 * @param \StatusValue $expect2 Expected result with $checkData = true
343 public function testProviderAllowsAuthenticationDataChange( $type, $user, \Status $validity,
344 \StatusValue $expect1, \StatusValue $expect2
346 if ( $type === PasswordAuthenticationRequest::class ) {
347 $req = new $type();
348 } elseif ( $type === PasswordDomainAuthenticationRequest::class ) {
349 $req = new $type( [] );
350 } else {
351 $req = $this->getMock( $type );
353 $req->action = AuthManager::ACTION_CHANGE;
354 $req->username = $user;
355 $req->password = 'NewPassword';
356 $req->retype = 'NewPassword';
358 $provider = $this->getProvider();
359 $this->validity = $validity;
360 $this->assertEquals( $expect1, $provider->providerAllowsAuthenticationDataChange( $req, false ) );
361 $this->assertEquals( $expect2, $provider->providerAllowsAuthenticationDataChange( $req, true ) );
363 $req->retype = 'BadRetype';
364 $this->assertEquals(
365 $expect1,
366 $provider->providerAllowsAuthenticationDataChange( $req, false )
368 $this->assertEquals(
369 $expect2->getValue() === 'ignored' ? $expect2 : \StatusValue::newFatal( 'badretype' ),
370 $provider->providerAllowsAuthenticationDataChange( $req, true )
373 $provider = $this->getProvider( true );
374 $this->assertEquals(
375 \StatusValue::newGood( 'ignored' ),
376 $provider->providerAllowsAuthenticationDataChange( $req, true ),
377 'loginOnly mode should claim to ignore all changes'
381 public static function provideProviderAllowsAuthenticationDataChange() {
382 $err = \StatusValue::newGood();
383 $err->error( 'arbitrary-warning' );
385 return [
386 [ AuthenticationRequest::class, 'UTSysop', \Status::newGood(),
387 \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ],
388 [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::newGood(),
389 \StatusValue::newGood(), \StatusValue::newGood() ],
390 [ PasswordAuthenticationRequest::class, 'uTSysop', \Status::newGood(),
391 \StatusValue::newGood(), \StatusValue::newGood() ],
392 [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::wrap( $err ),
393 \StatusValue::newGood(), $err ],
394 [ PasswordAuthenticationRequest::class, 'UTSysop', \Status::newFatal( 'arbitrary-error' ),
395 \StatusValue::newGood(), \StatusValue::newFatal( 'arbitrary-error' ) ],
396 [ PasswordAuthenticationRequest::class, 'DoesNotExist', \Status::newGood(),
397 \StatusValue::newGood(), \StatusValue::newGood( 'ignored' ) ],
398 [ PasswordDomainAuthenticationRequest::class, 'UTSysop', \Status::newGood(),
399 \StatusValue::newGood( 'ignored' ), \StatusValue::newGood( 'ignored' ) ],
404 * @dataProvider provideProviderChangeAuthenticationData
405 * @param callable|bool $usernameTransform
406 * @param string $type
407 * @param bool $loginOnly
408 * @param bool $changed
410 public function testProviderChangeAuthenticationData(
411 $usernameTransform, $type, $loginOnly, $changed ) {
412 $testUser = $this->getMutableTestUser();
413 $user = $testUser->getUser()->getName();
414 if ( is_callable( $usernameTransform ) ) {
415 $user = call_user_func( $usernameTransform, $user );
417 $cuser = ucfirst( $user );
418 $oldpass = $testUser->getPassword();
419 $newpass = 'NewPassword';
421 $dbw = wfGetDB( DB_MASTER );
422 $oldExpiry = $dbw->selectField( 'user', 'user_password_expires', [ 'user_name' => $cuser ] );
424 $this->mergeMwGlobalArrayValue( 'wgHooks', [
425 'ResetPasswordExpiration' => [ function ( $user, &$expires ) {
426 $expires = '30001231235959';
428 ] );
430 $provider = $this->getProvider( $loginOnly );
432 // Sanity check
433 $loginReq = new PasswordAuthenticationRequest();
434 $loginReq->action = AuthManager::ACTION_LOGIN;
435 $loginReq->username = $user;
436 $loginReq->password = $oldpass;
437 $loginReqs = [ PasswordAuthenticationRequest::class => $loginReq ];
438 $this->assertEquals(
439 AuthenticationResponse::newPass( $cuser ),
440 $provider->beginPrimaryAuthentication( $loginReqs ),
441 'Sanity check'
444 if ( $type === PasswordAuthenticationRequest::class ) {
445 $changeReq = new $type();
446 } else {
447 $changeReq = $this->getMock( $type );
449 $changeReq->action = AuthManager::ACTION_CHANGE;
450 $changeReq->username = $user;
451 $changeReq->password = $newpass;
452 $provider->providerChangeAuthenticationData( $changeReq );
454 if ( $loginOnly && $changed ) {
455 $old = 'fail';
456 $new = 'fail';
457 $expectExpiry = null;
458 } elseif ( $changed ) {
459 $old = 'fail';
460 $new = 'pass';
461 $expectExpiry = '30001231235959';
462 } else {
463 $old = 'pass';
464 $new = 'fail';
465 $expectExpiry = $oldExpiry;
468 $loginReq->password = $oldpass;
469 $ret = $provider->beginPrimaryAuthentication( $loginReqs );
470 if ( $old === 'pass' ) {
471 $this->assertEquals(
472 AuthenticationResponse::newPass( $cuser ),
473 $ret,
474 'old password should pass'
476 } else {
477 $this->assertEquals(
478 AuthenticationResponse::FAIL,
479 $ret->status,
480 'old password should fail'
482 $this->assertEquals(
483 'wrongpassword',
484 $ret->message->getKey(),
485 'old password should fail'
489 $loginReq->password = $newpass;
490 $ret = $provider->beginPrimaryAuthentication( $loginReqs );
491 if ( $new === 'pass' ) {
492 $this->assertEquals(
493 AuthenticationResponse::newPass( $cuser ),
494 $ret,
495 'new password should pass'
497 } else {
498 $this->assertEquals(
499 AuthenticationResponse::FAIL,
500 $ret->status,
501 'new password should fail'
503 $this->assertEquals(
504 'wrongpassword',
505 $ret->message->getKey(),
506 'new password should fail'
510 $this->assertSame(
511 $expectExpiry,
512 $dbw->selectField( 'user', 'user_password_expires', [ 'user_name' => $cuser ] )
516 public static function provideProviderChangeAuthenticationData() {
517 return [
518 [ false, AuthenticationRequest::class, false, false ],
519 [ false, PasswordAuthenticationRequest::class, false, true ],
520 [ false, AuthenticationRequest::class, true, false ],
521 [ false, PasswordAuthenticationRequest::class, true, true ],
522 [ 'ucfirst', PasswordAuthenticationRequest::class, false, true ],
523 [ 'ucfirst', PasswordAuthenticationRequest::class, true, true ],
527 public function testTestForAccountCreation() {
528 $user = \User::newFromName( 'foo' );
529 $req = new PasswordAuthenticationRequest();
530 $req->action = AuthManager::ACTION_CREATE;
531 $req->username = 'Foo';
532 $req->password = 'Bar';
533 $req->retype = 'Bar';
534 $reqs = [ PasswordAuthenticationRequest::class => $req ];
536 $provider = $this->getProvider();
537 $this->assertEquals(
538 \StatusValue::newGood(),
539 $provider->testForAccountCreation( $user, $user, [] ),
540 'No password request'
543 $this->assertEquals(
544 \StatusValue::newGood(),
545 $provider->testForAccountCreation( $user, $user, $reqs ),
546 'Password request, validated'
549 $req->retype = 'Baz';
550 $this->assertEquals(
551 \StatusValue::newFatal( 'badretype' ),
552 $provider->testForAccountCreation( $user, $user, $reqs ),
553 'Password request, bad retype'
555 $req->retype = 'Bar';
557 $this->validity->error( 'arbitrary warning' );
558 $expect = \StatusValue::newGood();
559 $expect->error( 'arbitrary warning' );
560 $this->assertEquals(
561 $expect,
562 $provider->testForAccountCreation( $user, $user, $reqs ),
563 'Password request, not validated'
566 $provider = $this->getProvider( true );
567 $this->validity->error( 'arbitrary warning' );
568 $this->assertEquals(
569 \StatusValue::newGood(),
570 $provider->testForAccountCreation( $user, $user, $reqs ),
571 'Password request, not validated, loginOnly'
575 public function testAccountCreation() {
576 $user = \User::newFromName( 'Foo' );
578 $req = new PasswordAuthenticationRequest();
579 $req->action = AuthManager::ACTION_CREATE;
580 $reqs = [ PasswordAuthenticationRequest::class => $req ];
582 $provider = $this->getProvider( true );
583 try {
584 $provider->beginPrimaryAccountCreation( $user, $user, [] );
585 $this->fail( 'Expected exception was not thrown' );
586 } catch ( \BadMethodCallException $ex ) {
587 $this->assertSame(
588 'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage()
592 try {
593 $provider->finishAccountCreation( $user, $user, AuthenticationResponse::newPass() );
594 $this->fail( 'Expected exception was not thrown' );
595 } catch ( \BadMethodCallException $ex ) {
596 $this->assertSame(
597 'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage()
601 $provider = $this->getProvider( false );
603 $this->assertEquals(
604 AuthenticationResponse::newAbstain(),
605 $provider->beginPrimaryAccountCreation( $user, $user, [] )
608 $req->username = 'foo';
609 $req->password = null;
610 $this->assertEquals(
611 AuthenticationResponse::newAbstain(),
612 $provider->beginPrimaryAccountCreation( $user, $user, $reqs )
615 $req->username = null;
616 $req->password = 'bar';
617 $this->assertEquals(
618 AuthenticationResponse::newAbstain(),
619 $provider->beginPrimaryAccountCreation( $user, $user, $reqs )
622 $req->username = 'foo';
623 $req->password = 'bar';
625 $expect = AuthenticationResponse::newPass( 'Foo' );
626 $expect->createRequest = clone( $req );
627 $expect->createRequest->username = 'Foo';
628 $this->assertEquals( $expect, $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) );
630 // We have to cheat a bit to avoid having to add a new user to
631 // the database to test the actual setting of the password works right
632 $dbw = wfGetDB( DB_MASTER );
634 $user = \User::newFromName( 'UTSysop' );
635 $req->username = $user->getName();
636 $req->password = 'NewPassword';
637 $expect = AuthenticationResponse::newPass( 'UTSysop' );
638 $expect->createRequest = $req;
640 $res2 = $provider->beginPrimaryAccountCreation( $user, $user, $reqs );
641 $this->assertEquals( $expect, $res2, 'Sanity check' );
643 $ret = $provider->beginPrimaryAuthentication( $reqs );
644 $this->assertEquals( AuthenticationResponse::FAIL, $ret->status, 'sanity check' );
646 $this->assertNull( $provider->finishAccountCreation( $user, $user, $res2 ) );
647 $ret = $provider->beginPrimaryAuthentication( $reqs );
648 $this->assertEquals( AuthenticationResponse::PASS, $ret->status, 'new password is set' );