Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 158
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialCentralLogin
0.00% covered (danger)
0.00%
0 / 158
0.00% covered (danger)
0.00%
0 / 5
930
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
30
 doLoginStart
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
182
 doLoginComplete
0.00% covered (danger)
0.00%
0 / 65
0.00% covered (danger)
0.00%
0 / 1
110
 showError
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\CentralAuth\Special;
4
5use CentralAuthSessionProvider;
6use Exception;
7use IDBAccessObject;
8use MediaWiki\Extension\CentralAuth\CentralAuthSessionManager;
9use MediaWiki\Extension\CentralAuth\CentralAuthUtilityService;
10use MediaWiki\Extension\CentralAuth\Hooks\CentralAuthHookRunner;
11use MediaWiki\Extension\CentralAuth\Hooks\Handlers\LoginCompleteHookHandler;
12use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
13use MediaWiki\Logger\LoggerFactory;
14use MediaWiki\Session\Session;
15use MediaWiki\SpecialPage\SpecialPage;
16use MediaWiki\SpecialPage\UnlistedSpecialPage;
17use MediaWiki\StubObject\StubGlobalUser;
18use MediaWiki\Title\Title;
19use MediaWiki\User\User;
20use MediaWiki\User\UserIdentity;
21use MediaWiki\WikiMap\WikiMap;
22use MWCryptRand;
23use Psr\Log\LoggerInterface;
24use RuntimeException;
25use Wikimedia\ScopedCallback;
26
27/**
28 * Special page for handling the central login process, which is done right after a successful
29 * login to create a session on the central wiki.
30 *
31 * It does different things depending on the subpage name:
32 * - /start: Creates the stub central session and redirects to /complete. {@see self::doLoginStart()}
33 * - /complete: Unstubs the central session, and redirects back to where the central login was
34 *   started from. {@see self::doLoginComplete()}
35 *
36 * @see LoginCompleteHookHandler::onUserLoginComplete()
37 * @see LoginCompleteHookHandler::onTempUserCreatedRedirect()
38 * @see SpecialCentralAutoLogin
39 * @see https://www.mediawiki.org/wiki/Extension:CentralAuth/authentication
40 */
41class SpecialCentralLogin extends UnlistedSpecialPage {
42
43    /** @var Session */
44    protected $session = null;
45
46    /** @var CentralAuthSessionManager */
47    private $sessionManager;
48
49    /** @var CentralAuthUtilityService */
50    private $utilityService;
51
52    /** @var LoggerInterface */
53    private $logger;
54
55    /**
56     * @param CentralAuthSessionManager $sessionManager
57     * @param CentralAuthUtilityService $utilityService
58     */
59    public function __construct(
60        CentralAuthSessionManager $sessionManager,
61        CentralAuthUtilityService $utilityService
62    ) {
63        parent::__construct( 'CentralLogin' );
64        $this->sessionManager = $sessionManager;
65        $this->utilityService = $utilityService;
66        $this->logger = LoggerFactory::getInstance( 'CentralAuth' );
67    }
68
69    /** @inheritDoc */
70    public function execute( $subpage ) {
71        if ( in_array( $subpage, [ 'start', 'complete' ], true ) ) {
72            $this->logger->debug( 'CentralLogin step {step}', [
73                'step' => $subpage,
74            ] );
75        }
76
77        $this->setHeaders();
78        $this->getOutput()->disallowUserJs();
79
80        // Check session, if possible
81        $session = $this->getRequest()->getSession();
82        if ( !$session->getProvider() instanceof CentralAuthSessionProvider ) {
83            $this->showError(
84                'centralauth-error-wrongprovider',
85                $session->getProvider()->describe( $this->getLanguage() )
86            );
87            return;
88        }
89        $this->session = $session;
90
91        $token = $this->getRequest()->getVal( 'token' );
92
93        if ( $subpage === 'start' ) {
94            $this->doLoginStart( $token );
95        } elseif ( $subpage === 'complete' ) {
96            $this->doLoginComplete( $token );
97        } else {
98            // invalid request
99            $title = SpecialPage::getTitleFor( 'Userlogin' );
100            $this->getOutput()->redirect( $title->getLocalURL() );
101        }
102
103        // Auto-submit and back links
104        $this->getOutput()->addModules( 'ext.centralauth' );
105    }
106
107    /**
108     * First step of central login. Runs on the central login wiki.
109     * - Reads the token store data stashed by getRedirectUrl() (using the token passed in the URL).
110     * - Creates a stub central session (basically a session that stores the username under
111     *   'pending_name' instead of 'user', and is not valid for authentication) in the central
112     *   session backend.
113     * - Creates the local session (which makes CentralAuthSessionProvider store the session
114     *   metadata in the normal session backend, and issue normal and central session cookies).
115     *   Since CentralAuthSessionProvider checks the central session when validating the local
116     *   session, in effect this will also be a stub session until the central session is unstubbed.
117     *   The "remember me" flag is forced off, since that would use the token mechanism which
118     *   doesn't require a valid session and so would ignore stubbing. It will be updated later
119     *   via Special:CentralAutoLogin/refreshCookies.
120     * - Redirects to /complete, and uses the token store and a GET parameter to pass the session
121     *   ID and the login secret from getRedirectUrl() in a secure way. It uses the
122     *   CentralAuthSilentLoginRedirect hook so the redirect can take into account URL modifications
123     *   not understood by WikiMap, such as a mobile domain.
124     *
125     * @param string $token
126     * @throws Exception
127     *
128     * @see LoginCompleteHookHandler::getRedirectUrl()
129     * @see CentralAuthSessionProvider
130     * @see CentralAuthSilentLoginRedirect
131     * @see SpecialCentralAutoLogin
132     */
133    protected function doLoginStart( $token ) {
134        $tokenStore = $this->sessionManager->getTokenStore();
135
136        $key = $this->sessionManager->makeTokenKey( 'central-login-start-token', $token );
137        $info = $this->utilityService->getKeyValueUponExistence( $tokenStore, $key );
138        if ( !is_array( $info ) ) {
139            $this->showError( 'centralauth-error-badtoken' );
140            return;
141        }
142
143        $getException = static function ( CentralAuthUser $centralUser, UserIdentity $user, array $info ) {
144            if ( !$centralUser->exists() ) {
145                return new RuntimeException( "Global user '{$info['name']}' does not exist." );
146            }
147
148            if ( $centralUser->getId() !== $info['guid'] ) {
149                return new RuntimeException( "Global user does not have ID '{$info['guid']}'." );
150            }
151
152            if ( !$centralUser->isAttached() && $user->isRegistered() ) {
153                return new RuntimeException( "User '{$info['name']}' exists locally but is not attached." );
154            }
155
156            return null;
157        };
158
159        $user = User::newFromName( $info['name'] );
160        $centralUser = CentralAuthUser::getInstance( $user );
161        if ( $getException( $centralUser, $user, $info ) ) {
162            // Retry from primary database. Central login is done right after user creation so lag problems
163            // are common.
164            $user = User::newFromName( $info['name'] );
165            $user->load( IDBAccessObject::READ_LATEST );
166            $centralUser = CentralAuthUser::getPrimaryInstance( $user );
167            $e = $getException( $centralUser, $user, $info );
168            if ( $e ) {
169                throw $e;
170            }
171        }
172
173        $session = $this->sessionManager->getCentralSession();
174        // If the user has a full session, make sure that the names match up.
175        // If they do, then send the user back to the "login successful" page.
176        // We want to avoid overwriting any session that may already exist.
177        $createStubSession = true;
178        if ( isset( $session['user'] ) ) {
179            // fully initialized session
180            if ( $session['user'] !== $centralUser->getName() ) {
181                // If the User is trying to switch accounts. Let them do so by
182                // creating a new central session.
183            } else {
184                // They're already logged in to the target account, don't stomp
185                // on the existing session! (T125139)
186                $createStubSession = false;
187            }
188        // If the user has a stub session, error out if the names do not match up
189        } elseif ( isset( $session['pending_name'] ) ) {
190            // stub session
191            if ( $session['pending_name'] !== $centralUser->getName() ) {
192                $this->showError( 'centralauth-error-token-wronguser' );
193                return;
194            }
195        }
196
197        // Delete the temporary token
198        $tokenStore->delete( $key );
199
200        if ( $createStubSession ) {
201            // Start an unusable placeholder session stub and send a cookie.
202            // The cookie will not be usable until the session is unstubbed.
203            // Note: the "remember me" token must be dealt with later (security).
204            $delay = $this->session->delaySave();
205            $this->session->setUser( User::newFromName( $centralUser->getName() ) );
206            $newSessionId = $this->sessionManager->setCentralSession( [
207                'pending_name' => $centralUser->getName(),
208                'pending_guid' => $centralUser->getId()
209            ], true, $this->session );
210            $this->session->persist();
211            ScopedCallback::consume( $delay );
212        } else {
213            // Since the full central session already exists, reuse it.
214            $newSessionId = $session['sessionId'];
215        }
216
217        // Create a new token to pass to Special:CentralLogin/complete (local wiki).
218        $token = MWCryptRand::generateHex( 32 );
219        $key = $this->sessionManager->makeTokenKey( 'central-login-complete-token', $token );
220        $data = [
221            'sessionId' => $newSessionId,
222            // should match the login attempt secret
223            'secret'    => $info['secret']
224        ];
225        $tokenStore = $this->sessionManager->getTokenStore();
226        $tokenStore->set( $key, $data, $tokenStore::TTL_MINUTE );
227
228        $query = [ 'token' => $token ];
229
230        $wiki = WikiMap::getWiki( $info['wikiId'] );
231        $url = $wiki->getCanonicalUrl( 'Special:CentralLogin/complete' );
232        $url = wfAppendQuery( $url, $query );
233
234        $caHookRunner = new CentralAuthHookRunner( $this->getHookContainer() );
235        $caHookRunner->onCentralAuthSilentLoginRedirect( $centralUser, $url, $info );
236
237        $this->getOutput()->redirect( $url );
238    }
239
240    /**
241     * Second step of central login. Runs on the wiki where the original login happened.
242     * - Verifies the login secret that was passed along the redirect chain via the token store,
243     *   against the login secret that was stored in the local session by getRedirectUrl().
244     * - Unstubs the central session, and sets the local session and issues cookies for it.
245     * - Redirects and sets up edge login pixels to be shown on the next request.
246     *   Lets extensions influence the redirect target via the CentralAuthPostLoginRedirect hook.
247     *
248     * Security-wise, we know we are on the same redirect chain as the original login because of
249     * the tokenstore data. This wouldn't necessarily mean the user is the same - an attacker might
250     * stop at some step in the redirect, and trick another user to continue from that step. But we
251     * know this is the same user who did the login, because of the login secret in the local session.
252     *
253     * @param string $token
254     * @throws Exception
255     *
256     * @see LoginCompleteHookHandler::getRedirectUrl()
257     * @see CentralAuthPostLoginRedirect
258     */
259    protected function doLoginComplete( $token ) {
260        $request = $this->getRequest();
261        $tokenStore = $this->sessionManager->getTokenStore();
262
263        $key = $this->sessionManager->makeTokenKey( 'central-login-complete-token', $token );
264        $sessionKey = 'CentralAuth:autologin:current-attempt';
265
266        $info = $this->utilityService->getKeyValueUponExistence( $tokenStore, $key );
267
268        if ( !is_array( $info ) ) {
269            $this->showError( 'centralauth-error-badtoken' );
270            return;
271        }
272
273        // Get the user's current login attempt information
274        $attempt = $request->getSessionData( $sessionKey );
275        if ( !isset( $attempt['secret'] ) ) {
276            $this->showError( 'centralauth-error-nologinattempt' );
277            return;
278        }
279
280        // Make sure this token belongs to the user who spawned the tokens.
281        // This prevents users from giving out links that log people in as them.
282        if ( $info['secret'] !== $attempt['secret'] ) {
283            $this->showError( 'centralauth-error-token-wrongattempt' );
284            return;
285        }
286
287        $getException = static function ( CentralAuthUser $centralUser, UserIdentity $user ) {
288            if ( !$user->getId() ) {
289                return new RuntimeException( "The user account logged into does not exist." );
290            }
291            if ( !$centralUser->getId() ) {
292                return new RuntimeException( "The central user account does not exist." );
293            }
294            if ( !$centralUser->isAttached() ) {
295                return new RuntimeException( "The user account is not attached." );
296            }
297            return null;
298        };
299
300        $user = User::newFromName( $request->getSessionData( 'wsUserName' ) );
301        $centralUser = CentralAuthUser::getInstance( $user );
302        if ( $getException( $centralUser, $user ) ) {
303            $user = User::newFromName( $request->getSessionData( 'wsUserName' ) );
304            $user->load( IDBAccessObject::READ_LATEST );
305            $centralUser = CentralAuthUser::getPrimaryInstance( $user );
306            $e = $getException( $centralUser, $user );
307            if ( $e ) {
308                throw $e;
309            }
310        }
311
312        // Delete the temporary token
313        $tokenStore->delete( $key );
314
315        // Fully initialize the stub central user session and send the domain cookie.
316        // This is a bit tricky. We start with a stub session with 'pending_name' and no 'user'.
317        // CentralAuthSessionManager::setCentralSession() preserves most of the previous data,
318        // but drops 'pending_name'. CentralAuthSessionProvider::persistSession() then sets 'user'
319        // because it doesn't see 'pending_name'.
320        $delay = $this->session->delaySave();
321        $this->session->setUser( User::newFromName( $centralUser->getName() ) );
322        $this->session->setRememberUser( (bool)$attempt['remember'] );
323        $this->sessionManager->setCentralSession( [
324            'remember' => $attempt['remember'],
325        ], $info['sessionId'], $this->session );
326        $this->session->persist();
327        ScopedCallback::consume( $delay );
328
329        // Remove the "current login attempt" information
330        $request->setSessionData( $sessionKey, null );
331
332        // Update the current user global $wgUser,
333        // bypassing deprecation warnings because CentralAuth is the one place outside
334        // of core where we still support writing to $wgUser
335        // See T291515
336        StubGlobalUser::setUser( $user );
337
338        // This should set it for OutputPage and the Skin
339        // which is needed or the personal links will be wrong.
340        $this->getContext()->setUser( $user );
341
342        LoggerFactory::getInstance( 'authevents' )->info( 'Central login attempt', [
343            'event' => 'centrallogin',
344            'successful' => true,
345            'extension' => 'CentralAuth',
346        ] );
347
348        $unusedReference = '';
349        // Allow other extensions to modify the returnTo and returnToQuery
350        $caHookRunner = new CentralAuthHookRunner( $this->getHookContainer() );
351        $caHookRunner->onCentralAuthPostLoginRedirect(
352            $attempt['returnTo'],
353            $attempt['returnToQuery'],
354            true,
355            $attempt['type'],
356            $unusedReference
357        );
358
359        // Mark the session to include the edge login imgs on the next pageview
360        $this->logger->debug( 'Edge login on the next pageview after CentralLogin' );
361        $request->setSessionData( 'CentralAuthDoEdgeLogin', true );
362
363        $returnToTitle = Title::newFromText( $attempt['returnTo'] ) ?: Title::newMainPage();
364        $redirectUrl = $returnToTitle->getFullUrlForRedirect( $attempt['returnToQuery'] )
365            . $attempt['returnToAnchor'];
366        $this->getOutput()->redirect( $redirectUrl );
367    }
368
369    /**
370     * @param mixed ...$args
371     */
372    protected function showError( ...$args ) {
373        LoggerFactory::getInstance( 'authevents' )->info( 'Central login attempt', [
374            'event' => 'centrallogin',
375            'successful' => false,
376            'status' => $args[0],
377            'extension' => 'CentralAuth',
378        ] );
379        $this->getOutput()->wrapWikiMsg( '<div class="error">$1</div>', $args );
380        // JS only
381        $this->getOutput()->addHtml( '<p id="centralauth-backlink-section"></p>' );
382    }
383}