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