Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 441 |
|
0.00% |
0 / 11 |
CRAP | |
0.00% |
0 / 1 |
SpecialCentralAutoLogin | |
0.00% |
0 / 441 |
|
0.00% |
0 / 11 |
12882 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
getInlineScript | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
checkSession | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
isUIReloadRecommended | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
execute | |
0.00% |
0 / 285 |
|
0.00% |
0 / 1 |
4692 | |||
do302Redirect | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
42 | |||
logFinished | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
90 | |||
doFinalOutput | |
0.00% |
0 / 45 |
|
0.00% |
0 / 1 |
156 | |||
checkIsCentralWiki | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
20 | |||
checkIsLocalWiki | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getCentralSession | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
20 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\CentralAuth\Special; |
4 | |
5 | use CentralAuthSessionProvider; |
6 | use Exception; |
7 | use MediaWiki\Context\RequestContext; |
8 | use MediaWiki\Extension\CentralAuth\CentralAuthHooks; |
9 | use MediaWiki\Extension\CentralAuth\CentralAuthSessionManager; |
10 | use MediaWiki\Extension\CentralAuth\CentralAuthTokenManager; |
11 | use MediaWiki\Extension\CentralAuth\CentralAuthUtilityService; |
12 | use MediaWiki\Extension\CentralAuth\Config\CAMainConfigNames; |
13 | use MediaWiki\Extension\CentralAuth\Hooks\CentralAuthHookRunner; |
14 | use MediaWiki\Extension\CentralAuth\Hooks\Handlers\PageDisplayHookHandler; |
15 | use MediaWiki\Extension\CentralAuth\Hooks\Handlers\SpecialPageBeforeExecuteHookHandler; |
16 | use MediaWiki\Extension\CentralAuth\User\CentralAuthUser; |
17 | use MediaWiki\HookContainer\HookContainer; |
18 | use MediaWiki\Html\Html; |
19 | use MediaWiki\Json\FormatJson; |
20 | use MediaWiki\Languages\LanguageFactory; |
21 | use MediaWiki\Logger\LoggerFactory; |
22 | use MediaWiki\MainConfigNames; |
23 | use MediaWiki\Registration\ExtensionRegistry; |
24 | use MediaWiki\ResourceLoader\ResourceLoader; |
25 | use MediaWiki\Session\Session; |
26 | use MediaWiki\SpecialPage\UnlistedSpecialPage; |
27 | use MediaWiki\Title\Title; |
28 | use MediaWiki\User\Options\UserOptionsLookup; |
29 | use MediaWiki\User\User; |
30 | use MediaWiki\User\UserFactory; |
31 | use MediaWiki\WikiMap\WikiMap; |
32 | use MobileContext; |
33 | use Psr\Log\LoggerInterface; |
34 | use RuntimeException; |
35 | use SkinTemplate; |
36 | use 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 | */ |
59 | class 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 | } |