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