Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 438 |
|
0.00% |
0 / 10 |
CRAP | |
0.00% |
0 / 1 |
SpecialCentralAutoLogin | |
0.00% |
0 / 438 |
|
0.00% |
0 / 10 |
12882 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 8 |
|
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 | |||
execute | |
0.00% |
0 / 308 |
|
0.00% |
0 / 1 |
5550 | |||
do302Redirect | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
42 | |||
logFinished | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
56 | |||
doFinalOutput | |
0.00% |
0 / 43 |
|
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 / 8 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\CentralAuth\Special; |
4 | |
5 | use CentralAuthSessionProvider; |
6 | use Exception; |
7 | use ExtensionRegistry; |
8 | use FormatJson; |
9 | use MediaWiki\Deferred\DeferredUpdates; |
10 | use MediaWiki\Extension\CentralAuth\CentralAuthHooks; |
11 | use MediaWiki\Extension\CentralAuth\CentralAuthSessionManager; |
12 | use MediaWiki\Extension\CentralAuth\CentralAuthUtilityService; |
13 | use MediaWiki\Extension\CentralAuth\Hooks\Handlers\PageDisplayHookHandler; |
14 | use MediaWiki\Extension\CentralAuth\Hooks\Handlers\SpecialPageBeforeExecuteHookHandler; |
15 | use MediaWiki\Extension\CentralAuth\User\CentralAuthUser; |
16 | use MediaWiki\Html\Html; |
17 | use MediaWiki\Languages\LanguageFactory; |
18 | use MediaWiki\Logger\LoggerFactory; |
19 | use MediaWiki\ResourceLoader\ResourceLoader; |
20 | use MediaWiki\Session\Session; |
21 | use MediaWiki\SpecialPage\UnlistedSpecialPage; |
22 | use MediaWiki\Title\Title; |
23 | use MediaWiki\User\Options\UserOptionsManager; |
24 | use MediaWiki\User\User; |
25 | use MediaWiki\User\UserIdentity; |
26 | use MediaWiki\WikiMap\WikiMap; |
27 | use MobileContext; |
28 | use MWCryptRand; |
29 | use Psr\Log\LoggerInterface; |
30 | use RequestContext; |
31 | use RuntimeException; |
32 | use SkinTemplate; |
33 | use Wikimedia\Rdbms\ReadOnlyMode; |
34 | use Wikimedia\ScopedCallback; |
35 | use 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 | */ |
58 | class 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 | } |