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