createAndPromote: use AuthManager::autoCreateUser
[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 * Call a legacy AuthPlugin method, if necessary
236 * @codeCoverageIgnore
237 * @deprecated For backwards compatibility only, should be avoided in new code
238 * @param string $method AuthPlugin method to call
239 * @param array $params Parameters to pass
240 * @param mixed $return Return value if AuthPlugin wasn't called
241 * @return mixed Return value from the AuthPlugin method, or $return
242 */
243 public static function callLegacyAuthPlugin( $method, array $params, $return = null ) {
244 global $wgAuth;
245
246 if ( $wgAuth && !$wgAuth instanceof AuthManagerAuthPlugin ) {
247 return $wgAuth->$method( ...$params );
248 } else {
249 return $return;
250 }
251 }
252
253 /**
254 * @name Authentication
255 * @{
256 */
257
258 /**
259 * Indicate whether user authentication is possible
260 *
261 * It may not be if the session is provided by something like OAuth
262 * for which each individual request includes authentication data.
263 *
264 * @return bool
265 */
266 public function canAuthenticateNow() {
267 return $this->request->getSession()->canSetUser();
268 }
269
270 /**
271 * Start an authentication flow
272 *
273 * In addition to the AuthenticationRequests returned by
274 * $this->getAuthenticationRequests(), a client might include a
275 * CreateFromLoginAuthenticationRequest from a previous login attempt to
276 * preserve state.
277 *
278 * Instead of the AuthenticationRequests returned by
279 * $this->getAuthenticationRequests(), a client might pass a
280 * CreatedAccountAuthenticationRequest from an account creation that just
281 * succeeded to log in to the just-created account.
282 *
283 * @param AuthenticationRequest[] $reqs
284 * @param string $returnToUrl Url that REDIRECT responses should eventually
285 * return to.
286 * @return AuthenticationResponse See self::continueAuthentication()
287 */
288 public function beginAuthentication( array $reqs, $returnToUrl ) {
289 $session = $this->request->getSession();
290 if ( !$session->canSetUser() ) {
291 // Caller should have called canAuthenticateNow()
292 $session->remove( 'AuthManager::authnState' );
293 throw new \LogicException( 'Authentication is not possible now' );
294 }
295
296 $guessUserName = null;
297 foreach ( $reqs as $req ) {
298 $req->returnToUrl = $returnToUrl;
299 // @codeCoverageIgnoreStart
300 if ( $req->username !== null && $req->username !== '' ) {
301 if ( $guessUserName === null ) {
302 $guessUserName = $req->username;
303 } elseif ( $guessUserName !== $req->username ) {
304 $guessUserName = null;
305 break;
306 }
307 }
308 // @codeCoverageIgnoreEnd
309 }
310
311 // Check for special-case login of a just-created account
312 $req = AuthenticationRequest::getRequestByClass(
313 $reqs, CreatedAccountAuthenticationRequest::class
314 );
315 if ( $req ) {
316 if ( !in_array( $req, $this->createdAccountAuthenticationRequests, true ) ) {
317 throw new \LogicException(
318 'CreatedAccountAuthenticationRequests are only valid on ' .
319 'the same AuthManager that created the account'
320 );
321 }
322
323 $user = User::newFromName( $req->username );
324 // @codeCoverageIgnoreStart
325 if ( !$user ) {
326 throw new \UnexpectedValueException(
327 "CreatedAccountAuthenticationRequest had invalid username \"{$req->username}\""
328 );
329 } elseif ( $user->getId() != $req->id ) {
330 throw new \UnexpectedValueException(
331 "ID for \"{$req->username}\" was {$user->getId()}, expected {$req->id}"
332 );
333 }
334 // @codeCoverageIgnoreEnd
335
336 $this->logger->info( 'Logging in {user} after account creation', [
337 'user' => $user->getName(),
338 ] );
339 $ret = AuthenticationResponse::newPass( $user->getName() );
340 $this->setSessionDataForUser( $user );
341 $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] );
342 $session->remove( 'AuthManager::authnState' );
343 \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, $user, $user->getName(), [] ] );
344 return $ret;
345 }
346
347 $this->removeAuthenticationSessionData( null );
348
349 foreach ( $this->getPreAuthenticationProviders() as $provider ) {
350 $status = $provider->testForAuthentication( $reqs );
351 if ( !$status->isGood() ) {
352 $this->logger->debug( 'Login failed in pre-authentication by ' . $provider->getUniqueId() );
353 $ret = AuthenticationResponse::newFail(
354 Status::wrap( $status )->getMessage()
355 );
356 $this->callMethodOnProviders( 7, 'postAuthentication',
357 [ User::newFromName( $guessUserName ) ?: null, $ret ]
358 );
359 \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, null, $guessUserName, [] ] );
360 return $ret;
361 }
362 }
363
364 $state = [
365 'reqs' => $reqs,
366 'returnToUrl' => $returnToUrl,
367 'guessUserName' => $guessUserName,
368 'primary' => null,
369 'primaryResponse' => null,
370 'secondary' => [],
371 'maybeLink' => [],
372 'continueRequests' => [],
373 ];
374
375 // Preserve state from a previous failed login
376 $req = AuthenticationRequest::getRequestByClass(
377 $reqs, CreateFromLoginAuthenticationRequest::class
378 );
379 if ( $req ) {
380 $state['maybeLink'] = $req->maybeLink;
381 }
382
383 $session = $this->request->getSession();
384 $session->setSecret( 'AuthManager::authnState', $state );
385 $session->persist();
386
387 return $this->continueAuthentication( $reqs );
388 }
389
390 /**
391 * Continue an authentication flow
392 *
393 * Return values are interpreted as follows:
394 * - status FAIL: Authentication failed. If $response->createRequest is
395 * set, that may be passed to self::beginAuthentication() or to
396 * self::beginAccountCreation() to preserve state.
397 * - status REDIRECT: The client should be redirected to the contained URL,
398 * new AuthenticationRequests should be made (if any), then
399 * AuthManager::continueAuthentication() should be called.
400 * - status UI: The client should be presented with a user interface for
401 * the fields in the specified AuthenticationRequests, then new
402 * AuthenticationRequests should be made, then
403 * AuthManager::continueAuthentication() should be called.
404 * - status RESTART: The user logged in successfully with a third-party
405 * service, but the third-party credentials aren't attached to any local
406 * account. This could be treated as a UI or a FAIL.
407 * - status PASS: Authentication was successful.
408 *
409 * @param AuthenticationRequest[] $reqs
410 * @return AuthenticationResponse
411 */
412 public function continueAuthentication( array $reqs ) {
413 $session = $this->request->getSession();
414 try {
415 if ( !$session->canSetUser() ) {
416 // Caller should have called canAuthenticateNow()
417 // @codeCoverageIgnoreStart
418 throw new \LogicException( 'Authentication is not possible now' );
419 // @codeCoverageIgnoreEnd
420 }
421
422 $state = $session->getSecret( 'AuthManager::authnState' );
423 if ( !is_array( $state ) ) {
424 return AuthenticationResponse::newFail(
425 wfMessage( 'authmanager-authn-not-in-progress' )
426 );
427 }
428 $state['continueRequests'] = [];
429
430 $guessUserName = $state['guessUserName'];
431
432 foreach ( $reqs as $req ) {
433 $req->returnToUrl = $state['returnToUrl'];
434 }
435
436 // Step 1: Choose an primary authentication provider, and call it until it succeeds.
437
438 if ( $state['primary'] === null ) {
439 // We haven't picked a PrimaryAuthenticationProvider yet
440 // @codeCoverageIgnoreStart
441 $guessUserName = null;
442 foreach ( $reqs as $req ) {
443 if ( $req->username !== null && $req->username !== '' ) {
444 if ( $guessUserName === null ) {
445 $guessUserName = $req->username;
446 } elseif ( $guessUserName !== $req->username ) {
447 $guessUserName = null;
448 break;
449 }
450 }
451 }
452 $state['guessUserName'] = $guessUserName;
453 // @codeCoverageIgnoreEnd
454 $state['reqs'] = $reqs;
455
456 foreach ( $this->getPrimaryAuthenticationProviders() as $id => $provider ) {
457 $res = $provider->beginPrimaryAuthentication( $reqs );
458 switch ( $res->status ) {
459 case AuthenticationResponse::PASS;
460 $state['primary'] = $id;
461 $state['primaryResponse'] = $res;
462 $this->logger->debug( "Primary login with $id succeeded" );
463 break 2;
464 case AuthenticationResponse::FAIL;
465 $this->logger->debug( "Login failed in primary authentication by $id" );
466 if ( $res->createRequest || $state['maybeLink'] ) {
467 $res->createRequest = new CreateFromLoginAuthenticationRequest(
468 $res->createRequest, $state['maybeLink']
469 );
470 }
471 $this->callMethodOnProviders( 7, 'postAuthentication',
472 [ User::newFromName( $guessUserName ) ?: null, $res ]
473 );
474 $session->remove( 'AuthManager::authnState' );
475 \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $res, null, $guessUserName, [] ] );
476 return $res;
477 case AuthenticationResponse::ABSTAIN;
478 // Continue loop
479 break;
480 case AuthenticationResponse::REDIRECT;
481 case AuthenticationResponse::UI;
482 $this->logger->debug( "Primary login with $id returned $res->status" );
483 $this->fillRequests( $res->neededRequests, self::ACTION_LOGIN, $guessUserName );
484 $state['primary'] = $id;
485 $state['continueRequests'] = $res->neededRequests;
486 $session->setSecret( 'AuthManager::authnState', $state );
487 return $res;
488
489 // @codeCoverageIgnoreStart
490 default:
491 throw new \DomainException(
492 get_class( $provider ) . "::beginPrimaryAuthentication() returned $res->status"
493 );
494 // @codeCoverageIgnoreEnd
495 }
496 }
497 if ( $state['primary'] === null ) {
498 $this->logger->debug( 'Login failed in primary authentication because no provider accepted' );
499 $ret = AuthenticationResponse::newFail(
500 wfMessage( 'authmanager-authn-no-primary' )
501 );
502 $this->callMethodOnProviders( 7, 'postAuthentication',
503 [ User::newFromName( $guessUserName ) ?: null, $ret ]
504 );
505 $session->remove( 'AuthManager::authnState' );
506 return $ret;
507 }
508 } elseif ( $state['primaryResponse'] === null ) {
509 $provider = $this->getAuthenticationProvider( $state['primary'] );
510 if ( !$provider instanceof PrimaryAuthenticationProvider ) {
511 // Configuration changed? Force them to start over.
512 // @codeCoverageIgnoreStart
513 $ret = AuthenticationResponse::newFail(
514 wfMessage( 'authmanager-authn-not-in-progress' )
515 );
516 $this->callMethodOnProviders( 7, 'postAuthentication',
517 [ User::newFromName( $guessUserName ) ?: null, $ret ]
518 );
519 $session->remove( 'AuthManager::authnState' );
520 return $ret;
521 // @codeCoverageIgnoreEnd
522 }
523 $id = $provider->getUniqueId();
524 $res = $provider->continuePrimaryAuthentication( $reqs );
525 switch ( $res->status ) {
526 case AuthenticationResponse::PASS;
527 $state['primaryResponse'] = $res;
528 $this->logger->debug( "Primary login with $id succeeded" );
529 break;
530 case AuthenticationResponse::FAIL;
531 $this->logger->debug( "Login failed in primary authentication by $id" );
532 if ( $res->createRequest || $state['maybeLink'] ) {
533 $res->createRequest = new CreateFromLoginAuthenticationRequest(
534 $res->createRequest, $state['maybeLink']
535 );
536 }
537 $this->callMethodOnProviders( 7, 'postAuthentication',
538 [ User::newFromName( $guessUserName ) ?: null, $res ]
539 );
540 $session->remove( 'AuthManager::authnState' );
541 \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $res, null, $guessUserName, [] ] );
542 return $res;
543 case AuthenticationResponse::REDIRECT;
544 case AuthenticationResponse::UI;
545 $this->logger->debug( "Primary login with $id returned $res->status" );
546 $this->fillRequests( $res->neededRequests, self::ACTION_LOGIN, $guessUserName );
547 $state['continueRequests'] = $res->neededRequests;
548 $session->setSecret( 'AuthManager::authnState', $state );
549 return $res;
550 default:
551 throw new \DomainException(
552 get_class( $provider ) . "::continuePrimaryAuthentication() returned $res->status"
553 );
554 }
555 }
556
557 $res = $state['primaryResponse'];
558 if ( $res->username === null ) {
559 $provider = $this->getAuthenticationProvider( $state['primary'] );
560 if ( !$provider instanceof PrimaryAuthenticationProvider ) {
561 // Configuration changed? Force them to start over.
562 // @codeCoverageIgnoreStart
563 $ret = AuthenticationResponse::newFail(
564 wfMessage( 'authmanager-authn-not-in-progress' )
565 );
566 $this->callMethodOnProviders( 7, 'postAuthentication',
567 [ User::newFromName( $guessUserName ) ?: null, $ret ]
568 );
569 $session->remove( 'AuthManager::authnState' );
570 return $ret;
571 // @codeCoverageIgnoreEnd
572 }
573
574 if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK &&
575 $res->linkRequest &&
576 // don't confuse the user with an incorrect message if linking is disabled
577 $this->getAuthenticationProvider( ConfirmLinkSecondaryAuthenticationProvider::class )
578 ) {
579 $state['maybeLink'][$res->linkRequest->getUniqueId()] = $res->linkRequest;
580 $msg = 'authmanager-authn-no-local-user-link';
581 } else {
582 $msg = 'authmanager-authn-no-local-user';
583 }
584 $this->logger->debug(
585 "Primary login with {$provider->getUniqueId()} succeeded, but returned no user"
586 );
587 $ret = AuthenticationResponse::newRestart( wfMessage( $msg ) );
588 $ret->neededRequests = $this->getAuthenticationRequestsInternal(
589 self::ACTION_LOGIN,
590 [],
591 $this->getPrimaryAuthenticationProviders() + $this->getSecondaryAuthenticationProviders()
592 );
593 if ( $res->createRequest || $state['maybeLink'] ) {
594 $ret->createRequest = new CreateFromLoginAuthenticationRequest(
595 $res->createRequest, $state['maybeLink']
596 );
597 $ret->neededRequests[] = $ret->createRequest;
598 }
599 $this->fillRequests( $ret->neededRequests, self::ACTION_LOGIN, null, true );
600 $session->setSecret( 'AuthManager::authnState', [
601 'reqs' => [], // Will be filled in later
602 'primary' => null,
603 'primaryResponse' => null,
604 'secondary' => [],
605 'continueRequests' => $ret->neededRequests,
606 ] + $state );
607 return $ret;
608 }
609
610 // Step 2: Primary authentication succeeded, create the User object
611 // (and add the user locally if necessary)
612
613 $user = User::newFromName( $res->username, 'usable' );
614 if ( !$user ) {
615 $provider = $this->getAuthenticationProvider( $state['primary'] );
616 throw new \DomainException(
617 get_class( $provider ) . " returned an invalid username: {$res->username}"
618 );
619 }
620 if ( $user->getId() === 0 ) {
621 // User doesn't exist locally. Create it.
622 $this->logger->info( 'Auto-creating {user} on login', [
623 'user' => $user->getName(),
624 ] );
625 $status = $this->autoCreateUser( $user, $state['primary'], false );
626 if ( !$status->isGood() ) {
627 $ret = AuthenticationResponse::newFail(
628 Status::wrap( $status )->getMessage( 'authmanager-authn-autocreate-failed' )
629 );
630 $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] );
631 $session->remove( 'AuthManager::authnState' );
632 \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, $user, $user->getName(), [] ] );
633 return $ret;
634 }
635 }
636
637 // Step 3: Iterate over all the secondary authentication providers.
638
639 $beginReqs = $state['reqs'];
640
641 foreach ( $this->getSecondaryAuthenticationProviders() as $id => $provider ) {
642 if ( !isset( $state['secondary'][$id] ) ) {
643 // This provider isn't started yet, so we pass it the set
644 // of reqs from beginAuthentication instead of whatever
645 // might have been used by a previous provider in line.
646 $func = 'beginSecondaryAuthentication';
647 $res = $provider->beginSecondaryAuthentication( $user, $beginReqs );
648 } elseif ( !$state['secondary'][$id] ) {
649 $func = 'continueSecondaryAuthentication';
650 $res = $provider->continueSecondaryAuthentication( $user, $reqs );
651 } else {
652 continue;
653 }
654 switch ( $res->status ) {
655 case AuthenticationResponse::PASS;
656 $this->logger->debug( "Secondary login with $id succeeded" );
657 // fall through
658 case AuthenticationResponse::ABSTAIN;
659 $state['secondary'][$id] = true;
660 break;
661 case AuthenticationResponse::FAIL;
662 $this->logger->debug( "Login failed in secondary authentication by $id" );
663 $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $res ] );
664 $session->remove( 'AuthManager::authnState' );
665 \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $res, $user, $user->getName(), [] ] );
666 return $res;
667 case AuthenticationResponse::REDIRECT;
668 case AuthenticationResponse::UI;
669 $this->logger->debug( "Secondary login with $id returned " . $res->status );
670 $this->fillRequests( $res->neededRequests, self::ACTION_LOGIN, $user->getName() );
671 $state['secondary'][$id] = false;
672 $state['continueRequests'] = $res->neededRequests;
673 $session->setSecret( 'AuthManager::authnState', $state );
674 return $res;
675
676 // @codeCoverageIgnoreStart
677 default:
678 throw new \DomainException(
679 get_class( $provider ) . "::{$func}() returned $res->status"
680 );
681 // @codeCoverageIgnoreEnd
682 }
683 }
684
685 // Step 4: Authentication complete! Set the user in the session and
686 // clean up.
687
688 $this->logger->info( 'Login for {user} succeeded from {clientip}', [
689 'user' => $user->getName(),
690 'clientip' => $this->request->getIP(),
691 ] );
692 /** @var RememberMeAuthenticationRequest $req */
693 $req = AuthenticationRequest::getRequestByClass(
694 $beginReqs, RememberMeAuthenticationRequest::class
695 );
696 $this->setSessionDataForUser( $user, $req && $req->rememberMe );
697 $ret = AuthenticationResponse::newPass( $user->getName() );
698 $this->callMethodOnProviders( 7, 'postAuthentication', [ $user, $ret ] );
699 $session->remove( 'AuthManager::authnState' );
700 $this->removeAuthenticationSessionData( null );
701 \Hooks::run( 'AuthManagerLoginAuthenticateAudit', [ $ret, $user, $user->getName(), [] ] );
702 return $ret;
703 } catch ( \Exception $ex ) {
704 $session->remove( 'AuthManager::authnState' );
705 throw $ex;
706 }
707 }
708
709 /**
710 * Whether security-sensitive operations should proceed.
711 *
712 * A "security-sensitive operation" is something like a password or email
713 * change, that would normally have a "reenter your password to confirm"
714 * box if we only supported password-based authentication.
715 *
716 * @param string $operation Operation being checked. This should be a
717 * message-key-like string such as 'change-password' or 'change-email'.
718 * @return string One of the SEC_* constants.
719 */
720 public function securitySensitiveOperationStatus( $operation ) {
721 $status = self::SEC_OK;
722
723 $this->logger->debug( __METHOD__ . ": Checking $operation" );
724
725 $session = $this->request->getSession();
726 $aId = $session->getUser()->getId();
727 if ( $aId === 0 ) {
728 // User isn't authenticated. DWIM?
729 $status = $this->canAuthenticateNow() ? self::SEC_REAUTH : self::SEC_FAIL;
730 $this->logger->info( __METHOD__ . ": Not logged in! $operation is $status" );
731 return $status;
732 }
733
734 if ( $session->canSetUser() ) {
735 $id = $session->get( 'AuthManager:lastAuthId' );
736 $last = $session->get( 'AuthManager:lastAuthTimestamp' );
737 if ( $id !== $aId || $last === null ) {
738 $timeSinceLogin = PHP_INT_MAX; // Forever ago
739 } else {
740 $timeSinceLogin = max( 0, time() - $last );
741 }
742
743 $thresholds = $this->config->get( 'ReauthenticateTime' );
744 if ( isset( $thresholds[$operation] ) ) {
745 $threshold = $thresholds[$operation];
746 } elseif ( isset( $thresholds['default'] ) ) {
747 $threshold = $thresholds['default'];
748 } else {
749 throw new \UnexpectedValueException( '$wgReauthenticateTime lacks a default' );
750 }
751
752 if ( $threshold >= 0 && $timeSinceLogin > $threshold ) {
753 $status = self::SEC_REAUTH;
754 }
755 } else {
756 $timeSinceLogin = -1;
757
758 $pass = $this->config->get( 'AllowSecuritySensitiveOperationIfCannotReauthenticate' );
759 if ( isset( $pass[$operation] ) ) {
760 $status = $pass[$operation] ? self::SEC_OK : self::SEC_FAIL;
761 } elseif ( isset( $pass['default'] ) ) {
762 $status = $pass['default'] ? self::SEC_OK : self::SEC_FAIL;
763 } else {
764 throw new \UnexpectedValueException(
765 '$wgAllowSecuritySensitiveOperationIfCannotReauthenticate lacks a default'
766 );
767 }
768 }
769
770 \Hooks::run( 'SecuritySensitiveOperationStatus', [
771 &$status, $operation, $session, $timeSinceLogin
772 ] );
773
774 // If authentication is not possible, downgrade from "REAUTH" to "FAIL".
775 if ( !$this->canAuthenticateNow() && $status === self::SEC_REAUTH ) {
776 $status = self::SEC_FAIL;
777 }
778
779 $this->logger->info( __METHOD__ . ": $operation is $status for '{user}'",
780 [
781 'user' => $session->getUser()->getName(),
782 'clientip' => $this->getRequest()->getIP(),
783 ]
784 );
785
786 return $status;
787 }
788
789 /**
790 * Determine whether a username can authenticate
791 *
792 * This is mainly for internal purposes and only takes authentication data into account,
793 * not things like blocks that can change without the authentication system being aware.
794 *
795 * @param string $username MediaWiki username
796 * @return bool
797 */
798 public function userCanAuthenticate( $username ) {
799 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
800 if ( $provider->testUserCanAuthenticate( $username ) ) {
801 return true;
802 }
803 }
804 return false;
805 }
806
807 /**
808 * Provide normalized versions of the username for security checks
809 *
810 * Since different providers can normalize the input in different ways,
811 * this returns an array of all the different ways the name might be
812 * normalized for authentication.
813 *
814 * The returned strings should not be revealed to the user, as that might
815 * leak private information (e.g. an email address might be normalized to a
816 * username).
817 *
818 * @param string $username
819 * @return string[]
820 */
821 public function normalizeUsername( $username ) {
822 $ret = [];
823 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
824 $normalized = $provider->providerNormalizeUsername( $username );
825 if ( $normalized !== null ) {
826 $ret[$normalized] = true;
827 }
828 }
829 return array_keys( $ret );
830 }
831
832 /**@}*/
833
834 /**
835 * @name Authentication data changing
836 * @{
837 */
838
839 /**
840 * Revoke any authentication credentials for a user
841 *
842 * After this, the user should no longer be able to log in.
843 *
844 * @param string $username
845 */
846 public function revokeAccessForUser( $username ) {
847 $this->logger->info( 'Revoking access for {user}', [
848 'user' => $username,
849 ] );
850 $this->callMethodOnProviders( 6, 'providerRevokeAccessForUser', [ $username ] );
851 }
852
853 /**
854 * Validate a change of authentication data (e.g. passwords)
855 * @param AuthenticationRequest $req
856 * @param bool $checkData If false, $req hasn't been loaded from the
857 * submission so checks on user-submitted fields should be skipped. $req->username is
858 * considered user-submitted for this purpose, even if it cannot be changed via
859 * $req->loadFromSubmission.
860 * @return Status
861 */
862 public function allowsAuthenticationDataChange( AuthenticationRequest $req, $checkData = true ) {
863 $any = false;
864 $providers = $this->getPrimaryAuthenticationProviders() +
865 $this->getSecondaryAuthenticationProviders();
866 foreach ( $providers as $provider ) {
867 $status = $provider->providerAllowsAuthenticationDataChange( $req, $checkData );
868 if ( !$status->isGood() ) {
869 return Status::wrap( $status );
870 }
871 $any = $any || $status->value !== 'ignored';
872 }
873 if ( !$any ) {
874 $status = Status::newGood( 'ignored' );
875 $status->warning( 'authmanager-change-not-supported' );
876 return $status;
877 }
878 return Status::newGood();
879 }
880
881 /**
882 * Change authentication data (e.g. passwords)
883 *
884 * If $req was returned for AuthManager::ACTION_CHANGE, using $req should
885 * result in a successful login in the future.
886 *
887 * If $req was returned for AuthManager::ACTION_REMOVE, using $req should
888 * no longer result in a successful login.
889 *
890 * This method should only be called if allowsAuthenticationDataChange( $req, true )
891 * returned success.
892 *
893 * @param AuthenticationRequest $req
894 * @param bool $isAddition Set true if this represents an addition of
895 * credentials rather than a change. The main difference is that additions
896 * should not invalidate BotPasswords. If you're not sure, leave it false.
897 */
898 public function changeAuthenticationData( AuthenticationRequest $req, $isAddition = false ) {
899 $this->logger->info( 'Changing authentication data for {user} class {what}', [
900 'user' => is_string( $req->username ) ? $req->username : '<no name>',
901 'what' => get_class( $req ),
902 ] );
903
904 $this->callMethodOnProviders( 6, 'providerChangeAuthenticationData', [ $req ] );
905
906 // When the main account's authentication data is changed, invalidate
907 // all BotPasswords too.
908 if ( !$isAddition ) {
909 \BotPassword::invalidateAllPasswordsForUser( $req->username );
910 }
911 }
912
913 /**@}*/
914
915 /**
916 * @name Account creation
917 * @{
918 */
919
920 /**
921 * Determine whether accounts can be created
922 * @return bool
923 */
924 public function canCreateAccounts() {
925 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
926 switch ( $provider->accountCreationType() ) {
927 case PrimaryAuthenticationProvider::TYPE_CREATE:
928 case PrimaryAuthenticationProvider::TYPE_LINK:
929 return true;
930 }
931 }
932 return false;
933 }
934
935 /**
936 * Determine whether a particular account can be created
937 * @param string $username MediaWiki username
938 * @param array $options
939 * - flags: (int) Bitfield of User:READ_* constants, default User::READ_NORMAL
940 * - creating: (bool) For internal use only. Never specify this.
941 * @return Status
942 */
943 public function canCreateAccount( $username, $options = [] ) {
944 // Back compat
945 if ( is_int( $options ) ) {
946 $options = [ 'flags' => $options ];
947 }
948 $options += [
949 'flags' => User::READ_NORMAL,
950 'creating' => false,
951 ];
952 $flags = $options['flags'];
953
954 if ( !$this->canCreateAccounts() ) {
955 return Status::newFatal( 'authmanager-create-disabled' );
956 }
957
958 if ( $this->userExists( $username, $flags ) ) {
959 return Status::newFatal( 'userexists' );
960 }
961
962 $user = User::newFromName( $username, 'creatable' );
963 if ( !is_object( $user ) ) {
964 return Status::newFatal( 'noname' );
965 } else {
966 $user->load( $flags ); // Explicitly load with $flags, auto-loading always uses READ_NORMAL
967 if ( $user->getId() !== 0 ) {
968 return Status::newFatal( 'userexists' );
969 }
970 }
971
972 // Denied by providers?
973 $providers = $this->getPreAuthenticationProviders() +
974 $this->getPrimaryAuthenticationProviders() +
975 $this->getSecondaryAuthenticationProviders();
976 foreach ( $providers as $provider ) {
977 $status = $provider->testUserForCreation( $user, false, $options );
978 if ( !$status->isGood() ) {
979 return Status::wrap( $status );
980 }
981 }
982
983 return Status::newGood();
984 }
985
986 /**
987 * Basic permissions checks on whether a user can create accounts
988 * @param User $creator User doing the account creation
989 * @return Status
990 */
991 public function checkAccountCreatePermissions( User $creator ) {
992 // Wiki is read-only?
993 if ( wfReadOnly() ) {
994 return Status::newFatal( wfMessage( 'readonlytext', wfReadOnlyReason() ) );
995 }
996
997 // This is awful, this permission check really shouldn't go through Title.
998 $permErrors = \SpecialPage::getTitleFor( 'CreateAccount' )
999 ->getUserPermissionsErrors( 'createaccount', $creator, 'secure' );
1000 if ( $permErrors ) {
1001 $status = Status::newGood();
1002 foreach ( $permErrors as $args ) {
1003 $status->fatal( ...$args );
1004 }
1005 return $status;
1006 }
1007
1008 $block = $creator->isBlockedFromCreateAccount();
1009 if ( $block ) {
1010 $errorParams = [
1011 $block->getTarget(),
1012 $block->mReason ?: wfMessage( 'blockednoreason' )->text(),
1013 $block->getByName()
1014 ];
1015
1016 if ( $block->getType() === \Block::TYPE_RANGE ) {
1017 $errorMessage = 'cantcreateaccount-range-text';
1018 $errorParams[] = $this->getRequest()->getIP();
1019 } else {
1020 $errorMessage = 'cantcreateaccount-text';
1021 }
1022
1023 return Status::newFatal( wfMessage( $errorMessage, $errorParams ) );
1024 }
1025
1026 $ip = $this->getRequest()->getIP();
1027 if ( $creator->isDnsBlacklisted( $ip, true /* check $wgProxyWhitelist */ ) ) {
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::run( '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 ( !$anon->isAllowedAny( 'createaccount', 'autocreateaccount' ) ) {
1639 $this->logger->debug( __METHOD__ . ': IP lacks the ability to create or autocreate accounts', [
1640 'username' => $username,
1641 'ip' => $anon->getName(),
1642 ] );
1643 $session->set( 'AuthManager::AutoCreateBlacklist', 'authmanager-autocreate-noperm' );
1644 $session->persist();
1645 $user->setId( 0 );
1646 $user->loadFromId();
1647 return Status::newFatal( 'authmanager-autocreate-noperm' );
1648 }
1649
1650 // Avoid account creation races on double submissions
1651 $cache = \ObjectCache::getLocalClusterInstance();
1652 $lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) );
1653 if ( !$lock ) {
1654 $this->logger->debug( __METHOD__ . ': Could not acquire account creation lock', [
1655 'user' => $username,
1656 ] );
1657 $user->setId( 0 );
1658 $user->loadFromId();
1659 return Status::newFatal( 'usernameinprogress' );
1660 }
1661
1662 // Denied by providers?
1663 $options = [
1664 'flags' => User::READ_LATEST,
1665 'creating' => true,
1666 ];
1667 $providers = $this->getPreAuthenticationProviders() +
1668 $this->getPrimaryAuthenticationProviders() +
1669 $this->getSecondaryAuthenticationProviders();
1670 foreach ( $providers as $provider ) {
1671 $status = $provider->testUserForCreation( $user, $source, $options );
1672 if ( !$status->isGood() ) {
1673 $ret = Status::wrap( $status );
1674 $this->logger->debug( __METHOD__ . ': Provider denied creation of {username}: {reason}', [
1675 'username' => $username,
1676 'reason' => $ret->getWikiText( null, null, 'en' ),
1677 ] );
1678 $session->set( 'AuthManager::AutoCreateBlacklist', $status );
1679 $user->setId( 0 );
1680 $user->loadFromId();
1681 return $ret;
1682 }
1683 }
1684
1685 $backoffKey = $cache->makeKey( 'AuthManager', 'autocreate-failed', md5( $username ) );
1686 if ( $cache->get( $backoffKey ) ) {
1687 $this->logger->debug( __METHOD__ . ': {username} denied by prior creation attempt failures', [
1688 'username' => $username,
1689 ] );
1690 $user->setId( 0 );
1691 $user->loadFromId();
1692 return Status::newFatal( 'authmanager-autocreate-exception' );
1693 }
1694
1695 // Checks passed, create the user...
1696 $from = $_SERVER['REQUEST_URI'] ?? 'CLI';
1697 $this->logger->info( __METHOD__ . ': creating new user ({username}) - from: {from}', [
1698 'username' => $username,
1699 'from' => $from,
1700 ] );
1701
1702 // Ignore warnings about master connections/writes...hard to avoid here
1703 $trxProfiler = \Profiler::instance()->getTransactionProfiler();
1704 $old = $trxProfiler->setSilenced( true );
1705 try {
1706 $status = $user->addToDatabase();
1707 if ( !$status->isOK() ) {
1708 // Double-check for a race condition (T70012). We make use of the fact that when
1709 // addToDatabase fails due to the user already existing, the user object gets loaded.
1710 if ( $user->getId() ) {
1711 $this->logger->info( __METHOD__ . ': {username} already exists locally (race)', [
1712 'username' => $username,
1713 ] );
1714 if ( $login ) {
1715 $this->setSessionDataForUser( $user );
1716 }
1717 $status = Status::newGood();
1718 $status->warning( 'userexists' );
1719 } else {
1720 $this->logger->error( __METHOD__ . ': {username} failed with message {msg}', [
1721 'username' => $username,
1722 'msg' => $status->getWikiText( null, null, 'en' )
1723 ] );
1724 $user->setId( 0 );
1725 $user->loadFromId();
1726 }
1727 return $status;
1728 }
1729 } catch ( \Exception $ex ) {
1730 $trxProfiler->setSilenced( $old );
1731 $this->logger->error( __METHOD__ . ': {username} failed with exception {exception}', [
1732 'username' => $username,
1733 'exception' => $ex,
1734 ] );
1735 // Do not keep throwing errors for a while
1736 $cache->set( $backoffKey, 1, 600 );
1737 // Bubble up error; which should normally trigger DB rollbacks
1738 throw $ex;
1739 }
1740
1741 $this->setDefaultUserOptions( $user, false );
1742
1743 // Inform the providers
1744 $this->callMethodOnProviders( 6, 'autoCreatedAccount', [ $user, $source ] );
1745
1746 \Hooks::run( 'AuthPluginAutoCreate', [ $user ], '1.27' );
1747 \Hooks::run( 'LocalUserCreated', [ $user, true ] );
1748 $user->saveSettings();
1749
1750 // Update user count
1751 \DeferredUpdates::addUpdate( \SiteStatsUpdate::factory( [ 'users' => 1 ] ) );
1752 // Watch user's userpage and talk page
1753 \DeferredUpdates::addCallableUpdate( function () use ( $user ) {
1754 $user->addWatch( $user->getUserPage(), User::IGNORE_USER_RIGHTS );
1755 } );
1756
1757 // Log the creation
1758 if ( $this->config->get( 'NewUserLog' ) ) {
1759 $logEntry = new \ManualLogEntry( 'newusers', 'autocreate' );
1760 $logEntry->setPerformer( $user );
1761 $logEntry->setTarget( $user->getUserPage() );
1762 $logEntry->setComment( '' );
1763 $logEntry->setParameters( [
1764 '4::userid' => $user->getId(),
1765 ] );
1766 $logEntry->insert();
1767 }
1768
1769 $trxProfiler->setSilenced( $old );
1770
1771 if ( $login ) {
1772 $this->setSessionDataForUser( $user );
1773 }
1774
1775 return Status::newGood();
1776 }
1777
1778 /**@}*/
1779
1780 /**
1781 * @name Account linking
1782 * @{
1783 */
1784
1785 /**
1786 * Determine whether accounts can be linked
1787 * @return bool
1788 */
1789 public function canLinkAccounts() {
1790 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
1791 if ( $provider->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK ) {
1792 return true;
1793 }
1794 }
1795 return false;
1796 }
1797
1798 /**
1799 * Start an account linking flow
1800 *
1801 * @param User $user User being linked
1802 * @param AuthenticationRequest[] $reqs
1803 * @param string $returnToUrl Url that REDIRECT responses should eventually
1804 * return to.
1805 * @return AuthenticationResponse
1806 */
1807 public function beginAccountLink( User $user, array $reqs, $returnToUrl ) {
1808 $session = $this->request->getSession();
1809 $session->remove( 'AuthManager::accountLinkState' );
1810
1811 if ( !$this->canLinkAccounts() ) {
1812 // Caller should have called canLinkAccounts()
1813 throw new \LogicException( 'Account linking is not possible' );
1814 }
1815
1816 if ( $user->getId() === 0 ) {
1817 if ( !User::isUsableName( $user->getName() ) ) {
1818 $msg = wfMessage( 'noname' );
1819 } else {
1820 $msg = wfMessage( 'authmanager-userdoesnotexist', $user->getName() );
1821 }
1822 return AuthenticationResponse::newFail( $msg );
1823 }
1824 foreach ( $reqs as $req ) {
1825 $req->username = $user->getName();
1826 $req->returnToUrl = $returnToUrl;
1827 }
1828
1829 $this->removeAuthenticationSessionData( null );
1830
1831 $providers = $this->getPreAuthenticationProviders();
1832 foreach ( $providers as $id => $provider ) {
1833 $status = $provider->testForAccountLink( $user );
1834 if ( !$status->isGood() ) {
1835 $this->logger->debug( __METHOD__ . ": Account linking pre-check failed by $id", [
1836 'user' => $user->getName(),
1837 ] );
1838 $ret = AuthenticationResponse::newFail(
1839 Status::wrap( $status )->getMessage()
1840 );
1841 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
1842 return $ret;
1843 }
1844 }
1845
1846 $state = [
1847 'username' => $user->getName(),
1848 'userid' => $user->getId(),
1849 'returnToUrl' => $returnToUrl,
1850 'primary' => null,
1851 'continueRequests' => [],
1852 ];
1853
1854 $providers = $this->getPrimaryAuthenticationProviders();
1855 foreach ( $providers as $id => $provider ) {
1856 if ( $provider->accountCreationType() !== PrimaryAuthenticationProvider::TYPE_LINK ) {
1857 continue;
1858 }
1859
1860 $res = $provider->beginPrimaryAccountLink( $user, $reqs );
1861 switch ( $res->status ) {
1862 case AuthenticationResponse::PASS;
1863 $this->logger->info( "Account linked to {user} by $id", [
1864 'user' => $user->getName(),
1865 ] );
1866 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
1867 return $res;
1868
1869 case AuthenticationResponse::FAIL;
1870 $this->logger->debug( __METHOD__ . ": Account linking failed by $id", [
1871 'user' => $user->getName(),
1872 ] );
1873 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
1874 return $res;
1875
1876 case AuthenticationResponse::ABSTAIN;
1877 // Continue loop
1878 break;
1879
1880 case AuthenticationResponse::REDIRECT;
1881 case AuthenticationResponse::UI;
1882 $this->logger->debug( __METHOD__ . ": Account linking $res->status by $id", [
1883 'user' => $user->getName(),
1884 ] );
1885 $this->fillRequests( $res->neededRequests, self::ACTION_LINK, $user->getName() );
1886 $state['primary'] = $id;
1887 $state['continueRequests'] = $res->neededRequests;
1888 $session->setSecret( 'AuthManager::accountLinkState', $state );
1889 $session->persist();
1890 return $res;
1891
1892 // @codeCoverageIgnoreStart
1893 default:
1894 throw new \DomainException(
1895 get_class( $provider ) . "::beginPrimaryAccountLink() returned $res->status"
1896 );
1897 // @codeCoverageIgnoreEnd
1898 }
1899 }
1900
1901 $this->logger->debug( __METHOD__ . ': Account linking failed because no provider accepted', [
1902 'user' => $user->getName(),
1903 ] );
1904 $ret = AuthenticationResponse::newFail(
1905 wfMessage( 'authmanager-link-no-primary' )
1906 );
1907 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
1908 return $ret;
1909 }
1910
1911 /**
1912 * Continue an account linking flow
1913 * @param AuthenticationRequest[] $reqs
1914 * @return AuthenticationResponse
1915 */
1916 public function continueAccountLink( array $reqs ) {
1917 $session = $this->request->getSession();
1918 try {
1919 if ( !$this->canLinkAccounts() ) {
1920 // Caller should have called canLinkAccounts()
1921 $session->remove( 'AuthManager::accountLinkState' );
1922 throw new \LogicException( 'Account linking is not possible' );
1923 }
1924
1925 $state = $session->getSecret( 'AuthManager::accountLinkState' );
1926 if ( !is_array( $state ) ) {
1927 return AuthenticationResponse::newFail(
1928 wfMessage( 'authmanager-link-not-in-progress' )
1929 );
1930 }
1931 $state['continueRequests'] = [];
1932
1933 // Step 0: Prepare and validate the input
1934
1935 $user = User::newFromName( $state['username'], 'usable' );
1936 if ( !is_object( $user ) ) {
1937 $session->remove( 'AuthManager::accountLinkState' );
1938 return AuthenticationResponse::newFail( wfMessage( 'noname' ) );
1939 }
1940 if ( $user->getId() !== $state['userid'] ) {
1941 throw new \UnexpectedValueException(
1942 "User \"{$state['username']}\" is valid, but " .
1943 "ID {$user->getId()} !== {$state['userid']}!"
1944 );
1945 }
1946
1947 foreach ( $reqs as $req ) {
1948 $req->username = $state['username'];
1949 $req->returnToUrl = $state['returnToUrl'];
1950 }
1951
1952 // Step 1: Call the primary again until it succeeds
1953
1954 $provider = $this->getAuthenticationProvider( $state['primary'] );
1955 if ( !$provider instanceof PrimaryAuthenticationProvider ) {
1956 // Configuration changed? Force them to start over.
1957 // @codeCoverageIgnoreStart
1958 $ret = AuthenticationResponse::newFail(
1959 wfMessage( 'authmanager-link-not-in-progress' )
1960 );
1961 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $ret ] );
1962 $session->remove( 'AuthManager::accountLinkState' );
1963 return $ret;
1964 // @codeCoverageIgnoreEnd
1965 }
1966 $id = $provider->getUniqueId();
1967 $res = $provider->continuePrimaryAccountLink( $user, $reqs );
1968 switch ( $res->status ) {
1969 case AuthenticationResponse::PASS;
1970 $this->logger->info( "Account linked to {user} by $id", [
1971 'user' => $user->getName(),
1972 ] );
1973 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
1974 $session->remove( 'AuthManager::accountLinkState' );
1975 return $res;
1976 case AuthenticationResponse::FAIL;
1977 $this->logger->debug( __METHOD__ . ": Account linking failed by $id", [
1978 'user' => $user->getName(),
1979 ] );
1980 $this->callMethodOnProviders( 3, 'postAccountLink', [ $user, $res ] );
1981 $session->remove( 'AuthManager::accountLinkState' );
1982 return $res;
1983 case AuthenticationResponse::REDIRECT;
1984 case AuthenticationResponse::UI;
1985 $this->logger->debug( __METHOD__ . ": Account linking $res->status by $id", [
1986 'user' => $user->getName(),
1987 ] );
1988 $this->fillRequests( $res->neededRequests, self::ACTION_LINK, $user->getName() );
1989 $state['continueRequests'] = $res->neededRequests;
1990 $session->setSecret( 'AuthManager::accountLinkState', $state );
1991 return $res;
1992 default:
1993 throw new \DomainException(
1994 get_class( $provider ) . "::continuePrimaryAccountLink() returned $res->status"
1995 );
1996 }
1997 } catch ( \Exception $ex ) {
1998 $session->remove( 'AuthManager::accountLinkState' );
1999 throw $ex;
2000 }
2001 }
2002
2003 /**@}*/
2004
2005 /**
2006 * @name Information methods
2007 * @{
2008 */
2009
2010 /**
2011 * Return the applicable list of AuthenticationRequests
2012 *
2013 * Possible values for $action:
2014 * - ACTION_LOGIN: Valid for passing to beginAuthentication
2015 * - ACTION_LOGIN_CONTINUE: Valid for passing to continueAuthentication in the current state
2016 * - ACTION_CREATE: Valid for passing to beginAccountCreation
2017 * - ACTION_CREATE_CONTINUE: Valid for passing to continueAccountCreation in the current state
2018 * - ACTION_LINK: Valid for passing to beginAccountLink
2019 * - ACTION_LINK_CONTINUE: Valid for passing to continueAccountLink in the current state
2020 * - ACTION_CHANGE: Valid for passing to changeAuthenticationData to change credentials
2021 * - ACTION_REMOVE: Valid for passing to changeAuthenticationData to remove credentials.
2022 * - ACTION_UNLINK: Same as ACTION_REMOVE, but limited to linked accounts.
2023 *
2024 * @param string $action One of the AuthManager::ACTION_* constants
2025 * @param User|null $user User being acted on, instead of the current user.
2026 * @return AuthenticationRequest[]
2027 */
2028 public function getAuthenticationRequests( $action, User $user = null ) {
2029 $options = [];
2030 $providerAction = $action;
2031
2032 // Figure out which providers to query
2033 switch ( $action ) {
2034 case self::ACTION_LOGIN:
2035 case self::ACTION_CREATE:
2036 $providers = $this->getPreAuthenticationProviders() +
2037 $this->getPrimaryAuthenticationProviders() +
2038 $this->getSecondaryAuthenticationProviders();
2039 break;
2040
2041 case self::ACTION_LOGIN_CONTINUE:
2042 $state = $this->request->getSession()->getSecret( 'AuthManager::authnState' );
2043 return is_array( $state ) ? $state['continueRequests'] : [];
2044
2045 case self::ACTION_CREATE_CONTINUE:
2046 $state = $this->request->getSession()->getSecret( 'AuthManager::accountCreationState' );
2047 return is_array( $state ) ? $state['continueRequests'] : [];
2048
2049 case self::ACTION_LINK:
2050 $providers = array_filter( $this->getPrimaryAuthenticationProviders(), function ( $p ) {
2051 return $p->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK;
2052 } );
2053 break;
2054
2055 case self::ACTION_UNLINK:
2056 $providers = array_filter( $this->getPrimaryAuthenticationProviders(), function ( $p ) {
2057 return $p->accountCreationType() === PrimaryAuthenticationProvider::TYPE_LINK;
2058 } );
2059
2060 // To providers, unlink and remove are identical.
2061 $providerAction = self::ACTION_REMOVE;
2062 break;
2063
2064 case self::ACTION_LINK_CONTINUE:
2065 $state = $this->request->getSession()->getSecret( 'AuthManager::accountLinkState' );
2066 return is_array( $state ) ? $state['continueRequests'] : [];
2067
2068 case self::ACTION_CHANGE:
2069 case self::ACTION_REMOVE:
2070 $providers = $this->getPrimaryAuthenticationProviders() +
2071 $this->getSecondaryAuthenticationProviders();
2072 break;
2073
2074 // @codeCoverageIgnoreStart
2075 default:
2076 throw new \DomainException( __METHOD__ . ": Invalid action \"$action\"" );
2077 }
2078 // @codeCoverageIgnoreEnd
2079
2080 return $this->getAuthenticationRequestsInternal( $providerAction, $options, $providers, $user );
2081 }
2082
2083 /**
2084 * Internal request lookup for self::getAuthenticationRequests
2085 *
2086 * @param string $providerAction Action to pass to providers
2087 * @param array $options Options to pass to providers
2088 * @param AuthenticationProvider[] $providers
2089 * @param User|null $user
2090 * @return AuthenticationRequest[]
2091 */
2092 private function getAuthenticationRequestsInternal(
2093 $providerAction, array $options, array $providers, User $user = null
2094 ) {
2095 $user = $user ?: \RequestContext::getMain()->getUser();
2096 $options['username'] = $user->isAnon() ? null : $user->getName();
2097
2098 // Query them and merge results
2099 $reqs = [];
2100 foreach ( $providers as $provider ) {
2101 $isPrimary = $provider instanceof PrimaryAuthenticationProvider;
2102 foreach ( $provider->getAuthenticationRequests( $providerAction, $options ) as $req ) {
2103 $id = $req->getUniqueId();
2104
2105 // If a required request if from a Primary, mark it as "primary-required" instead
2106 if ( $isPrimary ) {
2107 if ( $req->required ) {
2108 $req->required = AuthenticationRequest::PRIMARY_REQUIRED;
2109 }
2110 }
2111
2112 if (
2113 !isset( $reqs[$id] )
2114 || $req->required === AuthenticationRequest::REQUIRED
2115 || $reqs[$id] === AuthenticationRequest::OPTIONAL
2116 ) {
2117 $reqs[$id] = $req;
2118 }
2119 }
2120 }
2121
2122 // AuthManager has its own req for some actions
2123 switch ( $providerAction ) {
2124 case self::ACTION_LOGIN:
2125 $reqs[] = new RememberMeAuthenticationRequest;
2126 break;
2127
2128 case self::ACTION_CREATE:
2129 $reqs[] = new UsernameAuthenticationRequest;
2130 $reqs[] = new UserDataAuthenticationRequest;
2131 if ( $options['username'] !== null ) {
2132 $reqs[] = new CreationReasonAuthenticationRequest;
2133 $options['username'] = null; // Don't fill in the username below
2134 }
2135 break;
2136 }
2137
2138 // Fill in reqs data
2139 $this->fillRequests( $reqs, $providerAction, $options['username'], true );
2140
2141 // For self::ACTION_CHANGE, filter out any that something else *doesn't* allow changing
2142 if ( $providerAction === self::ACTION_CHANGE || $providerAction === self::ACTION_REMOVE ) {
2143 $reqs = array_filter( $reqs, function ( $req ) {
2144 return $this->allowsAuthenticationDataChange( $req, false )->isGood();
2145 } );
2146 }
2147
2148 return array_values( $reqs );
2149 }
2150
2151 /**
2152 * Set values in an array of requests
2153 * @param AuthenticationRequest[] &$reqs
2154 * @param string $action
2155 * @param string|null $username
2156 * @param bool $forceAction
2157 */
2158 private function fillRequests( array &$reqs, $action, $username, $forceAction = false ) {
2159 foreach ( $reqs as $req ) {
2160 if ( !$req->action || $forceAction ) {
2161 $req->action = $action;
2162 }
2163 if ( $req->username === null ) {
2164 $req->username = $username;
2165 }
2166 }
2167 }
2168
2169 /**
2170 * Determine whether a username exists
2171 * @param string $username
2172 * @param int $flags Bitfield of User:READ_* constants
2173 * @return bool
2174 */
2175 public function userExists( $username, $flags = User::READ_NORMAL ) {
2176 foreach ( $this->getPrimaryAuthenticationProviders() as $provider ) {
2177 if ( $provider->testUserExists( $username, $flags ) ) {
2178 return true;
2179 }
2180 }
2181
2182 return false;
2183 }
2184
2185 /**
2186 * Determine whether a user property should be allowed to be changed.
2187 *
2188 * Supported properties are:
2189 * - emailaddress
2190 * - realname
2191 * - nickname
2192 *
2193 * @param string $property
2194 * @return bool
2195 */
2196 public function allowsPropertyChange( $property ) {
2197 $providers = $this->getPrimaryAuthenticationProviders() +
2198 $this->getSecondaryAuthenticationProviders();
2199 foreach ( $providers as $provider ) {
2200 if ( !$provider->providerAllowsPropertyChange( $property ) ) {
2201 return false;
2202 }
2203 }
2204 return true;
2205 }
2206
2207 /**
2208 * Get a provider by ID
2209 * @note This is public so extensions can check whether their own provider
2210 * is installed and so they can read its configuration if necessary.
2211 * Other uses are not recommended.
2212 * @param string $id
2213 * @return AuthenticationProvider|null
2214 */
2215 public function getAuthenticationProvider( $id ) {
2216 // Fast version
2217 if ( isset( $this->allAuthenticationProviders[$id] ) ) {
2218 return $this->allAuthenticationProviders[$id];
2219 }
2220
2221 // Slow version: instantiate each kind and check
2222 $providers = $this->getPrimaryAuthenticationProviders();
2223 if ( isset( $providers[$id] ) ) {
2224 return $providers[$id];
2225 }
2226 $providers = $this->getSecondaryAuthenticationProviders();
2227 if ( isset( $providers[$id] ) ) {
2228 return $providers[$id];
2229 }
2230 $providers = $this->getPreAuthenticationProviders();
2231 if ( isset( $providers[$id] ) ) {
2232 return $providers[$id];
2233 }
2234
2235 return null;
2236 }
2237
2238 /**@}*/
2239
2240 /**
2241 * @name Internal methods
2242 * @{
2243 */
2244
2245 /**
2246 * Store authentication in the current session
2247 * @protected For use by AuthenticationProviders
2248 * @param string $key
2249 * @param mixed $data Must be serializable
2250 */
2251 public function setAuthenticationSessionData( $key, $data ) {
2252 $session = $this->request->getSession();
2253 $arr = $session->getSecret( 'authData' );
2254 if ( !is_array( $arr ) ) {
2255 $arr = [];
2256 }
2257 $arr[$key] = $data;
2258 $session->setSecret( 'authData', $arr );
2259 }
2260
2261 /**
2262 * Fetch authentication data from the current session
2263 * @protected For use by AuthenticationProviders
2264 * @param string $key
2265 * @param mixed|null $default
2266 * @return mixed
2267 */
2268 public function getAuthenticationSessionData( $key, $default = null ) {
2269 $arr = $this->request->getSession()->getSecret( 'authData' );
2270 if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) {
2271 return $arr[$key];
2272 } else {
2273 return $default;
2274 }
2275 }
2276
2277 /**
2278 * Remove authentication data
2279 * @protected For use by AuthenticationProviders
2280 * @param string|null $key If null, all data is removed
2281 */
2282 public function removeAuthenticationSessionData( $key ) {
2283 $session = $this->request->getSession();
2284 if ( $key === null ) {
2285 $session->remove( 'authData' );
2286 } else {
2287 $arr = $session->getSecret( 'authData' );
2288 if ( is_array( $arr ) && array_key_exists( $key, $arr ) ) {
2289 unset( $arr[$key] );
2290 $session->setSecret( 'authData', $arr );
2291 }
2292 }
2293 }
2294
2295 /**
2296 * Create an array of AuthenticationProviders from an array of ObjectFactory specs
2297 * @param string $class
2298 * @param array[] $specs
2299 * @return AuthenticationProvider[]
2300 */
2301 protected function providerArrayFromSpecs( $class, array $specs ) {
2302 $i = 0;
2303 foreach ( $specs as &$spec ) {
2304 $spec = [ 'sort2' => $i++ ] + $spec + [ 'sort' => 0 ];
2305 }
2306 unset( $spec );
2307 // Sort according to the 'sort' field, and if they are equal, according to 'sort2'
2308 usort( $specs, function ( $a, $b ) {
2309 return $a['sort'] <=> $b['sort']
2310 ?: $a['sort2'] <=> $b['sort2'];
2311 } );
2312
2313 $ret = [];
2314 foreach ( $specs as $spec ) {
2315 $provider = ObjectFactory::getObjectFromSpec( $spec );
2316 if ( !$provider instanceof $class ) {
2317 throw new \RuntimeException(
2318 "Expected instance of $class, got " . get_class( $provider )
2319 );
2320 }
2321 $provider->setLogger( $this->logger );
2322 $provider->setManager( $this );
2323 $provider->setConfig( $this->config );
2324 $id = $provider->getUniqueId();
2325 if ( isset( $this->allAuthenticationProviders[$id] ) ) {
2326 throw new \RuntimeException(
2327 "Duplicate specifications for id $id (classes " .
2328 get_class( $provider ) . ' and ' .
2329 get_class( $this->allAuthenticationProviders[$id] ) . ')'
2330 );
2331 }
2332 $this->allAuthenticationProviders[$id] = $provider;
2333 $ret[$id] = $provider;
2334 }
2335 return $ret;
2336 }
2337
2338 /**
2339 * Get the configuration
2340 * @return array
2341 */
2342 private function getConfiguration() {
2343 return $this->config->get( 'AuthManagerConfig' ) ?: $this->config->get( 'AuthManagerAutoConfig' );
2344 }
2345
2346 /**
2347 * Get the list of PreAuthenticationProviders
2348 * @return PreAuthenticationProvider[]
2349 */
2350 protected function getPreAuthenticationProviders() {
2351 if ( $this->preAuthenticationProviders === null ) {
2352 $conf = $this->getConfiguration();
2353 $this->preAuthenticationProviders = $this->providerArrayFromSpecs(
2354 PreAuthenticationProvider::class, $conf['preauth']
2355 );
2356 }
2357 return $this->preAuthenticationProviders;
2358 }
2359
2360 /**
2361 * Get the list of PrimaryAuthenticationProviders
2362 * @return PrimaryAuthenticationProvider[]
2363 */
2364 protected function getPrimaryAuthenticationProviders() {
2365 if ( $this->primaryAuthenticationProviders === null ) {
2366 $conf = $this->getConfiguration();
2367 $this->primaryAuthenticationProviders = $this->providerArrayFromSpecs(
2368 PrimaryAuthenticationProvider::class, $conf['primaryauth']
2369 );
2370 }
2371 return $this->primaryAuthenticationProviders;
2372 }
2373
2374 /**
2375 * Get the list of SecondaryAuthenticationProviders
2376 * @return SecondaryAuthenticationProvider[]
2377 */
2378 protected function getSecondaryAuthenticationProviders() {
2379 if ( $this->secondaryAuthenticationProviders === null ) {
2380 $conf = $this->getConfiguration();
2381 $this->secondaryAuthenticationProviders = $this->providerArrayFromSpecs(
2382 SecondaryAuthenticationProvider::class, $conf['secondaryauth']
2383 );
2384 }
2385 return $this->secondaryAuthenticationProviders;
2386 }
2387
2388 /**
2389 * Log the user in
2390 * @param User $user
2391 * @param bool|null $remember
2392 */
2393 private function setSessionDataForUser( $user, $remember = null ) {
2394 $session = $this->request->getSession();
2395 $delay = $session->delaySave();
2396
2397 $session->resetId();
2398 $session->resetAllTokens();
2399 if ( $session->canSetUser() ) {
2400 $session->setUser( $user );
2401 }
2402 if ( $remember !== null ) {
2403 $session->setRememberUser( $remember );
2404 }
2405 $session->set( 'AuthManager:lastAuthId', $user->getId() );
2406 $session->set( 'AuthManager:lastAuthTimestamp', time() );
2407 $session->persist();
2408
2409 \Wikimedia\ScopedCallback::consume( $delay );
2410
2411 \Hooks::run( 'UserLoggedIn', [ $user ] );
2412 }
2413
2414 /**
2415 * @param User $user
2416 * @param bool $useContextLang Use 'uselang' to set the user's language
2417 */
2418 private function setDefaultUserOptions( User $user, $useContextLang ) {
2419 $user->setToken();
2420
2421 $contLang = MediaWikiServices::getInstance()->getContentLanguage();
2422
2423 $lang = $useContextLang ? \RequestContext::getMain()->getLanguage() : $contLang;
2424 $user->setOption( 'language', $lang->getPreferredVariant() );
2425
2426 if ( $contLang->hasVariants() ) {
2427 $user->setOption( 'variant', $contLang->getPreferredVariant() );
2428 }
2429 }
2430
2431 /**
2432 * @param int $which Bitmask: 1 = pre, 2 = primary, 4 = secondary
2433 * @param string $method
2434 * @param array $args
2435 */
2436 private function callMethodOnProviders( $which, $method, array $args ) {
2437 $providers = [];
2438 if ( $which & 1 ) {
2439 $providers += $this->getPreAuthenticationProviders();
2440 }
2441 if ( $which & 2 ) {
2442 $providers += $this->getPrimaryAuthenticationProviders();
2443 }
2444 if ( $which & 4 ) {
2445 $providers += $this->getSecondaryAuthenticationProviders();
2446 }
2447 foreach ( $providers as $provider ) {
2448 $provider->$method( ...$args );
2449 }
2450 }
2451
2452 /**
2453 * Reset the internal caching for unit testing
2454 * @protected Unit tests only
2455 */
2456 public static function resetCache() {
2457 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
2458 // @codeCoverageIgnoreStart
2459 throw new \MWException( __METHOD__ . ' may only be called from unit tests!' );
2460 // @codeCoverageIgnoreEnd
2461 }
2462
2463 self::$instance = null;
2464 }
2465
2466 /**@}*/
2467
2468 }
2469
2470 /**
2471 * For really cool vim folding this needs to be at the end:
2472 * vim: foldmarker=@{,@} foldmethod=marker
2473 */