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