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