Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 438
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialCentralAutoLogin
0.00% covered (danger)
0.00%
0 / 438
0.00% covered (danger)
0.00%
0 / 10
12882
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getInlineScript
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 checkSession
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 execute
0.00% covered (danger)
0.00%
0 / 308
0.00% covered (danger)
0.00%
0 / 1
5550
 do302Redirect
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 logFinished
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
56
 doFinalOutput
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
156
 checkIsCentralWiki
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 checkIsLocalWiki
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getCentralSession
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\Extension\CentralAuth\Special;
4
5use CentralAuthSessionProvider;
6use Exception;
7use ExtensionRegistry;
8use FormatJson;
9use MediaWiki\Deferred\DeferredUpdates;
10use MediaWiki\Extension\CentralAuth\CentralAuthHooks;
11use MediaWiki\Extension\CentralAuth\CentralAuthSessionManager;
12use MediaWiki\Extension\CentralAuth\CentralAuthUtilityService;
13use MediaWiki\Extension\CentralAuth\Hooks\Handlers\PageDisplayHookHandler;
14use MediaWiki\Extension\CentralAuth\Hooks\Handlers\SpecialPageBeforeExecuteHookHandler;
15use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
16use MediaWiki\Html\Html;
17use MediaWiki\Languages\LanguageFactory;
18use MediaWiki\Logger\LoggerFactory;
19use MediaWiki\ResourceLoader\ResourceLoader;
20use MediaWiki\Session\Session;
21use MediaWiki\SpecialPage\UnlistedSpecialPage;
22use MediaWiki\Title\Title;
23use MediaWiki\User\Options\UserOptionsManager;
24use MediaWiki\User\User;
25use MediaWiki\User\UserIdentity;
26use MediaWiki\WikiMap\WikiMap;
27use MobileContext;
28use MWCryptRand;
29use Psr\Log\LoggerInterface;
30use RequestContext;
31use RuntimeException;
32use SkinTemplate;
33use Wikimedia\Rdbms\ReadOnlyMode;
34use Wikimedia\ScopedCallback;
35use Xml;
36
37/**
38 * Unlisted special page that handles central autologin and edge login, and some related
39 * functionality.
40 *
41 * It does different things depending on the subpage name:
42 * - /start, /checkLoggedIn, /createSession, /validateSession, /setCookies: these are successive
43 *   steps of autologin/edge login, with each step calling the next one via a redirect.
44 *   (/start is somewhat optional.) The 'type' get parameter tells how the chain was triggered
45 *   (via a script tag, a visible or invisible img tag, or top-level redirect); the final step
46 *   needs to generate a response accordingly.
47 * - /refreshCookies: used right after login to update the central session cookies.
48 * - /deleteCookies: used after logout.
49 * - /toolslist: a helper used after successful autologin to update the skin's personal toolbar.
50 * See the inline comments in the big switch() construct for the description of each.
51 *
52 * @see CentralAuthHooks::getEdgeLoginHTML()
53 * @see PageDisplayHookHandler::onBeforePageDisplay()
54 * @see SpecialPageBeforeExecuteHookHandler::onSpecialPageBeforeExecute()
55 * @see SpecialCentralLogin
56 * @see https://www.mediawiki.org/wiki/Extension:CentralAuth/authentication
57 */
58class SpecialCentralAutoLogin extends UnlistedSpecialPage {
59    /** @var string */
60    private $loginWiki;
61
62    /** @var Session|null */
63    protected $session = null;
64
65    private ExtensionRegistry $extensionRegistry;
66
67    /** @var LanguageFactory */
68    private $languageFactory;
69
70    /** @var ReadOnlyMode */
71    private $readOnlyMode;
72
73    /** @var UserOptionsManager */
74    private $userOptionsManager;
75
76    /** @var CentralAuthSessionManager */
77    private $sessionManager;
78
79    /** @var CentralAuthUtilityService */
80    private $centralAuthUtilityService;
81
82    /** @var LoggerInterface */
83    private $logger;
84
85    private string $subpage;
86
87    /**
88     * @param LanguageFactory $languageFactory
89     * @param ReadOnlyMode $readOnlyMode
90     * @param UserOptionsManager $userOptionsManager
91     * @param CentralAuthSessionManager $sessionManager
92     * @param CentralAuthUtilityService $centralAuthUtilityService
93     */
94    public function __construct(
95        LanguageFactory $languageFactory,
96        ReadOnlyMode $readOnlyMode,
97        UserOptionsManager $userOptionsManager,
98        CentralAuthSessionManager $sessionManager,
99        CentralAuthUtilityService $centralAuthUtilityService
100    ) {
101        parent::__construct( 'CentralAutoLogin' );
102
103        $this->extensionRegistry = ExtensionRegistry::getInstance();
104        $this->languageFactory = $languageFactory;
105        $this->readOnlyMode = $readOnlyMode;
106        $this->userOptionsManager = $userOptionsManager;
107        $this->sessionManager = $sessionManager;
108        $this->centralAuthUtilityService = $centralAuthUtilityService;
109        $this->logger = LoggerFactory::getInstance( 'CentralAuth' );
110    }
111
112    /**
113     * Get contents of a javascript file for inline use.
114     *
115     * @param string $name Path to file relative to /modules/inline/
116     * @return string Minified JavaScript code
117     * @throws Exception If file doesn't exist
118     */
119    protected static function getInlineScript( $name ) {
120        $filePath = __DIR__ . '/../../modules/inline/' . $name;
121        if ( !file_exists( $filePath ) ) {
122            throw new RuntimeException( __METHOD__ . ": file not found: \"$filePath\"" );
123        }
124        $rawScript = file_get_contents( $filePath );
125
126        // Hot path, static content, must use a cache
127        return ResourceLoader::filter( 'minify-js', $rawScript, [ 'cache' => true ] );
128    }
129
130    /**
131     * Check the session (if applicable) and fill in $this->session
132     * @param string $body
133     * @param string|bool $type
134     * @return bool
135     */
136    protected function checkSession( $body = '', $type = false ) {
137        $session = $this->getRequest()->getSession();
138        if ( !$session->getProvider() instanceof CentralAuthSessionProvider ) {
139            $this->doFinalOutput(
140                false,
141                'Cannot operate when using ' .
142                    $session->getProvider()->describe( $this->languageFactory->getLanguage( 'en' ) ),
143                $body,
144                $type
145            );
146            return false;
147        }
148        $this->session = $session;
149        return true;
150    }
151
152    /**
153     * @param string|null $par
154     */
155    public function execute( $par ) {
156        if (
157            in_array( $par, [ 'toolslist', 'refreshCookies', 'deleteCookies', 'start', 'checkLoggedIn',
158                'createSession', 'validateSession', 'setCookies' ], true )
159        ) {
160            $this->logger->debug( 'CentralAutoLogin step {step}', [
161                'step' => $par,
162            ] );
163        }
164
165        $this->subpage = $par ?? '';
166
167        $request = $this->getRequest();
168        $tokenStore = $this->sessionManager->getTokenStore();
169
170        $this->loginWiki = $this->getConfig()->get( 'CentralAuthLoginWiki' );
171        if ( !$this->loginWiki ) {
172            // Ugh, no central wiki. If we're coming from an edge login, make
173            // the logged-into wiki the de-facto central wiki for this request
174            // so auto-login still works.
175            $fromwiki = $request->getVal( 'from' );
176            if ( $fromwiki !== null && WikiMap::getWiki( $fromwiki ) ) {
177                $this->loginWiki = $fromwiki;
178            }
179        }
180
181        $params = $request->getValues(
182            // The method by which autologin is initiated: 'script' (as the src of a <script> tag),
183            // 'json' (via AJAX), '1x1' (as the src of an invisible pixel), 'icon' (as the src of
184            // a site logo icon), 'redirect' (top-level redirect). Determines how the final response
185            // is formatted, in some cases might affect the logic in other ways as well.
186            'type',
187            // The wiki that started the autologin process. Not necessarily the wiki where the
188            // user is supposed to be logged in, because of edge autologin. Probably vestigial.
189            'from',
190            // Token for the final return URL for type=redirect
191            'returnUrlToken',
192            // When 'return' is set, at the end of autologin the user will be redirected based on
193            // returnto/returntoquery (like for normal login). Used for autologin triggered on the
194            // login page.
195            'return',
196            'returnto',
197            'returntoquery',
198            // Whether the request that initiated autologin was to the mobile domain.
199            'mobile',
200            // also used:
201            // 'wikiid': The wiki where the user is being auto-logged in. (used in checkIsCentralWiki)
202            // 'token': Random store key, used to pass information in a secure manner.
203        );
204        // phpcs:disable PSR2.ControlStructures.SwitchDeclaration.BreakIndent
205        switch ( strval( $par ) ) {
206            // Extra steps, not part of the login process
207            case 'toolslist':
208                // Return the contents of the user menu so autologin.js can sort-of refresh the skin
209                // without reloading the page. This results in lots of inconsistencies and brokenness,
210                // but at least the user sees they are logged in.
211                // Runs on the local wiki.
212
213                // Do not cache this, we want updated Echo numbers and such.
214                $this->getOutput()->disableClientCache();
215
216                if ( !$this->checkSession( '', 'json' ) ) {
217                    return;
218                }
219
220                $user = $this->getUser();
221                if ( $user->isRegistered() ) {
222                    $skin = $this->getSkin();
223                    if (
224                        !CentralAuthHooks::isUIReloadRecommended( $user ) &&
225                        $skin instanceof SkinTemplate
226                    ) {
227                        $html = $skin->makePersonalToolsList();
228                        $json = FormatJson::encode( [ 'toolslist' => $html ] );
229                    } else {
230                        $gender = $this->userOptionsManager->getOption( $this->getUser(), 'gender' );
231                        if ( strval( $gender ) === '' ) {
232                            $gender = 'unknown';
233                        }
234                        $json = FormatJson::encode( [
235                            'notify' => [
236                                'username' => $user->getName(),
237                                'gender' => $gender
238                            ]
239                        ] );
240                    }
241                    $this->doFinalOutput( true, 'OK', $json, 'json' );
242                } else {
243                    $this->doFinalOutput( false, 'Not logged in', '', 'json' );
244                }
245                return;
246
247            case 'refreshCookies':
248                // Refresh cookies on the central login wiki at the end of a successful login,
249                // to fill in information that could not be set when those cookies where created
250                // (e.g. the 'remember me' token).
251                // Runs on the central login wiki.
252
253                // Do not cache this, we need to reset the cookies every time.
254                $this->getOutput()->disableClientCache();
255
256                if ( !$this->loginWiki ) {
257                    $this->logger->debug( "refreshCookies: no login wiki" );
258                    return;
259                }
260                if ( !$this->checkIsCentralWiki( $wikiid ) ) {
261                    return;
262                }
263                if ( !$this->checkSession() ) {
264                    return;
265                }
266
267                $centralUser = CentralAuthUser::getInstance( $this->getUser() );
268                if ( $centralUser && $centralUser->getId() && $centralUser->isAttached() ) {
269                    $centralSession = $this->getCentralSession( $centralUser, $this->getUser() );
270
271                    // Refresh 'remember me' preference
272                    $user = $this->getUser();
273                    $remember = (bool)$centralSession['remember'];
274                    if ( $user->isNamed() &&
275                        $remember !== $this->userOptionsManager->getBoolOption( $user, 'rememberpassword' ) ) {
276                        $this->userOptionsManager->setOption( $user, 'rememberpassword', $remember ? 1 : 0 );
277                        DeferredUpdates::addCallableUpdate( function () use ( $user ) {
278                            if ( $this->readOnlyMode->isReadOnly() ) {
279                                // not possible to save
280                                return;
281                            }
282                            $this->userOptionsManager->saveOptions( $user );
283                        } );
284                    }
285
286                    $delay = $this->session->delaySave();
287                    $this->session->setRememberUser( $remember );
288                    $this->session->persist();
289                    ScopedCallback::consume( $delay );
290                    $this->doFinalOutput( true, 'success' );
291                } else {
292                    $this->doFinalOutput( false, 'Not logged in' );
293                }
294                return;
295
296            case 'deleteCookies':
297                // Delete CentralAuth-specific cookies on logout. (This is just cleanup, the backend
298                // session will be invalidated regardless of whether this succeeds.)
299                // Runs on the central login wiki and the edge wikis.
300
301                // Do not cache this, we need to reset the cookies every time.
302                $this->getOutput()->disableClientCache();
303
304                if ( !$this->checkSession() ) {
305                    return;
306                }
307
308                if ( $this->getUser()->isRegistered() ) {
309                    $this->doFinalOutput( false, 'Cannot delete cookies while still logged in' );
310                    return;
311                }
312
313                $this->session->setUser( new User );
314                $this->session->persist();
315                $this->doFinalOutput( true, 'success' );
316                return;
317
318            // Login process
319            case 'start':
320                // Entry point for edge autologin: this is called on various wikis via an <img> URL
321                // to preemptively log the user in on that wiki, so their session exists by the time
322                // they first visit.  Sometimes it is also used as the entry point for local autologin;
323                // there probably isn't much point in that (it's an extra redirect). This endpoint
324                // doesn't do much, just redirects to /checkLoggedIn on the central login wiki.
325                // Runs on the local wiki and the edge wikis.
326
327                // Note this is safe to cache, because the cache already varies on
328                // the session cookies.
329                $this->getOutput()->setCdnMaxage( 1200 );
330
331                if ( !$this->checkIsLocalWiki() ) {
332                    return;
333                }
334                if ( !$this->checkSession() ) {
335                    return;
336                }
337
338                $this->do302Redirect( $this->loginWiki, 'checkLoggedIn', [
339                    'wikiid' => WikiMap::getCurrentWikiId(),
340                ] + $params );
341                return;
342
343            case 'checkLoggedIn':
344                // Sometimes entry point for autologin, sometimes second step after /start.
345                // Runs on the central login wiki. Checks that the user has a valid session there,
346                // then redirects back to /createSession on the original wiki and passes the user ID
347                // (in the form of a lookup key for the shared token store, to prevent a malicious
348                // website from learning it).
349                // FIXME the indirection of user ID is supposedly for T59081, but unclear how that's
350                //   supposed to be a threat when the redirect URL has been validated via WikiMap.
351                // Runs on the central login wiki.
352
353                // Note this is safe to cache, because the cache already varies on
354                // the session cookies.
355                $this->getOutput()->setCdnMaxage( 1200 );
356
357                if ( !$this->checkIsCentralWiki( $wikiid ) ) {
358                    return;
359                }
360                if ( !$this->checkSession() ) {
361                    return;
362                }
363
364                if ( $this->getUser()->isRegistered() ) {
365                    $centralUser = CentralAuthUser::getInstance( $this->getUser() );
366                } else {
367                    $this->doFinalOutput( false, 'Not centrally logged in',
368                        self::getInlineScript( 'anon-set.js' ) );
369                    return;
370                }
371
372                // We're pretty sure this user is logged in, so pass back
373                // headers to prevent caching, just in case
374                $this->getOutput()->disableClientCache();
375
376                // Check if the loginwiki account isn't attached, things are broken (T137551)
377                if ( !$centralUser->isAttached() ) {
378                    $this->doFinalOutput( false,
379                        'Account on central wiki is not attached (this shouldn\'t happen)',
380                        self::getInlineScript( 'anon-set.js' )
381                    );
382                    return;
383                }
384
385                $memcData = [ 'gu_id' => $centralUser->getId() ];
386                $token = MWCryptRand::generateHex( 32 );
387                $key = $this->sessionManager->makeTokenKey( 'centralautologin-token', $token );
388                $tokenStore->set( $key, $memcData, $tokenStore::TTL_MINUTE );
389
390                $this->do302Redirect( $wikiid, 'createSession', [
391                    'token' => $token,
392                ] + $params );
393                return;
394
395            case 'createSession':
396                // Creates an unvalidated local session, and redirects back to the central login wiki
397                // to validate it.
398                // At this point we received the user ID from /checkLoggedIn but must ensure this is
399                // not a session fixation attack, so we set a session cookie for an anonymous session,
400                // set a random proof token in that session, stash the user's supposed identity
401                // under that token in the shared store, and pass the token back to the /validateSession
402                // endpoint of the central login wiki.
403                // Runs on the wiki where the autologin needs to log the user in (the local wiki,
404                // or the edge wikis, or both).
405
406                if ( !$this->checkIsLocalWiki() ) {
407                    return;
408                }
409                if ( !$this->checkSession() ) {
410                    return;
411                }
412
413                $token = $request->getVal( 'token', '' );
414                if ( $token !== '' ) {
415                    // Load memc data
416                    $key = $this->sessionManager->makeTokenKey( 'centralautologin-token', $token );
417                    $memcData = $this->centralAuthUtilityService->getKeyValueUponExistence(
418                        $this->sessionManager->getTokenStore(), $key
419                    );
420
421                    $tokenStore->delete( $key );
422
423                    if ( !$memcData || !isset( $memcData['gu_id'] ) ) {
424                        $this->doFinalOutput( false, 'Invalid parameters' );
425                        return;
426                    }
427                    $gu_id = intval( $memcData['gu_id'] );
428                } else {
429                    $this->doFinalOutput( false, 'Invalid parameters' );
430                    return;
431                }
432
433                if ( $gu_id <= 0 ) {
434                    $this->doFinalOutput( false, 'Not centrally logged in',
435                        self::getInlineScript( 'anon-set.js' ) );
436                    return;
437                }
438
439                // At this point we can't cache anymore because we need to set
440                // cookies and memc each time.
441                $this->getOutput()->disableClientCache();
442
443                // Ensure that a session exists
444                $this->session->persist();
445
446                // Create memc token
447                $wikiid = WikiMap::getCurrentWikiId();
448                $memcData = [
449                    'gu_id' => $gu_id,
450                    'wikiid' => $wikiid,
451                ];
452                $token = MWCryptRand::generateHex( 32 );
453                $key = $this->sessionManager->makeTokenKey( 'centralautologin-token', $token, $wikiid );
454                $tokenStore->set( $key, $memcData, $tokenStore::TTL_MINUTE );
455
456                // Save memc token for the 'setCookies' step
457                $request->setSessionData( 'centralautologin-token', $token );
458
459                $this->do302Redirect( $this->loginWiki, 'validateSession', [
460                    'token' => $token,
461                    'wikiid' => $wikiid,
462                ] + $params );
463                return;
464
465            case 'validateSession':
466                // Validates the session created by /createSession by looking up the user ID in the
467                // shared store and comparing it to the actual user ID. Puts all extra information
468                // needed to create a logged-in session ("remember me" flag, ID of the central
469                // session etc.) under the same entry in the shared store that /createSession initiated.
470                // Runs on the central login wiki.
471
472                // Do not cache this, we need to reset the cookies and memc every time.
473                $this->getOutput()->disableClientCache();
474
475                if ( !$this->checkIsCentralWiki( $wikiid ) ) {
476                    return;
477                }
478                if ( !$this->checkSession() ) {
479                    return;
480                }
481
482                if ( !$this->getUser()->isRegistered() ) {
483                    $this->doFinalOutput( false, 'Not logged in' );
484                    return;
485                }
486
487                // Validate params
488                $token = $request->getVal( 'token', '' );
489                if ( $token === '' ) {
490                    $this->doFinalOutput( false, 'Invalid parameters' );
491                    return;
492                }
493
494                // Load memc data
495                $key = $this->sessionManager->makeTokenKey( 'centralautologin-token', $token, $wikiid );
496                $memcData = $this->centralAuthUtilityService->getKeyValueUponExistence(
497                    $this->sessionManager->getTokenStore(), $key
498                );
499
500                $tokenStore->delete( $key );
501
502                // Check memc data
503                $centralUser = CentralAuthUser::getInstance( $this->getUser() );
504                if ( !$memcData ||
505                    $memcData['wikiid'] !== $wikiid ||
506                    !$centralUser ||
507                    !$centralUser->getId() ||
508                    !$centralUser->isAttached() ||
509                    $memcData['gu_id'] != $centralUser->getId()
510                ) {
511                    $this->doFinalOutput( false, 'Invalid parameters' );
512                    return;
513                }
514
515                // Write info for session creation into memc
516                $centralSession = $this->getCentralSession( $centralUser, $this->getUser() );
517                $memcData += [
518                    'userName' => $centralUser->getName(),
519                    'token' => $centralUser->getAuthToken(),
520                    'remember' => $centralSession['remember'],
521                    'sessionId' => $centralSession['sessionId'],
522                ];
523
524                $key = $this->sessionManager->makeTokenKey( 'centralautologin-token', $token, $wikiid );
525                $tokenStore->set( $key, $memcData, $tokenStore::TTL_MINUTE );
526
527                $this->do302Redirect( $wikiid, 'setCookies', $params );
528                return;
529
530            case 'setCookies':
531                // Final step of the autologin sequence, replaces the unvalidated session with a real
532                // logged-in session. Also schedules an edge login for the next pageview, and for
533                // type=script autocreates the user so that mw.messages notices can be shown in the
534                // user language.
535                // If all went well, the data about the user's session on the central login wiki will
536                // be in the shared store, under a random key that's stored in the temporary,
537                // anonymous local session. The access to that key proves that this is the same device
538                // that visited /createSession before with a preliminary central user ID, and the
539                // fact that /validateSession updated the store data proves that the preliminary ID was
540                // in fact correct.
541                // Runs on the wiki where the autologin needs to log the user in (the local wiki,
542                // or the edge wikis, or both).
543
544                // Do not cache this, we need to reset the cookies and memc every time.
545                $this->getOutput()->disableClientCache();
546
547                if ( !$this->checkIsLocalWiki() ) {
548                    return;
549                }
550                if ( !$this->checkSession() ) {
551                    return;
552                }
553
554                // Check saved memc token
555                $token = $this->getRequest()->getSessionData( 'centralautologin-token' );
556                if ( $token === null ) {
557                    $this->doFinalOutput( false, 'Lost session' );
558                    return;
559                }
560
561                // Load memc data
562                $wikiid = WikiMap::getCurrentWikiId();
563                $key = $this->sessionManager->makeTokenKey( 'centralautologin-token', $token, $wikiid );
564                $memcData = $this->centralAuthUtilityService->getKeyValueUponExistence(
565                    $this->sessionManager->getTokenStore(), $key
566                );
567
568                $tokenStore->delete( $key );
569
570                // Check memc data
571                if (
572                    !is_array( $memcData ) ||
573                    $memcData['wikiid'] !== $wikiid ||
574                    !isset( $memcData['userName'] ) ||
575                    !isset( $memcData['token'] )
576                ) {
577                    $this->doFinalOutput( false, 'Lost session' );
578                    return;
579                }
580
581                // Load and check CentralAuthUser. But don't check if it's
582                // attached, because then if the user is missing en.site they
583                // won't be auto logged in to any of the non-en versions either.
584                $centralUser = CentralAuthUser::getInstanceByName( $memcData['userName'] );
585                if ( !$centralUser->getId() || $centralUser->getId() != $memcData['gu_id'] ) {
586                    $msg = "Wrong user: expected {$memcData['gu_id']}, got {$centralUser->getId()}";
587                    $this->logger->warning( __METHOD__ . "$msg" );
588                    $this->doFinalOutput( false, 'Lost session' );
589                    return;
590                }
591                $loginResult = $centralUser->authenticateWithToken( $memcData['token'] );
592                if ( $loginResult != 'ok' ) {
593                    $msg = "Bad token: $loginResult";
594                    $this->logger->warning( __METHOD__ . "$msg" );
595                    $this->doFinalOutput( false, 'Lost session' );
596                    return;
597                }
598                $localUser = User::newFromName( $centralUser->getName(), 'usable' );
599                if ( !$localUser ) {
600                    $this->doFinalOutput( false, 'Invalid username' );
601                    return;
602                }
603                if ( $localUser->isRegistered() && !$centralUser->isAttached() ) {
604                    $this->doFinalOutput( false, 'Local user exists but is not attached' );
605                    return;
606                }
607
608                /** @var ScopedCallback|null $delay */
609                $delay = null;
610
611                $delay = $this->session->delaySave();
612                $this->session->resetId();
613                // FIXME what is the purpose of this (beyond storing the central session ID in the
614                //   local session, which we could do directly)? We have just read this data from
615                //   the central session one redirect hop ago.
616                $this->sessionManager->setCentralSession( [
617                    'remember' => $memcData['remember'],
618                ], $memcData['sessionId'], $this->session );
619                if ( $centralUser->isAttached() ) {
620                    // Set the user on the session, if the user is already attached.
621                    $this->session->setUser( User::newFromName( $centralUser->getName() ) );
622                }
623                $this->session->setRememberUser( $memcData['remember'] );
624                $this->session->persist();
625
626                // Now, figure out how to report this back to the user.
627
628                // First, set to redo the edge login on the next pageview
629                $this->logger->debug( 'Edge login on the next pageview after CentralAutoLogin' );
630                $request->setSessionData( 'CentralAuthDoEdgeLogin', true );
631
632                // If it's not a script or redirect callback, just go for it.
633                if ( !in_array( $request->getVal( 'type' ), [ 'script', 'redirect' ], true ) ) {
634                    ScopedCallback::consume( $delay );
635                    $this->doFinalOutput( true, 'success' );
636                    return;
637                }
638
639                // If it is a script or redirect callback, then we do want to create the user
640                // if it doesn't already exist locally (and fail if that can't be done).
641                if ( !$localUser->isRegistered() ) {
642                    $localUser = new User;
643                    $localUser->setName( $centralUser->getName() );
644                    if ( $this->centralAuthUtilityService->autoCreateUser( $localUser )->isGood() ) {
645                        $centralUser->invalidateCache();
646                        $centralUser = CentralAuthUser::getPrimaryInstanceByName( $centralUser->getName() );
647                    }
648                }
649                if ( !$centralUser->isAttached() ) {
650                    ScopedCallback::consume( $delay );
651                    $this->doFinalOutput(
652                        false, 'Local user is not attached', self::getInlineScript( 'anon-set.js' ) );
653                    return;
654                }
655                // Set the user on the session now that we know it exists.
656                $this->session->setUser( $localUser );
657                ScopedCallback::consume( $delay );
658
659                $script = self::getInlineScript( 'anon-remove.js' );
660
661                // If we're returning to returnto, do that
662                if ( $request->getCheck( 'return' ) ) {
663                    if ( $this->getConfig()->get( 'RedirectOnLogin' ) !== null ) {
664                        $returnTo = $this->getConfig()->get( 'RedirectOnLogin' );
665                        $returnToQuery = [];
666                    } else {
667                        $returnTo = $request->getVal( 'returnto', '' );
668                        $returnToQuery = wfCgiToArray( $request->getVal( 'returntoquery', '' ) );
669                    }
670
671                    $returnToTitle = Title::newFromText( $returnTo );
672                    if ( !$returnToTitle ) {
673                        $returnToTitle = Title::newMainPage();
674                        $returnToQuery = [];
675                    }
676
677                    $redirectUrl = $returnToTitle->getFullUrlForRedirect( $returnToQuery );
678
679                    $script .= "\n" . 'location.href = ' . Xml::encodeJsVar( $redirectUrl ) . ';';
680
681                    $this->doFinalOutput( true, 'success', $script );
682                    return;
683                }
684
685                // Otherwise, we need to rewrite p-personal and maybe notify the user too
686                // Add a script to the page that will pull in the user's toolslist
687                // via ajax, and update the UI. Don't write out the tools here (T59081).
688                $code = $this->userOptionsManager->getOption( $localUser, 'language' );
689                $code = RequestContext::sanitizeLangCode( $code );
690
691                $this->getHookRunner()->onUserGetLanguageObject( $localUser, $code, $this->getContext() );
692
693                $script .= "\n" . Xml::encodeJsCall( 'mw.messages.set', [
694                    [
695                        'centralauth-centralautologin-logged-in' =>
696                            $this->msg( 'centralauth-centralautologin-logged-in' )
697                                ->inLanguage( $code )->plain(),
698
699                        'centralautologin' =>
700                            $this->msg( 'centralautologin' )
701                                ->inLanguage( $code )->plain(),
702                    ]
703                ] );
704
705                $script .= "\n" . self::getInlineScript( 'autologin.js' );
706
707                // And for good measure, add the edge login HTML images to the page.
708                $this->logger->debug( 'Edge login triggered in CentralAutoLogin' );
709                // @phan-suppress-next-line SecurityCheck-DoubleEscaped
710                $script .= "\n" . Xml::encodeJsCall( "jQuery( 'body' ).append", [
711                    CentralAuthHooks::getEdgeLoginHTML()
712                ] );
713
714                $this->doFinalOutput( true, 'success', $script );
715                return;
716
717            default:
718                $this->setHeaders();
719                $this->getOutput()->addWikiMsg( 'centralauth-centralautologin-desc' );
720        }
721    }
722
723    /**
724     * @param string $target
725     * @param string $state
726     * @param array $params
727     */
728    private function do302Redirect( $target, $state, $params ) {
729        $url = WikiMap::getForeignURL( $target, "Special:CentralAutoLogin/$state" );
730        if ( WikiMap::getCurrentWikiId() == $this->loginWiki
731            && $this->extensionRegistry->isLoaded( 'MobileFrontend' )
732            && isset( $params['mobile'] )
733            && $params['mobile']
734        ) {
735            $url = MobileContext::singleton()->getMobileUrl( $url );
736        }
737
738        if ( $url === false ) {
739            $this->doFinalOutput( false, 'Invalid target wiki' );
740        } else {
741            // expands to PROTO_CURRENT
742            $this->getOutput()->redirect(
743                wfAppendQuery( $url, $params )
744            );
745        }
746    }
747
748    /**
749     * @param bool $ok
750     * @param string $status
751     * @param string $type
752     */
753    private function logFinished( $ok, $status, $type ): void {
754        switch ( $this->subpage ) {
755            // Extra steps, not part of the login process
756            case 'toolslist':
757            case 'refreshCookies':
758            case 'deleteCookies':
759                $this->logger->debug( "{$this->subpage} attempt", [
760                    'successful' => $ok,
761                    'status' => $status,
762                ] );
763                break;
764
765            // Login process
766            default:
767                if ( !in_array( $type, [ 'icon', '1x1', 'redirect', 'script', 'error' ] ) ) {
768                    // The type is included in metric names, so don't allow weird user-controlled values
769                    $type = 'unknown';
770                }
771
772                // Distinguish edge logins and autologins. Conveniently, all edge logins
773                // (and only edge logins) set the otherwise mostly vestigial 'from' parameter,
774                // and it's passed through all steps.
775                if ( $this->getRequest()->getCheck( 'from' ) ) {
776                    LoggerFactory::getInstance( 'authevents' )->info( 'Edge login attempt', [
777                        'event' => 'edgelogin',
778                        'successful' => $ok,
779                        'status' => $status,
780                        // Log this so that we can differentiate between:
781                        // - success page edge login (type=icon) [no longer used]
782                        // - next pageview edge login (type=1x1)
783                        'type' => $type,
784                        'extension' => 'CentralAuth',
785                    ] );
786                } else {
787                    LoggerFactory::getInstance( 'authevents' )->info( 'Central autologin attempt', [
788                        'event' => 'centralautologin',
789                        'successful' => $ok,
790                        'status' => $status,
791                        // Log this so that we can differentiate between:
792                        // - top-level autologin (type=redirect)
793                        // - JS subresource autologin (type=script)
794                        // - no-JS subresource autologin (type=1x1) (likely rarely successful - check this)
795                        'type' => $type,
796                        'extension' => 'CentralAuth',
797                    ] );
798                }
799                break;
800        }
801    }
802
803    /**
804     * @param bool $ok
805     * @param string $status
806     * @param string $body
807     * @param string $type
808     */
809    private function doFinalOutput( $ok, $status, $body = '', $type = '' ) {
810        $type = $type ?: $this->getRequest()->getVal( 'type', 'script' );
811        '@phan-var string $type';
812
813        if ( $type === 'redirect' ) {
814            $returnUrlToken = $this->getRequest()->getVal( 'returnUrlToken', '' );
815            $returnUrl = $this->centralAuthUtilityService->detokenize( $returnUrlToken,
816                'centralautologin-returnurl', $this->sessionManager );
817            if ( $returnUrl === false ) {
818                $type = 'error';
819                $status = 'invalid returnUrlToken';
820            }
821        }
822
823        $this->logFinished( $ok, $status, $type );
824
825        $this->getOutput()->disable();
826        wfResetOutputBuffers();
827        $this->getOutput()->sendCacheControl();
828
829        if ( $type === 'icon' || $type === '1x1' ) {
830            header( 'Content-Type: image/png' );
831            header( "X-CentralAuth-Status: $status" );
832
833            if ( $ok && $this->getConfig()->get( 'CentralAuthLoginIcon' ) && $type === 'icon' ) {
834                readfile( $this->getConfig()->get( 'CentralAuthLoginIcon' ) );
835            } else {
836                readfile( __DIR__ . '/../../images/1x1.png' );
837            }
838        } elseif ( $type === 'json' ) {
839            header( 'Content-Type: application/json; charset=utf-8' );
840            header( "X-CentralAuth-Status: $status" );
841            echo $body;
842        } elseif ( $type === 'redirect' ) {
843            $this->getRequest()->response()->statusHeader( 302 );
844            header( 'Content-Type: text/html; charset=UTF-8' );
845            header( "X-CentralAuth-Status: $status" );
846            // $returnUrl is always a string when $type==='redirect' but Phan can't figure it out
847            '@phan-var string $returnUrl';
848            $returnUrl = wfAppendQuery( $returnUrl, [
849                SpecialPageBeforeExecuteHookHandler::AUTOLOGIN_ERROR_QUERY_PARAM => $status,
850            ] );
851            header( "Location: $returnUrl" );
852        } elseif ( $type === 'error' ) {
853            // type=redirect but the redirect URL is invalid. Just display the error message.
854            // This is poor UX (we might not even be on the wiki where the user started) but
855            // shouldn't happen unless the request was tampered with.
856            $this->getRequest()->response()->statusHeader( 400 );
857            header( 'Content-Type: text/html; charset=UTF-8' );
858            header( "X-CentralAuth-Status: $status" );
859            echo Html::element( 'p', [], $status );
860            echo Html::rawElement( 'p', [],
861                Html::element( 'a',
862                    [ 'href' => 'javascript:window.history.back()' ],
863                    $this->msg( 'centralauth-completelogin-back' )->text()
864                )
865            );
866        } else {
867            header( 'Content-Type: text/javascript; charset=utf-8' );
868            echo "/* $status */\n$body";
869        }
870    }
871
872    /**
873     * @param string &$wikiId
874     *
875     * @return bool
876     */
877    private function checkIsCentralWiki( &$wikiId ) {
878        if ( WikiMap::getCurrentWikiId() !== $this->loginWiki ) {
879            $this->doFinalOutput( false, 'Not central wiki' );
880            return false;
881        }
882
883        $wikiId = $this->getRequest()->getVal( 'wikiid' );
884        if ( $wikiId === $this->loginWiki ) {
885            $this->doFinalOutput( false, 'Specified local wiki is the central wiki' );
886            return false;
887        }
888        $wiki = WikiMap::getWiki( $wikiId );
889        if ( !$wiki ) {
890            $this->doFinalOutput( false, 'Specified local wiki not found' );
891            return false;
892        }
893
894        return true;
895    }
896
897    private function checkIsLocalWiki() {
898        if ( WikiMap::getCurrentWikiId() === $this->loginWiki ) {
899            $this->doFinalOutput( false, 'Is central wiki, should be local' );
900            return false;
901        }
902
903        return true;
904    }
905
906    /**
907     * @param CentralAuthUser $centralUser
908     * @param UserIdentity $user
909     * @return array
910     */
911    private function getCentralSession( $centralUser, $user ) {
912        $centralSession = $this->sessionManager->getCentralSession( $this->session );
913        $request = $this->getRequest();
914
915        // If there's no "remember", pull from the user preference.
916        if ( !isset( $centralSession['remember'] ) ) {
917            $centralSession['remember'] = $this->userOptionsManager->getBoolOption( $user, 'rememberpassword' );
918        }
919
920        // Make sure there's a session id by creating a session if necessary.
921        if ( !isset( $centralSession['sessionId'] ) ) {
922            $centralSession['sessionId'] = $this->sessionManager->setCentralSession(
923                $centralSession, false, $this->session );
924        }
925
926        return $centralSession;
927    }
928}