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