Merge "Make DBAccessBase use DBConnRef, rename $wiki, and hide getLoadBalancer()"
[lhc/web/wiklou.git] / includes / auth / AuthManager.php
1 <?php
2 /**
3 * Authentication (and possibly Authorization in the future) system entry point
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Auth
22 */
23
24 namespace MediaWiki\Auth;
25
26 use Config;
27 use MediaWiki\Block\DatabaseBlock;
28 use MediaWiki\MediaWikiServices;
29 use Psr\Log\LoggerAwareInterface;
30 use Psr\Log\LoggerInterface;
31 use Status;
32 use StatusValue;
33 use User;
34 use WebRequest;
35 use Wikimedia\ObjectFactory;
36
37 /**
38 * This serves as the entry point to the authentication system.
39 *
40 * In the future, it may also serve as the entry point to the authorization
41 * system.
42 *
43 * If you are looking at this because you are working on an extension that creates its own
44 * login or signup page, then 1) you really shouldn't do that, 2) if you feel you absolutely
45 * have to, subclass AuthManagerSpecialPage or build it on the client side using the clientlogin
46 * or the createaccount API. Trying to call this class directly will very likely end up in
47 * security vulnerabilities or broken UX in edge cases.
48 *
49 * If you are working on an extension that needs to integrate with the authentication system
50 * (e.g. by providing a new login method, or doing extra permission checks), you'll probably
51 * need to write an AuthenticationProvider.
52 *
53 * If you want to create a "reserved" user programmatically, User::newSystemUser() might be what
54 * you are looking for. If you want to change user data, use User::changeAuthenticationData().
55 * Code that is related to some SessionProvider or PrimaryAuthenticationProvider can
56 * create a (non-reserved) user by calling AuthManager::autoCreateUser(); it is then the provider's
57 * responsibility to ensure that the user can authenticate somehow (see especially
58 * PrimaryAuthenticationProvider::autoCreatedAccount()). The same functionality can also be used
59 * from Maintenance scripts such as createAndPromote.php.
60 * If you are writing code that is not associated with such a provider and needs to create accounts
61 * programmatically for real users, you should rethink your architecture. There is no good way to
62 * do that as such code has no knowledge of what authentication methods are enabled on the wiki and
63 * cannot provide any means for users to access the accounts it would create.
64 *
65 * The two main control flows when using this class are as follows:
66 * * Login, user creation or account linking code will call getAuthenticationRequests(), populate
67 * the requests with data (by using them to build a HTMLForm and have the user fill it, or by
68 * exposing a form specification via the API, so that the client can build it), and pass them to
69 * the appropriate begin* method. That will return either a success/failure response, or more
70 * requests to fill (either by building a form or by redirecting the user to some external
71 * provider which will send the data back), in which case they need to be submitted to the
72 * appropriate continue* method and that step has to be repeated until the response is a success
73 * or failure response. AuthManager will use the session to maintain internal state during the
74 * process.
75 * * Code doing an authentication data change will call getAuthenticationRequests(), select
76 * a single request, populate it, and pass it to allowsAuthenticationDataChange() and then
77 * changeAuthenticationData(). If the data change is user-initiated, the whole process needs
78 * to be preceded by a call to securitySensitiveOperationStatus() and aborted if that returns
79 * a non-OK status.
80 *
81 * @ingroup Auth
82 * @since 1.27
83 * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager
84 */
85 class AuthManager implements LoggerAwareInterface {
86 /** Log in with an existing (not necessarily local) user */
87 const ACTION_LOGIN = 'login';
88 /** Continue a login process that was interrupted by the need for user input or communication
89 * with an external provider
90 */
91 const ACTION_LOGIN_CONTINUE = 'login-continue';
92 /** Create a new user */
93 const ACTION_CREATE = 'create';
94 /** Continue a user creation process that was interrupted by the need for user input or
95 * communication with an external provider
96 */
97 const ACTION_CREATE_CONTINUE = 'create-continue';
98 /** Link an existing user to a third-party account */
99 const ACTION_LINK = 'link';
100 /** Continue a user linking process that was interrupted by the need for user input or
101 * communication with an external provider
102 */
103 const ACTION_LINK_CONTINUE = 'link-continue';
104 /** Change a user's credentials */
105 const ACTION_CHANGE = 'change';
106 /** Remove a user's credentials */
107 const ACTION_REMOVE = 'remove';
108 /** Like ACTION_REMOVE but for linking providers only */
109 const ACTION_UNLINK = 'unlink';
110
111 /** Security-sensitive operations are ok. */
112 const SEC_OK = 'ok';
113 /** Security-sensitive operations should re-authenticate. */
114 const SEC_REAUTH = 'reauth';
115 /** Security-sensitive should not be performed. */
116 const SEC_FAIL = 'fail';
117
118 /** Auto-creation is due to SessionManager */
119 const AUTOCREATE_SOURCE_SESSION = \MediaWiki\Session\SessionManager::class;
120
121 /** Auto-creation is due to a Maintenance script */
122 const AUTOCREATE_SOURCE_MAINT = '::Maintenance::';
123
124 /** @var AuthManager|null */
125 private static $instance = null;
126
127 /** @var WebRequest */
128 private $request;
129
130 /** @var Config */
131 private $config;
132
133 /** @var LoggerInterface */
134 private $logger;
135
136 /** @var AuthenticationProvider[] */
137 private $allAuthenticationProviders = [];
138
139 /** @var PreAuthenticationProvider[] */
140 private $preAuthenticationProviders = null;
141
142 /** @var PrimaryAuthenticationProvider[] */
143 private $primaryAuthenticationProviders = null;
144
145 /** @var SecondaryAuthenticationProvider[] */
146 private $secondaryAuthenticationProviders = null;
147
148 /** @var CreatedAccountAuthenticationRequest[] */
149 private $createdAccountAuthenticationRequests = [];
150
151 /**
152 * Get the global AuthManager
153 * @return AuthManager
154 */
155 public static function singleton() {
156 if ( self::$instance === null ) {
157 self::$instance = new self(
158 \RequestContext::getMain()->getRequest(),
159 MediaWikiServices::getInstance()->getMainConfig()
160 );
161 }
162 return self::$instance;
163 }
164
165 /**
166 * @param WebRequest $request
167 * @param Config $config
168 */
169 public function __construct( WebRequest $request, Config $config ) {
170 $this->request = $request;
171 $this->config = $config;
172 $this->setLogger( \MediaWiki\Logger\LoggerFactory::getInstance( 'authentication' ) );
173 }
174
175 /**
176 * @param LoggerInterface $logger
177 */
178 public function setLogger( LoggerInterface $logger ) {
179 $this->logger = $logger;
180 }
181
182 /**
183 * @return WebRequest
184 */
185 public function getRequest() {
186 return $this->request;
187 }
188
189 /**
190 * Force certain PrimaryAuthenticationProviders
191 * @deprecated For backwards compatibility only
192 * @param PrimaryAuthenticationProvider[] $providers
193 * @param string $why
194 */
195 public function forcePrimaryAuthenticationProviders( array $providers, $why ) {
196 $this->logger->warning( "Overriding AuthManager primary authn because $why" );
197
198 if ( $this->primaryAuthenticationProviders !== null ) {
199 $this->logger->warning(
200 'PrimaryAuthenticationProviders have already been accessed! I hope nothing breaks.'
201 );
202
203 $this->allAuthenticationProviders = array_diff_key(
204 $this->allAuthenticationProviders,
205 $this->primaryAuthenticationProviders
206 );
207 $session = $this->request->getSession();
208 $session->remove( 'AuthManager::authnState' );
209 $session->remove( 'AuthManager::accountCreationState' );
210 $session->remove( 'AuthManager::accountLinkState' );
211 $this->createdAccountAuthenticationRequests = [];
212 }
213
214 $this->primaryAuthenticationProviders = [];
215 foreach ( $providers as $provider ) {
216 if ( !$provider instanceof PrimaryAuthenticationProvider ) {
217 throw new \RuntimeException(
218 'Expected instance of MediaWiki\\Auth\\PrimaryAuthenticationProvider, got ' .
219 get_class( $provider )
220 );
221 }
222 $provider->setLogger( $this->logger );
223 $provider->setManager( $this );
224 $provider->setConfig( $this->config );
225 $id = $provider->getUniqueId();
226 if ( isset( $this->allAuthenticationProviders[$id] ) ) {
227 throw new \RuntimeException(
228 "Duplicate specifications for id $id (classes " .
229 get_class( $provider ) . ' and ' .
230 get_class( $this->allAuthenticationProviders[$id] ) . ')'
231 );
232 }
233 $this->allAuthenticationProviders[$id] = $provider;
234 $this->primaryAuthenticationProviders[$id] = $provider;
235 }
236 }
237
238 /**
239 * This used to call a legacy AuthPlugin method, if necessary. Since that code has
240 * been removed, it now just returns the $return parameter.
241 *
242 * @codeCoverageIgnore
243 * @deprecated For backwards compatibility only, should be avoided in new code
244 * @param string $method AuthPlugin method to call
245 * @param array $params Parameters to pass
246 * @param mixed $return Return value if AuthPlugin wasn't called
247 * @return mixed Return value from the AuthPlugin method, or $return
248 */
249 public static function callLegacyAuthPlugin( $method, array $params, $return = null ) {
250 wfDeprecated( __METHOD__, '1.33' );
251 return $return;
252 }
253
254 /**
255 * @name Authentication
256 * @{
257 */
258
259 /**
260 * Indicate whether user authentication is possible
261 *
262 * It may not be if the session is provided by something like OAuth
263 * for which each individual request includes authentication data.
264 *
265 * @return bool
266 */
267 public function canAuthenticateNow() {
268 return $this->request->getSession()->canSetUser();
269 }
270
271 /**
272 * Start an authentication flow
273 *
274 * In addition to the AuthenticationRequests returned by
275 * $this->getAuthenticationRequests(), a client might include a
276 * CreateFromLoginAuthenticationRequest from a previous login attempt to
277 * preserve state.
278 *
279 * Instead of the AuthenticationRequests returned by
280 * $this->getAuthenticationRequests(), a client might pass a
281 * CreatedAccountAuthenticationRequest from an account creation that just
282 * succeeded to log in to the just-created account.
283 *
284 * @param AuthenticationRequest[] $reqs
285 * @param string $returnToUrl Url that REDIRECT responses should eventually
286 * return to.
287 * @return AuthenticationResponse See self::continueAuthentication()
288 */
289 public function beginAuthentication( array $reqs, $returnToUrl ) {
290 $session = $this->request->getSession();
291 if ( !$session->canSetUser() ) {
292 // Caller should have called canAuthenticateNow()
293 $session->remove( 'AuthManager::authnState' );
294 throw new \LogicException( 'Authentication is not possible now' );
295 }
296
297 $guessUserName = null;
298 foreach ( $reqs as $req ) {
299 $req->returnToUrl = $returnToUrl;
300 // @codeCoverageIgnoreStart
301 if ( $req->username !== null && $req->username !== '' ) {
302 if ( $guessUserName === null ) {
303 $guessUserName = $req->username;
304 } elseif ( $guessUserName !== $req->username ) {
305 $guessUserName = null;
306 break;
307 }
308 }
309 // @codeCoverageIgnoreEnd
310 }
311
312 // Check for special-case login of a just-created account
313 $req = AuthenticationRequest::getRequestByClass(
314 $reqs, CreatedAccountAuthenticationRequest::class
315 );
316 if ( $req ) {
317 if ( !in_array( $req, $this->createdAccountAuthenticationRequests, true ) ) {
318 throw new \LogicException(
319 'CreatedAccountAuthenticationRequests are only valid on ' .
320 'the same AuthManager that created the account'
321 );
322 }
323
324 $user = User::newFromName( $req->username );
325 // @codeCoverageIgnoreStart
326 if ( !$user ) {
327 throw new \UnexpectedValueException(
328 "CreatedAccountAuthenticationRequest had invalid username \"{$req->username}\""
329 );
330 } elseif ( $user->getId() != $req->id ) {
331 throw new \UnexpectedValueException(
332 "ID for \"{$req->username}\" was {$user->getId()}, expected {$req->id}"
333 );
334 }
335 // @codeCoverageIgnoreEnd
336
337 $this->logger->info( 'Logging in {user} after account creation', [
338 'user' => $user->getName(),
339 ] );
340 $ret = AuthenticationResponse::newPass( $user->getName() );
341 $this->setSessionDataForUser( $user );
342 $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] );
343 $session->remove( 'AuthManager::authnState' );
344 \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, $user, $user->getName(), [] ] );
345 return $ret;
346 }
347
348 $this->removeAuthenticationSessionData( null );
349
350 foreach ( $this->getPreAuthenticationProviders() as $provider ) {
351 $status = $provider->testForAuthentication( $reqs );
352 if ( !$status->isGood() ) {
353 $this->logger->debug( 'Login failed in pre-authentication by ' . $provider->getUniqueId() );
354 $ret = AuthenticationResponse::newFail(
355 Status::wrap( $status )->getMessage()
356 );
357 $this->callMethodOnProviders( 7, 'postAuthentication',
358 [ User::newFromName( $guessUserName ) ?: null, $ret ]
359 );
360 \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, null, $guessUserName, [] ] );
361 return $ret;
362 }
363 }
364
365 $state = [
366 'reqs' => $reqs,
367 'returnToUrl' => $returnToUrl,
368 'guessUserName' => $guessUserName,
369 'primary' => null,
370 'primaryResponse' => null,
371 'secondary' => [],
372 'maybeLink' => [],
373 'continueRequests' => [],
374 ];
375
376 // Preserve state from a previous failed login
377 $req = AuthenticationRequest::getRequestByClass(
378 $reqs, CreateFromLoginAuthenticationRequest::class
379 );
380 if ( $req ) {
381 $state['maybeLink'] = $req->maybeLink;
382 }
383
384 $session = $this->request->getSession();
385 $session->setSecret( 'AuthManager::authnState', $state );
386 $session->persist();
387
388 return $this->continueAuthentication( $reqs );
389 }
390
391 /**
392 * Continue an authentication flow
393 *
394 * Return values are interpreted as follows:
395 * - status FAIL: Authentication failed. If $response->createRequest is
396 * set, that may be passed to self::beginAuthentication() or to
397 * self::beginAccountCreation() to preserve state.
398 * - status REDIRECT: The client should be redirected to the contained URL,
399 * new AuthenticationRequests should be made (if any), then
400 * AuthManager::continueAuthentication() should be called.
401 * - status UI: The client should be presented with a user interface for
402 * the fields in the specified AuthenticationRequests, then new
403 * AuthenticationRequests should be made, then
404 * AuthManager::continueAuthentication() should be called.
405 * - status RESTART: The user logged in successfully with a third-party
406 * service, but the third-party credentials aren't attached to any local
407 * account. This could be treated as a UI or a FAIL.
408 * - status PASS: Authentication was successful.
409 *
410 * @param AuthenticationRequest[] $reqs
411 * @return AuthenticationResponse
412 */
413 public function continueAuthentication( array $reqs ) {
414 $session = $this->request->getSession();
415 try {
416 if ( !$session->canSetUser() ) {
417 // Caller should have called canAuthenticateNow()
418 // @codeCoverageIgnoreStart
419 throw new \LogicException( 'Authentication is not possible now' );
420 // @codeCoverageIgnoreEnd
421 }
422
423 $state = $session->getSecret( 'AuthManager::authnState' );
424 if ( !is_array( $state ) ) {
425 return AuthenticationResponse::newFail(
426 wfMessage( 'authmanager-authn-not-in-progress' )
427 );
428 }
429 $state['continueRequests'] = [];
430
431 $guessUserName = $state['guessUserName'];
432
433 foreach ( $reqs as $req ) {
434 $req->returnToUrl = $state['returnToUrl'];
435 }
436
437 // Step 1: Choose an primary authentication provider, and call it until it succeeds.
438
439 if ( $state['primary'] === null ) {
440 // We haven't picked a PrimaryAuthenticationProvider yet
441 // @codeCoverageIgnoreStart
442 $guessUserName = null;
443 foreach ( $reqs as $req ) {
444 if ( $req->username !== null && $req->username !== '' ) {
445 if ( $guessUserName === null ) {
446 $guessUserName = $req->username;
447 } elseif ( $guessUserName !== $req->username ) {
448 $guessUserName = null;
449 break;
450 }
451 }
452 }
453 $state['guessUserName'] = $guessUserName;
454 // @codeCoverageIgnoreEnd
455 $state['reqs'] = $reqs;
456
457 foreach ( $this->getPrimaryAuthenticationProviders() as $id => $provider ) {
458 $res = $provider->beginPrimaryAuthentication( $reqs );
459 switch ( $res->status ) {
460 case AuthenticationResponse::PASS;
461 $state['primary'] = $id;
462 $state['primaryResponse'] = $res;
463 $this->logger->debug( "Primary login with $id succeeded" );
464 break 2;
465 case AuthenticationResponse::FAIL;
466 $this->logger->debug( "Login failed in primary authentication by $id" );
467 if ( $res->createRequest || $state['maybeLink'] ) {
468 $res->createRequest = new CreateFromLoginAuthenticationRequest(
469 $res->createRequest, $state['maybeLink']
470 );
471 }
472 $this->callMethodOnProviders( 7, 'postAuthentication',
473 [ User::newFromName( $guessUserName ) ?: null, $res ]
474 );
475 $session->remove( 'AuthManager::authnState' );
476 \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $res, null, $guessUserName, [] ] );
477 return $res;
478 case AuthenticationResponse::ABSTAIN;
479 // Continue loop
480 break;
481 case AuthenticationResponse::REDIRECT;
482 case AuthenticationResponse::UI;
483 $this->logger->debug( "Primary login with $id returned $res->status" );
484 $this->fillRequests( $res->neededRequests, self::ACTION_LOGIN, $guessUserName );
485 $state['primary'] = $id;
486 $state['continueRequests'] = $res->neededRequests;
487 $session->setSecret( 'AuthManager::authnState', $state );
488 return $res;
489
490 // @codeCoverageIgnoreStart
491 default:
492 throw new \DomainException(
493 get_class( $provider ) . "::beginPrimaryAuthentication() returned $res->status"
494 );
495 // @codeCoverageIgnoreEnd
496 }
497 }
498 if ( $state['primary'] === null ) {
499 $this->logger->debug( 'Login failed in primary authentication because no provider accepted' );
500 $ret = AuthenticationResponse::newFail(
501 wfMessage( 'authmanager-authn-no-primary' )
502 );
503 $this->callMethodOnProviders( 7, 'postAuthentication',
504 [ User::newFromName( $guessUserName ) ?: null, $ret ]
505 );
506 $session->remove( 'AuthManager::authnState' );
507 return $ret;
508 }
509 } elseif ( $state['primaryResponse'] === null ) {
510 $provider = $this->getAuthenticationProvider( $state['primary'] );
511 if ( !$provider instanceof PrimaryAuthenticationProvider ) {
512 // Configuration changed? Force them to start over.
513 // @codeCoverageIgnoreStart
514 $ret = AuthenticationResponse::newFail(
515 wfMessage( 'authmanager-authn-not-in-progress' )
516 );
517 $this->callMethodOnProviders( 7, 'postAuthentication',
518 [ User::newFromName( $guessUserName ) ?: null, $ret ]
519 );
520 $session->remove( 'AuthManager::authnState' );
521 return $ret;
522 // @codeCoverageIgnoreEnd
523 }
524 $id = $provider->getUniqueId();
525 $res = $provider->continuePrimaryAuthentication( $reqs );
526 switch ( $res->status ) {
527 case AuthenticationResponse::PASS;
528 $state['primaryResponse'] = $res;
529 $this->logger->debug( "Primary login with $id succeeded" );
530 break;
531 case AuthenticationResponse::FAIL;
532 $this->logger->debug( "Login failed in primary authentication by $id" );
533 if ( $res->createRequest || $state['maybeLink'] ) {
534 $res->createRequest = new CreateFromLoginAuthenticationRequest(
535 $res->createRequest, $state['maybeLink']
536 );
537 }
538 $this->callMethodOnProviders( 7, 'postAuthentication',
539 [ User::newFromName( $guessUserName ) ?: null, $res ]
540 );
541 $session->remove( 'AuthManager::authnState' );
542 \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $res, null, $guessUserName, [] ] );
543 return $res;
544 case AuthenticationResponse::REDIRECT;
545 case AuthenticationResponse::UI;
546 $this->logger->debug( "Primary login with $id returned $res->status" );
547 $this->fillRequests( $res->neededRequests, self::ACTION_LOGIN, $guessUserName );
548 $state['continueRequests'] = $res->neededRequests;
549 $session->setSecret( 'AuthManager::authnState', $state );
550 return $res;
551 default:
552 throw new \DomainException(
553 get_class( $provider ) . "::continuePrimaryAuthentication() returned $res->status"
554 );
555 }
556 }
557
558 $res = $state['primaryResponse'];
559 if ( $res->username === null ) {
560 $provider = $this->getAuthenticationProvider( $state['primary'] );
561 if ( !$provider instanceof PrimaryAuthenticationProvider ) {
562 // Configuration changed? Force them to start over.
563 // @codeCoverageIgnoreStart
564 $ret = AuthenticationResponse::newFail(
565 wfMessage( 'authmanager-authn-not-in-progress' )
566 );
567 $this->callMethodOnProviders( 7, 'postAuthentication',
568 [ User::newFromName( $guessUserName ) ?: null, $ret ]
569 );
570 $session->remove( 'AuthManager::authnState' );
571 return $ret;
572 // @codeCoverageIgnoreEnd
573 }
574
575 if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK &&
576 $res->linkRequest &&
577 // don't confuse the user with an incorrect message if linking is disabled
578 $this->getAuthenticationProvider( ConfirmLinkSecondaryAuthenticationProvider::class )
579 ) {
580 $state['maybeLink'][$res->linkRequest->getUniqueId()] = $res->linkRequest;
581 $msg = 'authmanager-authn-no-local-user-link';
582 } else {
583 $msg = 'authmanager-authn-no-local-user';
584 }
585 $this->logger->debug(
586 "Primary login with {$provider->getUniqueId()} succeeded, but returned no user"
587 );
588 $ret = AuthenticationResponse::newRestart( wfMessage( $msg ) );
589 $ret->neededRequests = $this->getAuthenticationRequestsInternal(
590 self::ACTION_LOGIN,
591 [],
592 $this->getPrimaryAuthenticationProviders() + $this->getSecondaryAuthenticationProviders()
593 );
594 if ( $res->createRequest || $state['maybeLink'] ) {
595 $ret->createRequest = new CreateFromLoginAuthenticationRequest(
596 $res->createRequest, $state['maybeLink']
597 );
598 $ret->neededRequests[] = $ret->createRequest;
599 }
600 $this->fillRequests( $ret->neededRequests, self::ACTION_LOGIN, null, true );
601 $session->setSecret( 'AuthManager::authnState', [
602 'reqs' => [], // Will be filled in later
603 'primary' => null,
604 'primaryResponse' => null,
605 'secondary' => [],
606 'continueRequests' => $ret->neededRequests,
607 ] + $state );
608 return $ret;
609 }
610
611 // Step 2: Primary authentication succeeded, create the User object
612 // (and add the user locally if necessary)
613
614 $user = User::newFromName( $res->username, 'usable' );
615 if ( !$user ) {
616 $provider = $this->getAuthenticationProvider( $state['primary'] );
617 throw new \DomainException(
618 get_class( $provider ) . " returned an invalid username: {$res->username}"
619 );
620 }
621 if ( $user->getId() === 0 ) {
622 // User doesn't exist locally. Create it.
623 $this->logger->info( 'Auto-creating {user} on login', [
624 'user' => $user->getName(),
625 ] );
626 $status = $this->autoCreateUser( $user, $state['primary'], false );
627 if ( !$status->isGood() ) {
628 $ret = AuthenticationResponse::newFail(
629 Status::wrap( $status )->getMessage( 'authmanager-authn-autocreate-failed' )
630 );
631 $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] );
632 $session->remove( 'AuthManager::authnState' );
633 \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, $user, $user->getName(), [] ] );
634 return $ret;
635 }
636 }
637
638 // Step 3: Iterate over all the secondary authentication providers.
639
640 $beginReqs = $state['reqs'];
641
642 foreach ( $this->getSecondaryAuthenticationProviders() as $id => $provider ) {
643 if ( !isset( $state['secondary'][$id] ) ) {
644 // This provider isn't started yet, so we pass it the set
645 // of reqs from beginAuthentication instead of whatever
646 // might have been used by a previous provider in line.
647 $func = 'beginSecondaryAuthentication';
648 $res = $provider->beginSecondaryAuthentication( $user, $beginReqs );
649 } elseif ( !$state['secondary'][$id] ) {
650 $func = 'continueSecondaryAuthentication';
651 $res = $provider->continueSecondaryAuthentication( $user, $reqs );
652 } else {
653 continue;
654 }
655 switch ( $res->status ) {
656 case AuthenticationResponse::PASS;
657 $this->logger->debug( "Secondary login with $id succeeded" );
658 // fall through
659 case AuthenticationResponse::ABSTAIN;
660 $state['secondary'][$id] = true;
661 break;
662 case AuthenticationResponse::FAIL;
663 $this->logger->debug( "Login failed in secondary authentication by $id" );
664 $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $res ] );
665 $session->remove( 'AuthManager::authnState' );
666 \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $res, $user, $user->getName(), [] ] );
667 return $res;
668 case AuthenticationResponse::REDIRECT;
669 case AuthenticationResponse::UI;
670 $this->logger->debug( "Secondary login with $id returned " . $res->status );
671 $this->fillRequests( $res->neededRequests, self::ACTION_LOGIN, $user->getName() );
672 $state['secondary'][$id] = false;
673 $state['continueRequests'] = $res->neededRequests;
674 $session->setSecret( 'AuthManager::authnState', $state );
675 return $res;
676
677 // @codeCoverageIgnoreStart
678 default:
679 throw new \DomainException(
680 get_class( $provider ) . "::{$func}() returned $res->status"
681 );
682 // @codeCoverageIgnoreEnd
683 }
684 }
685
686 // Step 4: Authentication complete! Set the user in the session and
687 // clean up.
688
689 $this->logger->info( 'Login for {user} succeeded from {clientip}', [
690 'user' => $user->getName(),
691 'clientip' => $this->request->getIP(),
692 ] );
693 /** @var RememberMeAuthenticationRequest $req */
694 $req = AuthenticationRequest::getRequestByClass(
695 $beginReqs, RememberMeAuthenticationRequest::class
696 );
697 $this->setSessionDataForUser( $user, $req && $req->rememberMe );
698 $ret = AuthenticationResponse::newPass( $user->getName() );
699 $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] );
700 $session->remove( 'AuthManager::authnState' );
701 $this->removeAuthenticationSessionData( null );
702 \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, $user, $user->getName(), [] ] );
703 return $ret;
704 } catch ( \Exception $ex ) {
705 $session->remove( 'AuthManager::authnState' );
706 throw $ex;
707 }
708 }
709
710 /**
711 * Whether security-sensitive operations should proceed.
712 *
713 * A "security-sensitive operation" is something like a password or email
714 * change, that would normally have a "reenter your password to confirm"
715 * box if we only supported password-based authentication.
716 *
717 * @param string $operation Operation being checked. This should be a
718 * message-key-like string such as 'change-password' or 'change-email'.
719 * @return string One of the SEC_* constants.
720 */
721 public function securitySensitiveOperationStatus( $operation ) {
722 $status = self::SEC_OK;
723
724 $this->logger->debug( __METHOD__ . ": Checking $operation" );
725
726 $session = $this->request->getSession();
727 $aId = $session->getUser()->getId();
728 if ( $aId === 0 ) {
729 // User isn't authenticated. DWIM?
730 $status = $this->canAuthenticateNow() ? self::SEC_REAUTH : self::SEC_FAIL;
731 $this->logger->info( __METHOD__ . ": Not logged in! $operation is $status" );
732 return $status;
733 }
734
735 if ( $session->canSetUser() ) {
736 $id = $session->get( 'AuthManager:lastAuthId' );
737 $last = $session->get( 'AuthManager:lastAuthTimestamp' );
738 if ( $id !== $aId || $last === null ) {
739 $timeSinceLogin = PHP_INT_MAX; // Forever ago
740 } else {
741 $timeSinceLogin = max( 0, time() - $last );
742 }
743
744 $thresholds = $this->config->get( 'ReauthenticateTime' );
745 if ( isset( $thresholds[$operation] ) ) {
746 $threshold = $thresholds[$operation];
747 } elseif ( isset( $thresholds['default'] ) ) {
748 $threshold = $thresholds['default'];
749 } else {
750 throw new \UnexpectedValueException( '$wgReauthenticateTime lacks a default' );
751 }
752
753 if ( $threshold >= 0 && $timeSinceLogin > $threshold ) {
754 $status = self::SEC_REAUTH;
755 }
756 } else {
757 $timeSinceLogin = -1;
758
759 $pass = $this->config->get( 'AllowSecuritySensitiveOperationIfCannotReauthenticate' );
760 if ( isset( $pass[$operation] ) ) {
761 $status = $pass[$operation] ? self::SEC_OK : self::SEC_FAIL;
762 } elseif ( isset( $pass['default'] ) ) {
763 $status = $pass['default'] ? self::SEC_OK : self::SEC_FAIL;
764 } else {
765 throw new \UnexpectedValueException(
766 '$wgAllowSecuritySensitiveOperationIfCannotReauthenticate lacks a default'
767 );
768 }
769 }
770
771 \Hooks::run( 'SecuritySensitiveOperationStatus', [
772 &$status, $operation, $session, $timeSinceLogin
773 ] );
774
775 // If authentication is not possible, downgrade from "REAUTH" to "FAIL".
776 if ( !$this->canAuthenticateNow() && $status === self::SEC_REAUTH ) {
777 $status = self::SEC_FAIL;
778 }
779
780 $this->logger->info( __METHOD__ . ": $operation is $status for '{user}'",
781 [
782 'user' => $session->getUser()->getName(),
783 'clientip' => $this->getRequest()->getIP(),
784 ]
785 );
786
787 return $status;
788 }
789
790 /**
791 * Determine whether a username can authenticate
792 *
793 * This is mainly for internal purposes and only takes authentication data into account,
794 * not things like blocks that can change without the authentication system being aware.
795 *
796 * @param string $username MediaWiki username
797 * @return bool
798 */
799 public function userCanAuthenticate( $username ) {
800 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
801 if ( $provider->testUserCanAuthenticate( $username ) ) {
802 return true;
803 }
804 }
805 return false;
806 }
807
808 /**
809 * Provide normalized versions of the username for security checks
810 *
811 * Since different providers can normalize the input in different ways,
812 * this returns an array of all the different ways the name might be
813 * normalized for authentication.
814 *
815 * The returned strings should not be revealed to the user, as that might
816 * leak private information (e.g. an email address might be normalized to a
817 * username).
818 *
819 * @param string $username
820 * @return string[]
821 */
822 public function normalizeUsername( $username ) {
823 $ret = [];
824 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
825 $normalized = $provider->providerNormalizeUsername( $username );
826 if ( $normalized !== null ) {
827 $ret[$normalized] = true;
828 }
829 }
830 return array_keys( $ret );
831 }
832
833 /** @} */
834
835 /**
836 * @name Authentication data changing
837 * @{
838 */
839
840 /**
841 * Revoke any authentication credentials for a user
842 *
843 * After this, the user should no longer be able to log in.
844 *
845 * @param string $username
846 */
847 public function revokeAccessForUser( $username ) {
848 $this->logger->info( 'Revoking access for {user}', [
849 'user' => $username,
850 ] );
851 $this->callMethodOnProviders( 6, 'providerRevokeAccessForUser', [ $username ] );
852 }
853
854 /**
855 * Validate a change of authentication data (e.g. passwords)
856 * @param AuthenticationRequest $req
857 * @param bool $checkData If false, $req hasn't been loaded from the
858 * submission so checks on user-submitted fields should be skipped. $req->username is
859 * considered user-submitted for this purpose, even if it cannot be changed via
860 * $req->loadFromSubmission.
861 * @return Status
862 */
863 public function allowsAuthenticationDataChange( AuthenticationRequest $req, $checkData = true ) {
864 $any = false;
865 $providers = $this->getPrimaryAuthenticationProviders() +
866 $this->getSecondaryAuthenticationProviders();
867 foreach ( $providers as $provider ) {
868 $status = $provider->providerAllowsAuthenticationDataChange( $req, $checkData );
869 if ( !$status->isGood() ) {
870 return Status::wrap( $status );
871 }
872 $any = $any || $status->value !== 'ignored';
873 }
874 if ( !$any ) {
875 $status = Status::newGood( 'ignored' );
876 $status->warning( 'authmanager-change-not-supported' );
877 return $status;
878 }
879 return Status::newGood();
880 }
881
882 /**
883 * Change authentication data (e.g. passwords)
884 *
885 * If $req was returned for AuthManager::ACTION_CHANGE, using $req should
886 * result in a successful login in the future.
887 *
888 * If $req was returned for AuthManager::ACTION_REMOVE, using $req should
889 * no longer result in a successful login.
890 *
891 * This method should only be called if allowsAuthenticationDataChange( $req, true )
892 * returned success.
893 *
894 * @param AuthenticationRequest $req
895 * @param bool $isAddition Set true if this represents an addition of
896 * credentials rather than a change. The main difference is that additions
897 * should not invalidate BotPasswords. If you're not sure, leave it false.
898 */
899 public function changeAuthenticationData( AuthenticationRequest $req, $isAddition = false ) {
900 $this->logger->info( 'Changing authentication data for {user} class {what}', [
901 'user' => is_string( $req->username ) ? $req->username : '<no name>',
902 'what' => get_class( $req ),
903 ] );
904
905 $this->callMethodOnProviders( 6, 'providerChangeAuthenticationData', [ $req ] );
906
907 // When the main account's authentication data is changed, invalidate
908 // all BotPasswords too.
909 if ( !$isAddition ) {
910 \BotPassword::invalidateAllPasswordsForUser( $req->username );
911 }
912 }
913
914 /** @} */
915
916 /**
917 * @name Account creation
918 * @{
919 */
920
921 /**
922 * Determine whether accounts can be created
923 * @return bool
924 */
925 public function canCreateAccounts() {
926 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
927 switch ( $provider->accountCreationType() ) {
928 case PrimaryAuthenticationProvider::TYPE_CREATE:
929 case PrimaryAuthenticationProvider::TYPE_LINK:
930 return true;
931 }
932 }
933 return false;
934 }
935
936 /**
937 * Determine whether a particular account can be created
938 * @param string $username MediaWiki username
939 * @param array $options
940 * - flags: (int) Bitfield of User:READ_* constants, default User::READ_NORMAL
941 * - creating: (bool) For internal use only. Never specify this.
942 * @return Status
943 */
944 public function canCreateAccount( $username, $options = [] ) {
945 // Back compat
946 if ( is_int( $options ) ) {
947 $options = [ 'flags' => $options ];
948 }
949 $options += [
950 'flags' => User::READ_NORMAL,
951 'creating' => false,
952 ];
953 $flags = $options['flags'];
954
955 if ( !$this->canCreateAccounts() ) {
956 return Status::newFatal( 'authmanager-create-disabled' );
957 }
958
959 if ( $this->userExists( $username, $flags ) ) {
960 return Status::newFatal( 'userexists' );
961 }
962
963 $user = User::newFromName( $username, 'creatable' );
964 if ( !is_object( $user ) ) {
965 return Status::newFatal( 'noname' );
966 } else {
967 $user->load( $flags ); // Explicitly load with $flags, auto-loading always uses READ_NORMAL
968 if ( $user->getId() !== 0 ) {
969 return Status::newFatal( 'userexists' );
970 }
971 }
972
973 // Denied by providers?
974 $providers = $this->getPreAuthenticationProviders() +
975 $this->getPrimaryAuthenticationProviders() +
976 $this->getSecondaryAuthenticationProviders();
977 foreach ( $providers as $provider ) {
978 $status = $provider->testUserForCreation( $user, false, $options );
979 if ( !$status->isGood() ) {
980 return Status::wrap( $status );
981 }
982 }
983
984 return Status::newGood();
985 }
986
987 /**
988 * Basic permissions checks on whether a user can create accounts
989 * @param User $creator User doing the account creation
990 * @return Status
991 */
992 public function checkAccountCreatePermissions( User $creator ) {
993 // Wiki is read-only?
994 if ( wfReadOnly() ) {
995 return Status::newFatal( wfMessage( 'readonlytext', wfReadOnlyReason() ) );
996 }
997
998 // This is awful, this permission check really shouldn't go through Title.
999 $permErrors = \SpecialPage::getTitleFor( 'CreateAccount' )
1000 ->getUserPermissionsErrors( 'createaccount', $creator, 'secure' );
1001 if ( $permErrors ) {
1002 $status = Status::newGood();
1003 foreach ( $permErrors as $args ) {
1004 $status->fatal( ...$args );
1005 }
1006 return $status;
1007 }
1008
1009 $block = $creator->isBlockedFromCreateAccount();
1010 if ( $block ) {
1011 $errorParams = [
1012 $block->getTarget(),
1013 $block->getReason() ?: wfMessage( 'blockednoreason' )->text(),
1014 $block->getByName()
1015 ];
1016
1017 if ( $block->getType() === DatabaseBlock::TYPE_RANGE ) {
1018 $errorMessage = 'cantcreateaccount-range-text';
1019 $errorParams[] = $this->getRequest()->getIP();
1020 } else {
1021 $errorMessage = 'cantcreateaccount-text';
1022 }
1023
1024 return Status::newFatal( wfMessage( $errorMessage, $errorParams ) );
1025 }
1026
1027 $ip = $this->getRequest()->getIP();
1028 if (
1029 MediaWikiServices::getInstance()->getBlockManager()
1030 ->isDnsBlacklisted( $ip, true /* check $wgProxyWhitelist */ )
1031 ) {
1032 return Status::newFatal( 'sorbs_create_account_reason' );
1033 }
1034
1035 return Status::newGood();
1036 }
1037
1038 /**
1039 * Start an account creation flow
1040 *
1041 * In addition to the AuthenticationRequests returned by
1042 * $this->getAuthenticationRequests(), a client might include a
1043 * CreateFromLoginAuthenticationRequest from a previous login attempt. If
1044 * <code>
1045 * $createFromLoginAuthenticationRequest->hasPrimaryStateForAction( AuthManager::ACTION_CREATE )
1046 * </code>
1047 * returns true, any AuthenticationRequest::PRIMARY_REQUIRED requests
1048 * should be omitted. If the CreateFromLoginAuthenticationRequest has a
1049 * username set, that username must be used for all other requests.
1050 *
1051 * @param User $creator User doing the account creation
1052 * @param AuthenticationRequest[] $reqs
1053 * @param string $returnToUrl Url that REDIRECT responses should eventually
1054 * return to.
1055 * @return AuthenticationResponse
1056 */
1057 public function beginAccountCreation( User $creator, array $reqs, $returnToUrl ) {
1058 $session = $this->request->getSession();
1059 if ( !$this->canCreateAccounts() ) {
1060 // Caller should have called canCreateAccounts()
1061 $session->remove( 'AuthManager::accountCreationState' );
1062 throw new \LogicException( 'Account creation is not possible' );
1063 }
1064
1065 try {
1066 $username = AuthenticationRequest::getUsernameFromRequests( $reqs );
1067 } catch ( \UnexpectedValueException $ex ) {
1068 $username = null;
1069 }
1070 if ( $username === null ) {
1071 $this->logger->debug( __METHOD__ . ': No username provided' );
1072 return AuthenticationResponse::newFail( wfMessage( 'noname' ) );
1073 }
1074
1075 // Permissions check
1076 $status = $this->checkAccountCreatePermissions( $creator );
1077 if ( !$status->isGood() ) {
1078 $this->logger->debug( __METHOD__ . ': {creator} cannot create users: {reason}', [
1079 'user' => $username,
1080 'creator' => $creator->getName(),
1081 'reason' => $status->getWikiText( null, null, 'en' )
1082 ] );
1083 return AuthenticationResponse::newFail( $status->getMessage() );
1084 }
1085
1086 $status = $this->canCreateAccount(
1087 $username, [ 'flags' => User::READ_LOCKING, 'creating' => true ]
1088 );
1089 if ( !$status->isGood() ) {
1090 $this->logger->debug( __METHOD__ . ': {user} cannot be created: {reason}', [
1091 'user' => $username,
1092 'creator' => $creator->getName(),
1093 'reason' => $status->getWikiText( null, null, 'en' )
1094 ] );
1095 return AuthenticationResponse::newFail( $status->getMessage() );
1096 }
1097
1098 $user = User::newFromName( $username, 'creatable' );
1099 foreach ( $reqs as $req ) {
1100 $req->username = $username;
1101 $req->returnToUrl = $returnToUrl;
1102 if ( $req instanceof UserDataAuthenticationRequest ) {
1103 $status = $req->populateUser( $user );
1104 if ( !$status->isGood() ) {
1105 $status = Status::wrap( $status );
1106 $session->remove( 'AuthManager::accountCreationState' );
1107 $this->logger->debug( __METHOD__ . ': UserData is invalid: {reason}', [
1108 'user' => $user->getName(),
1109 'creator' => $creator->getName(),
1110 'reason' => $status->getWikiText( null, null, 'en' ),
1111 ] );
1112 return AuthenticationResponse::newFail( $status->getMessage() );
1113 }
1114 }
1115 }
1116
1117 $this->removeAuthenticationSessionData( null );
1118
1119 $state = [
1120 'username' => $username,
1121 'userid' => 0,
1122 'creatorid' => $creator->getId(),
1123 'creatorname' => $creator->getName(),
1124 'reqs' => $reqs,
1125 'returnToUrl' => $returnToUrl,
1126 'primary' => null,
1127 'primaryResponse' => null,
1128 'secondary' => [],
1129 'continueRequests' => [],
1130 'maybeLink' => [],
1131 'ranPreTests' => false,
1132 ];
1133
1134 // Special case: converting a login to an account creation
1135 $req = AuthenticationRequest::getRequestByClass(
1136 $reqs, CreateFromLoginAuthenticationRequest::class
1137 );
1138 if ( $req ) {
1139 $state['maybeLink'] = $req->maybeLink;
1140
1141 if ( $req->createRequest ) {
1142 $reqs[] = $req->createRequest;
1143 $state['reqs'][] = $req->createRequest;
1144 }
1145 }
1146
1147 $session->setSecret( 'AuthManager::accountCreationState', $state );
1148 $session->persist();
1149
1150 return $this->continueAccountCreation( $reqs );
1151 }
1152
1153 /**
1154 * Continue an account creation flow
1155 * @param AuthenticationRequest[] $reqs
1156 * @return AuthenticationResponse
1157 */
1158 public function continueAccountCreation( array $reqs ) {
1159 $session = $this->request->getSession();
1160 try {
1161 if ( !$this->canCreateAccounts() ) {
1162 // Caller should have called canCreateAccounts()
1163 $session->remove( 'AuthManager::accountCreationState' );
1164 throw new \LogicException( 'Account creation is not possible' );
1165 }
1166
1167 $state = $session->getSecret( 'AuthManager::accountCreationState' );
1168 if ( !is_array( $state ) ) {
1169 return AuthenticationResponse::newFail(
1170 wfMessage( 'authmanager-create-not-in-progress' )
1171 );
1172 }
1173 $state['continueRequests'] = [];
1174
1175 // Step 0: Prepare and validate the input
1176
1177 $user = User::newFromName( $state['username'], 'creatable' );
1178 if ( !is_object( $user ) ) {
1179 $session->remove( 'AuthManager::accountCreationState' );
1180 $this->logger->debug( __METHOD__ . ': Invalid username', [
1181 'user' => $state['username'],
1182 ] );
1183 return AuthenticationResponse::newFail( wfMessage( 'noname' ) );
1184 }
1185
1186 if ( $state['creatorid'] ) {
1187 $creator = User::newFromId( $state['creatorid'] );
1188 } else {
1189 $creator = new User;
1190 $creator->setName( $state['creatorname'] );
1191 }
1192
1193 // Avoid account creation races on double submissions
1194 $cache = \ObjectCache::getLocalClusterInstance();
1195 $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $user->getName() ) ) );
1196 if ( !$lock ) {
1197 // Don't clear AuthManager::accountCreationState for this code
1198 // path because the process that won the race owns it.
1199 $this->logger->debug( __METHOD__ . ': Could not acquire account creation lock', [
1200 'user' => $user->getName(),
1201 'creator' => $creator->getName(),
1202 ] );
1203 return AuthenticationResponse::newFail( wfMessage( 'usernameinprogress' ) );
1204 }
1205
1206 // Permissions check
1207 $status = $this->checkAccountCreatePermissions( $creator );
1208 if ( !$status->isGood() ) {
1209 $this->logger->debug( __METHOD__ . ': {creator} cannot create users: {reason}', [
1210 'user' => $user->getName(),
1211 'creator' => $creator->getName(),
1212 'reason' => $status->getWikiText( null, null, 'en' )
1213 ] );
1214 $ret = AuthenticationResponse::newFail( $status->getMessage() );
1215 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1216 $session->remove( 'AuthManager::accountCreationState' );
1217 return $ret;
1218 }
1219
1220 // Load from master for existence check
1221 $user->load( User::READ_LOCKING );
1222
1223 if ( $state['userid'] === 0 ) {
1224 if ( $user->getId() !== 0 ) {
1225 $this->logger->debug( __METHOD__ . ': User exists locally', [
1226 'user' => $user->getName(),
1227 'creator' => $creator->getName(),
1228 ] );
1229 $ret = AuthenticationResponse::newFail( wfMessage( 'userexists' ) );
1230 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1231 $session->remove( 'AuthManager::accountCreationState' );
1232 return $ret;
1233 }
1234 } else {
1235 if ( $user->getId() === 0 ) {
1236 $this->logger->debug( __METHOD__ . ': User does not exist locally when it should', [
1237 'user' => $user->getName(),
1238 'creator' => $creator->getName(),
1239 'expected_id' => $state['userid'],
1240 ] );
1241 throw new \UnexpectedValueException(
1242 "User \"{$state['username']}\" should exist now, but doesn't!"
1243 );
1244 }
1245 if ( $user->getId() !== $state['userid'] ) {
1246 $this->logger->debug( __METHOD__ . ': User ID/name mismatch', [
1247 'user' => $user->getName(),
1248 'creator' => $creator->getName(),
1249 'expected_id' => $state['userid'],
1250 'actual_id' => $user->getId(),
1251 ] );
1252 throw new \UnexpectedValueException(
1253 "User \"{$state['username']}\" exists, but " .
1254 "ID {$user->getId()} !== {$state['userid']}!"
1255 );
1256 }
1257 }
1258 foreach ( $state['reqs'] as $req ) {
1259 if ( $req instanceof UserDataAuthenticationRequest ) {
1260 $status = $req->populateUser( $user );
1261 if ( !$status->isGood() ) {
1262 // This should never happen...
1263 $status = Status::wrap( $status );
1264 $this->logger->debug( __METHOD__ . ': UserData is invalid: {reason}', [
1265 'user' => $user->getName(),
1266 'creator' => $creator->getName(),
1267 'reason' => $status->getWikiText( null, null, 'en' ),
1268 ] );
1269 $ret = AuthenticationResponse::newFail( $status->getMessage() );
1270 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1271 $session->remove( 'AuthManager::accountCreationState' );
1272 return $ret;
1273 }
1274 }
1275 }
1276
1277 foreach ( $reqs as $req ) {
1278 $req->returnToUrl = $state['returnToUrl'];
1279 $req->username = $state['username'];
1280 }
1281
1282 // Run pre-creation tests, if we haven't already
1283 if ( !$state['ranPreTests'] ) {
1284 $providers = $this->getPreAuthenticationProviders() +
1285 $this->getPrimaryAuthenticationProviders() +
1286 $this->getSecondaryAuthenticationProviders();
1287 foreach ( $providers as $id => $provider ) {
1288 $status = $provider->testForAccountCreation( $user, $creator, $reqs );
1289 if ( !$status->isGood() ) {
1290 $this->logger->debug( __METHOD__ . ": Fail in pre-authentication by $id", [
1291 'user' => $user->getName(),
1292 'creator' => $creator->getName(),
1293 ] );
1294 $ret = AuthenticationResponse::newFail(
1295 Status::wrap( $status )->getMessage()
1296 );
1297 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1298 $session->remove( 'AuthManager::accountCreationState' );
1299 return $ret;
1300 }
1301 }
1302
1303 $state['ranPreTests'] = true;
1304 }
1305
1306 // Step 1: Choose a primary authentication provider and call it until it succeeds.
1307
1308 if ( $state['primary'] === null ) {
1309 // We haven't picked a PrimaryAuthenticationProvider yet
1310 foreach ( $this->getPrimaryAuthenticationProviders() as $id => $provider ) {
1311 if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_NONE ) {
1312 continue;
1313 }
1314 $res = $provider->beginPrimaryAccountCreation( $user, $creator, $reqs );
1315 switch ( $res->status ) {
1316 case AuthenticationResponse::PASS;
1317 $this->logger->debug( __METHOD__ . ": Primary creation passed by $id", [
1318 'user' => $user->getName(),
1319 'creator' => $creator->getName(),
1320 ] );
1321 $state['primary'] = $id;
1322 $state['primaryResponse'] = $res;
1323 break 2;
1324 case AuthenticationResponse::FAIL;
1325 $this->logger->debug( __METHOD__ . ": Primary creation failed by $id", [
1326 'user' => $user->getName(),
1327 'creator' => $creator->getName(),
1328 ] );
1329 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $res ] );
1330 $session->remove( 'AuthManager::accountCreationState' );
1331 return $res;
1332 case AuthenticationResponse::ABSTAIN;
1333 // Continue loop
1334 break;
1335 case AuthenticationResponse::REDIRECT;
1336 case AuthenticationResponse::UI;
1337 $this->logger->debug( __METHOD__ . ": Primary creation $res->status by $id", [
1338 'user' => $user->getName(),
1339 'creator' => $creator->getName(),
1340 ] );
1341 $this->fillRequests( $res->neededRequests, self::ACTION_CREATE, null );
1342 $state['primary'] = $id;
1343 $state['continueRequests'] = $res->neededRequests;
1344 $session->setSecret( 'AuthManager::accountCreationState', $state );
1345 return $res;
1346
1347 // @codeCoverageIgnoreStart
1348 default:
1349 throw new \DomainException(
1350 get_class( $provider ) . "::beginPrimaryAccountCreation() returned $res->status"
1351 );
1352 // @codeCoverageIgnoreEnd
1353 }
1354 }
1355 if ( $state['primary'] === null ) {
1356 $this->logger->debug( __METHOD__ . ': Primary creation failed because no provider accepted', [
1357 'user' => $user->getName(),
1358 'creator' => $creator->getName(),
1359 ] );
1360 $ret = AuthenticationResponse::newFail(
1361 wfMessage( 'authmanager-create-no-primary' )
1362 );
1363 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1364 $session->remove( 'AuthManager::accountCreationState' );
1365 return $ret;
1366 }
1367 } elseif ( $state['primaryResponse'] === null ) {
1368 $provider = $this->getAuthenticationProvider( $state['primary'] );
1369 if ( !$provider instanceof PrimaryAuthenticationProvider ) {
1370 // Configuration changed? Force them to start over.
1371 // @codeCoverageIgnoreStart
1372 $ret = AuthenticationResponse::newFail(
1373 wfMessage( 'authmanager-create-not-in-progress' )
1374 );
1375 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1376 $session->remove( 'AuthManager::accountCreationState' );
1377 return $ret;
1378 // @codeCoverageIgnoreEnd
1379 }
1380 $id = $provider->getUniqueId();
1381 $res = $provider->continuePrimaryAccountCreation( $user, $creator, $reqs );
1382 switch ( $res->status ) {
1383 case AuthenticationResponse::PASS;
1384 $this->logger->debug( __METHOD__ . ": Primary creation passed by $id", [
1385 'user' => $user->getName(),
1386 'creator' => $creator->getName(),
1387 ] );
1388 $state['primaryResponse'] = $res;
1389 break;
1390 case AuthenticationResponse::FAIL;
1391 $this->logger->debug( __METHOD__ . ": Primary creation failed by $id", [
1392 'user' => $user->getName(),
1393 'creator' => $creator->getName(),
1394 ] );
1395 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $res ] );
1396 $session->remove( 'AuthManager::accountCreationState' );
1397 return $res;
1398 case AuthenticationResponse::REDIRECT;
1399 case AuthenticationResponse::UI;
1400 $this->logger->debug( __METHOD__ . ": Primary creation $res->status by $id", [
1401 'user' => $user->getName(),
1402 'creator' => $creator->getName(),
1403 ] );
1404 $this->fillRequests( $res->neededRequests, self::ACTION_CREATE, null );
1405 $state['continueRequests'] = $res->neededRequests;
1406 $session->setSecret( 'AuthManager::accountCreationState', $state );
1407 return $res;
1408 default:
1409 throw new \DomainException(
1410 get_class( $provider ) . "::continuePrimaryAccountCreation() returned $res->status"
1411 );
1412 }
1413 }
1414
1415 // Step 2: Primary authentication succeeded, create the User object
1416 // and add the user locally.
1417
1418 if ( $state['userid'] === 0 ) {
1419 $this->logger->info( 'Creating user {user} during account creation', [
1420 'user' => $user->getName(),
1421 'creator' => $creator->getName(),
1422 ] );
1423 $status = $user->addToDatabase();
1424 if ( !$status->isOK() ) {
1425 // @codeCoverageIgnoreStart
1426 $ret = AuthenticationResponse::newFail( $status->getMessage() );
1427 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1428 $session->remove( 'AuthManager::accountCreationState' );
1429 return $ret;
1430 // @codeCoverageIgnoreEnd
1431 }
1432 $this->setDefaultUserOptions( $user, $creator->isAnon() );
1433 \Hooks::runWithoutAbort( 'LocalUserCreated', [ $user, false ] );
1434 $user->saveSettings();
1435 $state['userid'] = $user->getId();
1436
1437 // Update user count
1438 \DeferredUpdates::addUpdate( \SiteStatsUpdate::factory( [ 'users' => 1 ] ) );
1439
1440 // Watch user's userpage and talk page
1441 $user->addWatch( $user->getUserPage(), User::IGNORE_USER_RIGHTS );
1442
1443 // Inform the provider
1444 $logSubtype = $provider->finishAccountCreation( $user, $creator, $state['primaryResponse'] );
1445
1446 // Log the creation
1447 if ( $this->config->get( 'NewUserLog' ) ) {
1448 $isAnon = $creator->isAnon();
1449 $logEntry = new \ManualLogEntry(
1450 'newusers',
1451 $logSubtype ?: ( $isAnon ? 'create' : 'create2' )
1452 );
1453 $logEntry->setPerformer( $isAnon ? $user : $creator );
1454 $logEntry->setTarget( $user->getUserPage() );
1455 /** @var CreationReasonAuthenticationRequest $req */
1456 $req = AuthenticationRequest::getRequestByClass(
1457 $state['reqs'], CreationReasonAuthenticationRequest::class
1458 );
1459 $logEntry->setComment( $req ? $req->reason : '' );
1460 $logEntry->setParameters( [
1461 '4::userid' => $user->getId(),
1462 ] );
1463 $logid = $logEntry->insert();
1464 $logEntry->publish( $logid );
1465 }
1466 }
1467
1468 // Step 3: Iterate over all the secondary authentication providers.
1469
1470 $beginReqs = $state['reqs'];
1471
1472 foreach ( $this->getSecondaryAuthenticationProviders() as $id => $provider ) {
1473 if ( !isset( $state['secondary'][$id] ) ) {
1474 // This provider isn't started yet, so we pass it the set
1475 // of reqs from beginAuthentication instead of whatever
1476 // might have been used by a previous provider in line.
1477 $func = 'beginSecondaryAccountCreation';
1478 $res = $provider->beginSecondaryAccountCreation( $user, $creator, $beginReqs );
1479 } elseif ( !$state['secondary'][$id] ) {
1480 $func = 'continueSecondaryAccountCreation';
1481 $res = $provider->continueSecondaryAccountCreation( $user, $creator, $reqs );
1482 } else {
1483 continue;
1484 }
1485 switch ( $res->status ) {
1486 case AuthenticationResponse::PASS;
1487 $this->logger->debug( __METHOD__ . ": Secondary creation passed by $id", [
1488 'user' => $user->getName(),
1489 'creator' => $creator->getName(),
1490 ] );
1491 // fall through
1492 case AuthenticationResponse::ABSTAIN;
1493 $state['secondary'][$id] = true;
1494 break;
1495 case AuthenticationResponse::REDIRECT;
1496 case AuthenticationResponse::UI;
1497 $this->logger->debug( __METHOD__ . ": Secondary creation $res->status by $id", [
1498 'user' => $user->getName(),
1499 'creator' => $creator->getName(),
1500 ] );
1501 $this->fillRequests( $res->neededRequests, self::ACTION_CREATE, null );
1502 $state['secondary'][$id] = false;
1503 $state['continueRequests'] = $res->neededRequests;
1504 $session->setSecret( 'AuthManager::accountCreationState', $state );
1505 return $res;
1506 case AuthenticationResponse::FAIL;
1507 throw new \DomainException(
1508 get_class( $provider ) . "::{$func}() returned $res->status." .
1509 ' Secondary providers are not allowed to fail account creation, that' .
1510 ' should have been done via testForAccountCreation().'
1511 );
1512 // @codeCoverageIgnoreStart
1513 default:
1514 throw new \DomainException(
1515 get_class( $provider ) . "::{$func}() returned $res->status"
1516 );
1517 // @codeCoverageIgnoreEnd
1518 }
1519 }
1520
1521 $id = $user->getId();
1522 $name = $user->getName();
1523 $req = new CreatedAccountAuthenticationRequest( $id, $name );
1524 $ret = AuthenticationResponse::newPass( $name );
1525 $ret->loginRequest = $req;
1526 $this->createdAccountAuthenticationRequests[] = $req;
1527
1528 $this->logger->info( __METHOD__ . ': Account creation succeeded for {user}', [
1529 'user' => $user->getName(),
1530 'creator' => $creator->getName(),
1531 ] );
1532
1533 $this->callMethodOnProviders( 7, 'postAccountCreation', [ $user, $creator, $ret ] );
1534 $session->remove( 'AuthManager::accountCreationState' );
1535 $this->removeAuthenticationSessionData( null );
1536 return $ret;
1537 } catch ( \Exception $ex ) {
1538 $session->remove( 'AuthManager::accountCreationState' );
1539 throw $ex;
1540 }
1541 }
1542
1543 /**
1544 * Auto-create an account, and log into that account
1545 *
1546 * PrimaryAuthenticationProviders can invoke this method by returning a PASS from
1547 * beginPrimaryAuthentication/continuePrimaryAuthentication with the username of a
1548 * non-existing user. SessionProviders can invoke it by returning a SessionInfo with
1549 * the username of a non-existing user from provideSessionInfo(). Calling this method
1550 * explicitly (e.g. from a maintenance script) is also fine.
1551 *
1552 * @param User $user User to auto-create
1553 * @param string $source What caused the auto-creation? This must be one of:
1554 * - the ID of a PrimaryAuthenticationProvider,
1555 * - the constant self::AUTOCREATE_SOURCE_SESSION, or
1556 * - the constant AUTOCREATE_SOURCE_MAINT.
1557 * @param bool $login Whether to also log the user in
1558 * @return Status Good if user was created, Ok if user already existed, otherwise Fatal
1559 */
1560 public function autoCreateUser( User $user, $source, $login = true ) {
1561 if ( $source !== self::AUTOCREATE_SOURCE_SESSION &&
1562 $source !== self::AUTOCREATE_SOURCE_MAINT &&
1563 !$this->getAuthenticationProvider( $source ) instanceof PrimaryAuthenticationProvider
1564 ) {
1565 throw new \InvalidArgumentException( "Unknown auto-creation source: $source" );
1566 }
1567
1568 $username = $user->getName();
1569
1570 // Try the local user from the replica DB
1571 $localId = User::idFromName( $username );
1572 $flags = User::READ_NORMAL;
1573
1574 // Fetch the user ID from the master, so that we don't try to create the user
1575 // when they already exist, due to replication lag
1576 // @codeCoverageIgnoreStart
1577 if (
1578 !$localId &&
1579 MediaWikiServices::getInstance()->getDBLoadBalancer()->getReaderIndex() !== 0
1580 ) {
1581 $localId = User::idFromName( $username, User::READ_LATEST );
1582 $flags = User::READ_LATEST;
1583 }
1584 // @codeCoverageIgnoreEnd
1585
1586 if ( $localId ) {
1587 $this->logger->debug( __METHOD__ . ': {username} already exists locally', [
1588 'username' => $username,
1589 ] );
1590 $user->setId( $localId );
1591 $user->loadFromId( $flags );
1592 if ( $login ) {
1593 $this->setSessionDataForUser( $user );
1594 }
1595 $status = Status::newGood();
1596 $status->warning( 'userexists' );
1597 return $status;
1598 }
1599
1600 // Wiki is read-only?
1601 if ( wfReadOnly() ) {
1602 $this->logger->debug( __METHOD__ . ': denied by wfReadOnly(): {reason}', [
1603 'username' => $username,
1604 'reason' => wfReadOnlyReason(),
1605 ] );
1606 $user->setId( 0 );
1607 $user->loadFromId();
1608 return Status::newFatal( wfMessage( 'readonlytext', wfReadOnlyReason() ) );
1609 }
1610
1611 // Check the session, if we tried to create this user already there's
1612 // no point in retrying.
1613 $session = $this->request->getSession();
1614 if ( $session->get( 'AuthManager::AutoCreateBlacklist' ) ) {
1615 $this->logger->debug( __METHOD__ . ': blacklisted in session {sessionid}', [
1616 'username' => $username,
1617 'sessionid' => $session->getId(),
1618 ] );
1619 $user->setId( 0 );
1620 $user->loadFromId();
1621 $reason = $session->get( 'AuthManager::AutoCreateBlacklist' );
1622 if ( $reason instanceof StatusValue ) {
1623 return Status::wrap( $reason );
1624 } else {
1625 return Status::newFatal( $reason );
1626 }
1627 }
1628
1629 // Is the username creatable?
1630 if ( !User::isCreatableName( $username ) ) {
1631 $this->logger->debug( __METHOD__ . ': name "{username}" is not creatable', [
1632 'username' => $username,
1633 ] );
1634 $session->set( 'AuthManager::AutoCreateBlacklist', 'noname' );
1635 $user->setId( 0 );
1636 $user->loadFromId();
1637 return Status::newFatal( 'noname' );
1638 }
1639
1640 // Is the IP user able to create accounts?
1641 $anon = new User;
1642 if ( $source !== self::AUTOCREATE_SOURCE_MAINT && !MediaWikiServices::getInstance()
1643 ->getPermissionManager()
1644 ->userHasAnyRight( $anon, 'createaccount', 'autocreateaccount' )
1645 ) {
1646 $this->logger->debug( __METHOD__ . ': IP lacks the ability to create or autocreate accounts', [
1647 'username' => $username,
1648 'ip' => $anon->getName(),
1649 ] );
1650 $session->set( 'AuthManager::AutoCreateBlacklist', 'authmanager-autocreate-noperm' );
1651 $session->persist();
1652 $user->setId( 0 );
1653 $user->loadFromId();
1654 return Status::newFatal( 'authmanager-autocreate-noperm' );
1655 }
1656
1657 // Avoid account creation races on double submissions
1658 $cache = \ObjectCache::getLocalClusterInstance();
1659 $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) );
1660 if ( !$lock ) {
1661 $this->logger->debug( __METHOD__ . ': Could not acquire account creation lock', [
1662 'user' => $username,
1663 ] );
1664 $user->setId( 0 );
1665 $user->loadFromId();
1666 return Status::newFatal( 'usernameinprogress' );
1667 }
1668
1669 // Denied by providers?
1670 $options = [
1671 'flags' => User::READ_LATEST,
1672 'creating' => true,
1673 ];
1674 $providers = $this->getPreAuthenticationProviders() +
1675 $this->getPrimaryAuthenticationProviders() +
1676 $this->getSecondaryAuthenticationProviders();
1677 foreach ( $providers as $provider ) {
1678 $status = $provider->testUserForCreation( $user, $source, $options );
1679 if ( !$status->isGood() ) {
1680 $ret = Status::wrap( $status );
1681 $this->logger->debug( __METHOD__ . ': Provider denied creation of {username}: {reason}', [
1682 'username' => $username,
1683 'reason' => $ret->getWikiText( null, null, 'en' ),
1684 ] );
1685 $session->set( 'AuthManager::AutoCreateBlacklist', $status );
1686 $user->setId( 0 );
1687 $user->loadFromId();
1688 return $ret;
1689 }
1690 }
1691
1692 $backoffKey = $cache->makeKey( 'AuthManager', 'autocreate-failed', md5( $username ) );
1693 if ( $cache->get( $backoffKey ) ) {
1694 $this->logger->debug( __METHOD__ . ': {username} denied by prior creation attempt failures', [
1695 'username' => $username,
1696 ] );
1697 $user->setId( 0 );
1698 $user->loadFromId();
1699 return Status::newFatal( 'authmanager-autocreate-exception' );
1700 }
1701
1702 // Checks passed, create the user...
1703 $from = $_SERVER['REQUEST_URI'] ?? 'CLI';
1704 $this->logger->info( __METHOD__ . ': creating new user ({username}) - from: {from}', [
1705 'username' => $username,
1706 'from' => $from,
1707 ] );
1708
1709 // Ignore warnings about master connections/writes...hard to avoid here
1710 $trxProfiler = \Profiler::instance()->getTransactionProfiler();
1711 $old = $trxProfiler->setSilenced( true );
1712 try {
1713 $status = $user->addToDatabase();
1714 if ( !$status->isOK() ) {
1715 // Double-check for a race condition (T70012). We make use of the fact that when
1716 // addToDatabase fails due to the user already existing, the user object gets loaded.
1717 if ( $user->getId() ) {
1718 $this->logger->info( __METHOD__ . ': {username} already exists locally (race)', [
1719 'username' => $username,
1720 ] );
1721 if ( $login ) {
1722 $this->setSessionDataForUser( $user );
1723 }
1724 $status = Status::newGood();
1725 $status->warning( 'userexists' );
1726 } else {
1727 $this->logger->error( __METHOD__ . ': {username} failed with message {msg}', [
1728 'username' => $username,
1729 'msg' => $status->getWikiText( null, null, 'en' )
1730 ] );
1731 $user->setId( 0 );
1732 $user->loadFromId();
1733 }
1734 return $status;
1735 }
1736 } catch ( \Exception $ex ) {
1737 $trxProfiler->setSilenced( $old );
1738 $this->logger->error( __METHOD__ . ': {username} failed with exception {exception}', [
1739 'username' => $username,
1740 'exception' => $ex,
1741 ] );
1742 // Do not keep throwing errors for a while
1743 $cache->set( $backoffKey, 1, 600 );
1744 // Bubble up error; which should normally trigger DB rollbacks
1745 throw $ex;
1746 }
1747
1748 $this->setDefaultUserOptions( $user, false );
1749
1750 // Inform the providers
1751 $this->callMethodOnProviders( 6, 'autoCreatedAccount', [ $user, $source ] );
1752
1753 \Hooks::run( 'LocalUserCreated', [ $user, true ] );
1754 $user->saveSettings();
1755
1756 // Update user count
1757 \DeferredUpdates::addUpdate( \SiteStatsUpdate::factory( [ 'users' => 1 ] ) );
1758 // Watch user's userpage and talk page
1759 \DeferredUpdates::addCallableUpdate( function () use ( $user ) {
1760 $user->addWatch( $user->getUserPage(), User::IGNORE_USER_RIGHTS );
1761 } );
1762
1763 // Log the creation
1764 if ( $this->config->get( 'NewUserLog' ) ) {
1765 $logEntry = new \ManualLogEntry( 'newusers', 'autocreate' );
1766 $logEntry->setPerformer( $user );
1767 $logEntry->setTarget( $user->getUserPage() );
1768 $logEntry->setComment( '' );
1769 $logEntry->setParameters( [
1770 '4::userid' => $user->getId(),
1771 ] );
1772 $logEntry->insert();
1773 }
1774
1775 $trxProfiler->setSilenced( $old );
1776
1777 if ( $login ) {
1778 $this->setSessionDataForUser( $user );
1779 }
1780
1781 return Status::newGood();
1782 }
1783
1784 /** @} */
1785
1786 /**
1787 * @name Account linking
1788 * @{
1789 */
1790
1791 /**
1792 * Determine whether accounts can be linked
1793 * @return bool
1794 */
1795 public function canLinkAccounts() {
1796 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
1797 if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK ) {
1798 return true;
1799 }
1800 }
1801 return false;
1802 }
1803
1804 /**
1805 * Start an account linking flow
1806 *
1807 * @param User $user User being linked
1808 * @param AuthenticationRequest[] $reqs
1809 * @param string $returnToUrl Url that REDIRECT responses should eventually
1810 * return to.
1811 * @return AuthenticationResponse
1812 */
1813 public function beginAccountLink( User $user, array $reqs, $returnToUrl ) {
1814 $session = $this->request->getSession();
1815 $session->remove( 'AuthManager::accountLinkState' );
1816
1817 if ( !$this->canLinkAccounts() ) {
1818 // Caller should have called canLinkAccounts()
1819 throw new \LogicException( 'Account linking is not possible' );
1820 }
1821
1822 if ( $user->getId() === 0 ) {
1823 if ( !User::isUsableName( $user->getName() ) ) {
1824 $msg = wfMessage( 'noname' );
1825 } else {
1826 $msg = wfMessage( 'authmanager-userdoesnotexist', $user->getName() );
1827 }
1828 return AuthenticationResponse::newFail( $msg );
1829 }
1830 foreach ( $reqs as $req ) {
1831 $req->username = $user->getName();
1832 $req->returnToUrl = $returnToUrl;
1833 }
1834
1835 $this->removeAuthenticationSessionData( null );
1836
1837 $providers = $this->getPreAuthenticationProviders();
1838 foreach ( $providers as $id => $provider ) {
1839 $status = $provider->testForAccountLink( $user );
1840 if ( !$status->isGood() ) {
1841 $this->logger->debug( __METHOD__ . ": Account linking pre-check failed by $id", [
1842 'user' => $user->getName(),
1843 ] );
1844 $ret = AuthenticationResponse::newFail(
1845 Status::wrap( $status )->getMessage()
1846 );
1847 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
1848 return $ret;
1849 }
1850 }
1851
1852 $state = [
1853 'username' => $user->getName(),
1854 'userid' => $user->getId(),
1855 'returnToUrl' => $returnToUrl,
1856 'primary' => null,
1857 'continueRequests' => [],
1858 ];
1859
1860 $providers = $this->getPrimaryAuthenticationProviders();
1861 foreach ( $providers as $id => $provider ) {
1862 if ( $provider->accountCreationType() !== PrimaryAuthenticationProvider::TYPE_LINK ) {
1863 continue;
1864 }
1865
1866 $res = $provider->beginPrimaryAccountLink( $user, $reqs );
1867 switch ( $res->status ) {
1868 case AuthenticationResponse::PASS;
1869 $this->logger->info( "Account linked to {user} by $id", [
1870 'user' => $user->getName(),
1871 ] );
1872 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
1873 return $res;
1874
1875 case AuthenticationResponse::FAIL;
1876 $this->logger->debug( __METHOD__ . ": Account linking failed by $id", [
1877 'user' => $user->getName(),
1878 ] );
1879 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
1880 return $res;
1881
1882 case AuthenticationResponse::ABSTAIN;
1883 // Continue loop
1884 break;
1885
1886 case AuthenticationResponse::REDIRECT;
1887 case AuthenticationResponse::UI;
1888 $this->logger->debug( __METHOD__ . ": Account linking $res->status by $id", [
1889 'user' => $user->getName(),
1890 ] );
1891 $this->fillRequests( $res->neededRequests, self::ACTION_LINK, $user->getName() );
1892 $state['primary'] = $id;
1893 $state['continueRequests'] = $res->neededRequests;
1894 $session->setSecret( 'AuthManager::accountLinkState', $state );
1895 $session->persist();
1896 return $res;
1897
1898 // @codeCoverageIgnoreStart
1899 default:
1900 throw new \DomainException(
1901 get_class( $provider ) . "::beginPrimaryAccountLink() returned $res->status"
1902 );
1903 // @codeCoverageIgnoreEnd
1904 }
1905 }
1906
1907 $this->logger->debug( __METHOD__ . ': Account linking failed because no provider accepted', [
1908 'user' => $user->getName(),
1909 ] );
1910 $ret = AuthenticationResponse::newFail(
1911 wfMessage( 'authmanager-link-no-primary' )
1912 );
1913 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
1914 return $ret;
1915 }
1916
1917 /**
1918 * Continue an account linking flow
1919 * @param AuthenticationRequest[] $reqs
1920 * @return AuthenticationResponse
1921 */
1922 public function continueAccountLink( array $reqs ) {
1923 $session = $this->request->getSession();
1924 try {
1925 if ( !$this->canLinkAccounts() ) {
1926 // Caller should have called canLinkAccounts()
1927 $session->remove( 'AuthManager::accountLinkState' );
1928 throw new \LogicException( 'Account linking is not possible' );
1929 }
1930
1931 $state = $session->getSecret( 'AuthManager::accountLinkState' );
1932 if ( !is_array( $state ) ) {
1933 return AuthenticationResponse::newFail(
1934 wfMessage( 'authmanager-link-not-in-progress' )
1935 );
1936 }
1937 $state['continueRequests'] = [];
1938
1939 // Step 0: Prepare and validate the input
1940
1941 $user = User::newFromName( $state['username'], 'usable' );
1942 if ( !is_object( $user ) ) {
1943 $session->remove( 'AuthManager::accountLinkState' );
1944 return AuthenticationResponse::newFail( wfMessage( 'noname' ) );
1945 }
1946 if ( $user->getId() !== $state['userid'] ) {
1947 throw new \UnexpectedValueException(
1948 "User \"{$state['username']}\" is valid, but " .
1949 "ID {$user->getId()} !== {$state['userid']}!"
1950 );
1951 }
1952
1953 foreach ( $reqs as $req ) {
1954 $req->username = $state['username'];
1955 $req->returnToUrl = $state['returnToUrl'];
1956 }
1957
1958 // Step 1: Call the primary again until it succeeds
1959
1960 $provider = $this->getAuthenticationProvider( $state['primary'] );
1961 if ( !$provider instanceof PrimaryAuthenticationProvider ) {
1962 // Configuration changed? Force them to start over.
1963 // @codeCoverageIgnoreStart
1964 $ret = AuthenticationResponse::newFail(
1965 wfMessage( 'authmanager-link-not-in-progress' )
1966 );
1967 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
1968 $session->remove( 'AuthManager::accountLinkState' );
1969 return $ret;
1970 // @codeCoverageIgnoreEnd
1971 }
1972 $id = $provider->getUniqueId();
1973 $res = $provider->continuePrimaryAccountLink( $user, $reqs );
1974 switch ( $res->status ) {
1975 case AuthenticationResponse::PASS;
1976 $this->logger->info( "Account linked to {user} by $id", [
1977 'user' => $user->getName(),
1978 ] );
1979 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
1980 $session->remove( 'AuthManager::accountLinkState' );
1981 return $res;
1982 case AuthenticationResponse::FAIL;
1983 $this->logger->debug( __METHOD__ . ": Account linking failed by $id", [
1984 'user' => $user->getName(),
1985 ] );
1986 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
1987 $session->remove( 'AuthManager::accountLinkState' );
1988 return $res;
1989 case AuthenticationResponse::REDIRECT;
1990 case AuthenticationResponse::UI;
1991 $this->logger->debug( __METHOD__ . ": Account linking $res->status by $id", [
1992 'user' => $user->getName(),
1993 ] );
1994 $this->fillRequests( $res->neededRequests, self::ACTION_LINK, $user->getName() );
1995 $state['continueRequests'] = $res->neededRequests;
1996 $session->setSecret( 'AuthManager::accountLinkState', $state );
1997 return $res;
1998 default:
1999 throw new \DomainException(
2000 get_class( $provider ) . "::continuePrimaryAccountLink() returned $res->status"
2001 );
2002 }
2003 } catch ( \Exception $ex ) {
2004 $session->remove( 'AuthManager::accountLinkState' );
2005 throw $ex;
2006 }
2007 }
2008
2009 /** @} */
2010
2011 /**
2012 * @name Information methods
2013 * @{
2014 */
2015
2016 /**
2017 * Return the applicable list of AuthenticationRequests
2018 *
2019 * Possible values for $action:
2020 * - ACTION_LOGIN: Valid for passing to beginAuthentication
2021 * - ACTION_LOGIN_CONTINUE: Valid for passing to continueAuthentication in the current state
2022 * - ACTION_CREATE: Valid for passing to beginAccountCreation
2023 * - ACTION_CREATE_CONTINUE: Valid for passing to continueAccountCreation in the current state
2024 * - ACTION_LINK: Valid for passing to beginAccountLink
2025 * - ACTION_LINK_CONTINUE: Valid for passing to continueAccountLink in the current state
2026 * - ACTION_CHANGE: Valid for passing to changeAuthenticationData to change credentials
2027 * - ACTION_REMOVE: Valid for passing to changeAuthenticationData to remove credentials.
2028 * - ACTION_UNLINK: Same as ACTION_REMOVE, but limited to linked accounts.
2029 *
2030 * @param string $action One of the AuthManager::ACTION_* constants
2031 * @param User|null $user User being acted on, instead of the current user.
2032 * @return AuthenticationRequest[]
2033 */
2034 public function getAuthenticationRequests( $action, User $user = null ) {
2035 $options = [];
2036 $providerAction = $action;
2037
2038 // Figure out which providers to query
2039 switch ( $action ) {
2040 case self::ACTION_LOGIN:
2041 case self::ACTION_CREATE:
2042 $providers = $this->getPreAuthenticationProviders() +
2043 $this->getPrimaryAuthenticationProviders() +
2044 $this->getSecondaryAuthenticationProviders();
2045 break;
2046
2047 case self::ACTION_LOGIN_CONTINUE:
2048 $state = $this->request->getSession()->getSecret( 'AuthManager::authnState' );
2049 return is_array( $state ) ? $state['continueRequests'] : [];
2050
2051 case self::ACTION_CREATE_CONTINUE:
2052 $state = $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' );
2053 return is_array( $state ) ? $state['continueRequests'] : [];
2054
2055 case self::ACTION_LINK:
2056 $providers = array_filter( $this->getPrimaryAuthenticationProviders(), function ( $p ) {
2057 return $p->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK;
2058 } );
2059 break;
2060
2061 case self::ACTION_UNLINK:
2062 $providers = array_filter( $this->getPrimaryAuthenticationProviders(), function ( $p ) {
2063 return $p->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK;
2064 } );
2065
2066 // To providers, unlink and remove are identical.
2067 $providerAction = self::ACTION_REMOVE;
2068 break;
2069
2070 case self::ACTION_LINK_CONTINUE:
2071 $state = $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' );
2072 return is_array( $state ) ? $state['continueRequests'] : [];
2073
2074 case self::ACTION_CHANGE:
2075 case self::ACTION_REMOVE:
2076 $providers = $this->getPrimaryAuthenticationProviders() +
2077 $this->getSecondaryAuthenticationProviders();
2078 break;
2079
2080 // @codeCoverageIgnoreStart
2081 default:
2082 throw new \DomainException( __METHOD__ . ": Invalid action \"$action\"" );
2083 }
2084 // @codeCoverageIgnoreEnd
2085
2086 return $this->getAuthenticationRequestsInternal( $providerAction, $options, $providers, $user );
2087 }
2088
2089 /**
2090 * Internal request lookup for self::getAuthenticationRequests
2091 *
2092 * @param string $providerAction Action to pass to providers
2093 * @param array $options Options to pass to providers
2094 * @param AuthenticationProvider[] $providers
2095 * @param User|null $user
2096 * @return AuthenticationRequest[]
2097 */
2098 private function getAuthenticationRequestsInternal(
2099 $providerAction, array $options, array $providers, User $user = null
2100 ) {
2101 $user = $user ?: \RequestContext::getMain()->getUser();
2102 $options['username'] = $user->isAnon() ? null : $user->getName();
2103
2104 // Query them and merge results
2105 $reqs = [];
2106 foreach ( $providers as $provider ) {
2107 $isPrimary = $provider instanceof PrimaryAuthenticationProvider;
2108 foreach ( $provider->getAuthenticationRequests( $providerAction, $options ) as $req ) {
2109 $id = $req->getUniqueId();
2110
2111 // If a required request if from a Primary, mark it as "primary-required" instead
2112 if ( $isPrimary && $req->required ) {
2113 $req->required = AuthenticationRequest::PRIMARY_REQUIRED;
2114 }
2115
2116 if (
2117 !isset( $reqs[$id] )
2118 || $req->required === AuthenticationRequest::REQUIRED
2119 || $reqs[$id] === AuthenticationRequest::OPTIONAL
2120 ) {
2121 $reqs[$id] = $req;
2122 }
2123 }
2124 }
2125
2126 // AuthManager has its own req for some actions
2127 switch ( $providerAction ) {
2128 case self::ACTION_LOGIN:
2129 $reqs[] = new RememberMeAuthenticationRequest;
2130 break;
2131
2132 case self::ACTION_CREATE:
2133 $reqs[] = new UsernameAuthenticationRequest;
2134 $reqs[] = new UserDataAuthenticationRequest;
2135 if ( $options['username'] !== null ) {
2136 $reqs[] = new CreationReasonAuthenticationRequest;
2137 $options['username'] = null; // Don't fill in the username below
2138 }
2139 break;
2140 }
2141
2142 // Fill in reqs data
2143 $this->fillRequests( $reqs, $providerAction, $options['username'], true );
2144
2145 // For self::ACTION_CHANGE, filter out any that something else *doesn't* allow changing
2146 if ( $providerAction === self::ACTION_CHANGE || $providerAction === self::ACTION_REMOVE ) {
2147 $reqs = array_filter( $reqs, function ( $req ) {
2148 return $this->allowsAuthenticationDataChange( $req, false )->isGood();
2149 } );
2150 }
2151
2152 return array_values( $reqs );
2153 }
2154
2155 /**
2156 * Set values in an array of requests
2157 * @param AuthenticationRequest[] &$reqs
2158 * @param string $action
2159 * @param string|null $username
2160 * @param bool $forceAction
2161 */
2162 private function fillRequests( array &$reqs, $action, $username, $forceAction = false ) {
2163 foreach ( $reqs as $req ) {
2164 if ( !$req->action || $forceAction ) {
2165 $req->action = $action;
2166 }
2167 if ( $req->username === null ) {
2168 $req->username = $username;
2169 }
2170 }
2171 }
2172
2173 /**
2174 * Determine whether a username exists
2175 * @param string $username
2176 * @param int $flags Bitfield of User:READ_* constants
2177 * @return bool
2178 */
2179 public function userExists( $username, $flags = User::READ_NORMAL ) {
2180 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
2181 if ( $provider->testUserExists( $username, $flags ) ) {
2182 return true;
2183 }
2184 }
2185
2186 return false;
2187 }
2188
2189 /**
2190 * Determine whether a user property should be allowed to be changed.
2191 *
2192 * Supported properties are:
2193 * - emailaddress
2194 * - realname
2195 * - nickname
2196 *
2197 * @param string $property
2198 * @return bool
2199 */
2200 public function allowsPropertyChange( $property ) {
2201 $providers = $this->getPrimaryAuthenticationProviders() +
2202 $this->getSecondaryAuthenticationProviders();
2203 foreach ( $providers as $provider ) {
2204 if ( !$provider->providerAllowsPropertyChange( $property ) ) {
2205 return false;
2206 }
2207 }
2208 return true;
2209 }
2210
2211 /**
2212 * Get a provider by ID
2213 * @note This is public so extensions can check whether their own provider
2214 * is installed and so they can read its configuration if necessary.
2215 * Other uses are not recommended.
2216 * @param string $id
2217 * @return AuthenticationProvider|null
2218 */
2219 public function getAuthenticationProvider( $id ) {
2220 // Fast version
2221 if ( isset( $this->allAuthenticationProviders[$id] ) ) {
2222 return $this->allAuthenticationProviders[$id];
2223 }
2224
2225 // Slow version: instantiate each kind and check
2226 $providers = $this->getPrimaryAuthenticationProviders();
2227 if ( isset( $providers[$id] ) ) {
2228 return $providers[$id];
2229 }
2230 $providers = $this->getSecondaryAuthenticationProviders();
2231 if ( isset( $providers[$id] ) ) {
2232 return $providers[$id];
2233 }
2234 $providers = $this->getPreAuthenticationProviders();
2235 if ( isset( $providers[$id] ) ) {
2236 return $providers[$id];
2237 }
2238
2239 return null;
2240 }
2241
2242 /** @} */
2243
2244 /**
2245 * @name Internal methods
2246 * @{
2247 */
2248
2249 /**
2250 * Store authentication in the current session
2251 * @protected For use by AuthenticationProviders
2252 * @param string $key
2253 * @param mixed $data Must be serializable
2254 */
2255 public function setAuthenticationSessionData( $key, $data ) {
2256 $session = $this->request->getSession();
2257 $arr = $session->getSecret( 'authData' );
2258 if ( !is_array( $arr ) ) {
2259 $arr = [];
2260 }
2261 $arr[$key] = $data;
2262 $session->setSecret( 'authData', $arr );
2263 }
2264
2265 /**
2266 * Fetch authentication data from the current session
2267 * @protected For use by AuthenticationProviders
2268 * @param string $key
2269 * @param mixed|null $default
2270 * @return mixed
2271 */
2272 public function getAuthenticationSessionData( $key, $default = null ) {
2273 $arr = $this->request->getSession()->getSecret( 'authData' );
2274 if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) {
2275 return $arr[$key];
2276 } else {
2277 return $default;
2278 }
2279 }
2280
2281 /**
2282 * Remove authentication data
2283 * @protected For use by AuthenticationProviders
2284 * @param string|null $key If null, all data is removed
2285 */
2286 public function removeAuthenticationSessionData( $key ) {
2287 $session = $this->request->getSession();
2288 if ( $key === null ) {
2289 $session->remove( 'authData' );
2290 } else {
2291 $arr = $session->getSecret( 'authData' );
2292 if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) {
2293 unset( $arr[$key] );
2294 $session->setSecret( 'authData', $arr );
2295 }
2296 }
2297 }
2298
2299 /**
2300 * Create an array of AuthenticationProviders from an array of ObjectFactory specs
2301 * @param string $class
2302 * @param array[] $specs
2303 * @return AuthenticationProvider[]
2304 */
2305 protected function providerArrayFromSpecs( $class, array $specs ) {
2306 $i = 0;
2307 foreach ( $specs as &$spec ) {
2308 $spec = [ 'sort2' => $i++ ] + $spec + [ 'sort' => 0 ];
2309 }
2310 unset( $spec );
2311 // Sort according to the 'sort' field, and if they are equal, according to 'sort2'
2312 usort( $specs, function ( $a, $b ) {
2313 return $a['sort'] <=> $b['sort']
2314 ?: $a['sort2'] <=> $b['sort2'];
2315 } );
2316
2317 $ret = [];
2318 foreach ( $specs as $spec ) {
2319 $provider = ObjectFactory::getObjectFromSpec( $spec );
2320 if ( !$provider instanceof $class ) {
2321 throw new \RuntimeException(
2322 "Expected instance of $class, got " . get_class( $provider )
2323 );
2324 }
2325 $provider->setLogger( $this->logger );
2326 $provider->setManager( $this );
2327 $provider->setConfig( $this->config );
2328 $id = $provider->getUniqueId();
2329 if ( isset( $this->allAuthenticationProviders[$id] ) ) {
2330 throw new \RuntimeException(
2331 "Duplicate specifications for id $id (classes " .
2332 get_class( $provider ) . ' and ' .
2333 get_class( $this->allAuthenticationProviders[$id] ) . ')'
2334 );
2335 }
2336 $this->allAuthenticationProviders[$id] = $provider;
2337 $ret[$id] = $provider;
2338 }
2339 return $ret;
2340 }
2341
2342 /**
2343 * Get the configuration
2344 * @return array
2345 */
2346 private function getConfiguration() {
2347 return $this->config->get( 'AuthManagerConfig' ) ?: $this->config->get( 'AuthManagerAutoConfig' );
2348 }
2349
2350 /**
2351 * Get the list of PreAuthenticationProviders
2352 * @return PreAuthenticationProvider[]
2353 */
2354 protected function getPreAuthenticationProviders() {
2355 if ( $this->preAuthenticationProviders === null ) {
2356 $conf = $this->getConfiguration();
2357 $this->preAuthenticationProviders = $this->providerArrayFromSpecs(
2358 PreAuthenticationProvider::class, $conf['preauth']
2359 );
2360 }
2361 return $this->preAuthenticationProviders;
2362 }
2363
2364 /**
2365 * Get the list of PrimaryAuthenticationProviders
2366 * @return PrimaryAuthenticationProvider[]
2367 */
2368 protected function getPrimaryAuthenticationProviders() {
2369 if ( $this->primaryAuthenticationProviders === null ) {
2370 $conf = $this->getConfiguration();
2371 $this->primaryAuthenticationProviders = $this->providerArrayFromSpecs(
2372 PrimaryAuthenticationProvider::class, $conf['primaryauth']
2373 );
2374 }
2375 return $this->primaryAuthenticationProviders;
2376 }
2377
2378 /**
2379 * Get the list of SecondaryAuthenticationProviders
2380 * @return SecondaryAuthenticationProvider[]
2381 */
2382 protected function getSecondaryAuthenticationProviders() {
2383 if ( $this->secondaryAuthenticationProviders === null ) {
2384 $conf = $this->getConfiguration();
2385 $this->secondaryAuthenticationProviders = $this->providerArrayFromSpecs(
2386 SecondaryAuthenticationProvider::class, $conf['secondaryauth']
2387 );
2388 }
2389 return $this->secondaryAuthenticationProviders;
2390 }
2391
2392 /**
2393 * Log the user in
2394 * @param User $user
2395 * @param bool|null $remember
2396 */
2397 private function setSessionDataForUser( $user, $remember = null ) {
2398 $session = $this->request->getSession();
2399 $delay = $session->delaySave();
2400
2401 $session->resetId();
2402 $session->resetAllTokens();
2403 if ( $session->canSetUser() ) {
2404 $session->setUser( $user );
2405 }
2406 if ( $remember !== null ) {
2407 $session->setRememberUser( $remember );
2408 }
2409 $session->set( 'AuthManager:lastAuthId', $user->getId() );
2410 $session->set( 'AuthManager:lastAuthTimestamp', time() );
2411 $session->persist();
2412
2413 \Wikimedia\ScopedCallback::consume( $delay );
2414
2415 \Hooks::run( 'UserLoggedIn', [ $user ] );
2416 }
2417
2418 /**
2419 * @param User $user
2420 * @param bool $useContextLang Use 'uselang' to set the user's language
2421 */
2422 private function setDefaultUserOptions( User $user, $useContextLang ) {
2423 $user->setToken();
2424
2425 $contLang = MediaWikiServices::getInstance()->getContentLanguage();
2426
2427 $lang = $useContextLang ? \RequestContext::getMain()->getLanguage() : $contLang;
2428 $user->setOption( 'language', $lang->getPreferredVariant() );
2429
2430 if ( $contLang->hasVariants() ) {
2431 $user->setOption( 'variant', $contLang->getPreferredVariant() );
2432 }
2433 }
2434
2435 /**
2436 * @param int $which Bitmask: 1 = pre, 2 = primary, 4 = secondary
2437 * @param string $method
2438 * @param array $args
2439 */
2440 private function callMethodOnProviders( $which, $method, array $args ) {
2441 $providers = [];
2442 if ( $which & 1 ) {
2443 $providers += $this->getPreAuthenticationProviders();
2444 }
2445 if ( $which & 2 ) {
2446 $providers += $this->getPrimaryAuthenticationProviders();
2447 }
2448 if ( $which & 4 ) {
2449 $providers += $this->getSecondaryAuthenticationProviders();
2450 }
2451 foreach ( $providers as $provider ) {
2452 $provider->$method( ...$args );
2453 }
2454 }
2455
2456 /**
2457 * Reset the internal caching for unit testing
2458 * @protected Unit tests only
2459 */
2460 public static function resetCache() {
2461 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
2462 // @codeCoverageIgnoreStart
2463 throw new \MWException( __METHOD__ . ' may only be called from unit tests!' );
2464 // @codeCoverageIgnoreEnd
2465 }
2466
2467 self::$instance = null;
2468 }
2469
2470 /** @} */
2471
2472 }
2473
2474 /**
2475 * For really cool vim folding this needs to be at the end:
2476 * vim: foldmarker=@{,@} foldmethod=marker
2477 */