Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 160 |
|
0.00% |
0 / 5 |
CRAP | |
0.00% |
0 / 1 |
SpecialCentralLogin | |
0.00% |
0 / 160 |
|
0.00% |
0 / 5 |
1190 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
30 | |||
doLoginStart | |
0.00% |
0 / 58 |
|
0.00% |
0 / 1 |
210 | |||
doLoginComplete | |
0.00% |
0 / 63 |
|
0.00% |
0 / 1 |
132 | |||
showError | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\CentralAuth\Special; |
4 | |
5 | use CentralAuthSessionProvider; |
6 | use Exception; |
7 | use MediaWiki\Extension\CentralAuth\CentralAuthSessionManager; |
8 | use MediaWiki\Extension\CentralAuth\CentralAuthTokenManager; |
9 | use MediaWiki\Extension\CentralAuth\Hooks\CentralAuthHookRunner; |
10 | use MediaWiki\Extension\CentralAuth\Hooks\Handlers\LoginCompleteHookHandler; |
11 | use MediaWiki\Extension\CentralAuth\User\CentralAuthUser; |
12 | use MediaWiki\Logger\LoggerFactory; |
13 | use MediaWiki\Session\Session; |
14 | use MediaWiki\SpecialPage\SpecialPage; |
15 | use MediaWiki\SpecialPage\UnlistedSpecialPage; |
16 | use MediaWiki\StubObject\StubGlobalUser; |
17 | use MediaWiki\Title\Title; |
18 | use MediaWiki\User\User; |
19 | use MediaWiki\User\UserIdentity; |
20 | use MediaWiki\WikiMap\WikiMap; |
21 | use Psr\Log\LoggerInterface; |
22 | use RuntimeException; |
23 | use Wikimedia\Rdbms\IDBAccessObject; |
24 | use 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 | */ |
40 | class 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 | } |