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