Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
19.40% covered (danger)
19.40%
52 / 268
21.05% covered (danger)
21.05%
4 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
CentralAuthSessionProvider
19.40% covered (danger)
19.40%
52 / 268
21.05% covered (danger)
21.05%
4 / 19
3778.16
0.00% covered (danger)
0.00%
0 / 1
 __construct
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
2.06
 postInitSetup
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 returnParentSessionInfo
21.05% covered (danger)
21.05%
4 / 19
0.00% covered (danger)
0.00%
0 / 1
7.43
 provideSessionInfo
3.85% covered (danger)
3.85%
3 / 78
0.00% covered (danger)
0.00%
0 / 1
452.27
 refreshSessionInfo
7.14% covered (danger)
7.14%
2 / 28
0.00% covered (danger)
0.00%
0 / 1
90.07
 sessionIdWasReset
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 sessionDataToExport
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 cookieDataToExport
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 persistSession
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
156
 unpersistSession
25.00% covered (danger)
25.00%
3 / 12
0.00% covered (danger)
0.00%
0 / 1
6.80
 invalidateSessionsForUser
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
 preventSessionsForUser
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 setForceHTTPSCookie
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLoggedOutCookie
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getVaryCookies
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 suggestLoginUsername
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 getCentralCookieDomain
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getExtendedLoginCookies
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getRememberUserDuration
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3use MediaWiki\Extension\CentralAuth\CentralAuthSessionManager;
4use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
5use MediaWiki\MainConfigNames;
6use MediaWiki\Request\FauxRequest;
7use MediaWiki\Request\WebRequest;
8use MediaWiki\Session\SessionBackend;
9use MediaWiki\Session\SessionInfo;
10use MediaWiki\Session\SessionManager;
11use MediaWiki\Session\UserInfo;
12use MediaWiki\User\User;
13use MediaWiki\User\UserIdentityLookup;
14use MediaWiki\User\UserNameUtils;
15
16/**
17 * CentralAuth cookie-based sessions.
18 *
19 * This is intended to completely replace the core CookieSessionProvider.
20 *
21 * @warning Due to the complicated way CentralAuth has historically interacted with core
22 *  sessions, this is somewhat complicated and is probably not a good example to
23 *  copy if you're writing your own SessionProvider.
24 */
25class CentralAuthSessionProvider extends MediaWiki\Session\CookieSessionProvider {
26
27    /** @var bool */
28    protected $enable = false;
29
30    /** @var array */
31    protected $centralCookieOptions = [];
32
33    private UserIdentityLookup $userIdentityLookup;
34    private CentralAuthSessionManager $sessionManager;
35
36    /**
37     * @param UserIdentityLookup $userIdentityLookup
38     * @param CentralAuthSessionManager $sessionManager
39     * @param array $params In addition to the parameters for
40     * CookieSessionProvider, the following are
41     * recognized:
42     *  - enable: Whether to set CentralAuth-specific features. Defaults to
43     *    $wgCentralAuthCookies.
44     *  - centralSessionName: Central session cookie name. Defaults to
45     *    centralCookieOptions['prefix'] . 'Session'. Note this does not
46     *    replace the parent class's 'sessionName', it's a different cookie.
47     *  - centralCookieOptions: Settings for central cookies
48     *     - prefix: Cookie prefix, defaults to $wgCentralAuthCookiePrefix
49     *     - path: Cookie path, defaults to $wgCentralAuthCookiePath
50     *     - domain: Cookie domain, defaults to $wgCentralAuthCookieDomain
51     *     - secure: Cookie secure flag, defaults to $wgCookieSecure
52     *     - httpOnly: Cookie httpOnly flag, defaults to $wgCookieHttpOnly
53     *     - sameSite: Cookie SameSite attribute, defaults to $wgCookieSameSite
54     */
55    public function __construct(
56        UserIdentityLookup $userIdentityLookup,
57        CentralAuthSessionManager $sessionManager,
58        $params = []
59    ) {
60        $this->userIdentityLookup = $userIdentityLookup;
61        $this->sessionManager = $sessionManager;
62
63        $params += [
64            'centralCookieOptions' => [],
65        ];
66
67        if ( !is_array( $params['centralCookieOptions'] ) ) {
68            throw new \InvalidArgumentException(
69                __METHOD__ . ': centralCookieOptions must be an array'
70            );
71        }
72
73        $this->centralCookieOptions = $params['centralCookieOptions'];
74        unset( $params['centralCookieOptions'] );
75
76        parent::__construct( $params );
77    }
78
79    protected function postInitSetup() {
80        parent::postInitSetup();
81
82        $this->centralCookieOptions += [
83            'prefix' => $this->getConfig()->get( 'CentralAuthCookiePrefix' ),
84            'path' => $this->getConfig()->get( 'CentralAuthCookiePath' ),
85            'domain' => $this->getConfig()->get( 'CentralAuthCookieDomain' ),
86            'secure' => $this->getConfig()->get( 'CookieSecure' ) || $this->getConfig()->get( 'ForceHTTPS' ),
87            'httpOnly' => $this->getConfig()->get( 'CookieHttpOnly' ),
88            'sameSite' => $this->getConfig()->get( 'CookieSameSite' )
89        ];
90
91        $params = [
92            'enable' => $this->getConfig()->get( 'CentralAuthCookies' ),
93            'centralSessionName' => $this->centralCookieOptions['prefix'] . 'Session',
94        ];
95        $this->params += $params;
96
97        $this->enable = (bool)$params['enable'];
98    }
99
100    /**
101     * Get the local session info, with CentralAuthSource metadata.
102     *
103     * @param WebRequest $request
104     * @param bool $forceEmptyPersist If this is true and there is no local
105     *   session, return a persistent empty local session to override the
106     *   non-functional CentralAuth one. This prevents the deletion of global
107     *   cookies, allowing the user to retain their central login elsewhere
108     *   in the same cookie domain. It makes sense to do this for problems with
109     *   the central session that are specific to the local wiki. (T342475)
110     * @return SessionInfo|null
111     */
112    private function returnParentSessionInfo( WebRequest $request, $forceEmptyPersist = false ) {
113        $info = parent::provideSessionInfo( $request );
114        if ( $info ) {
115            return new SessionInfo( $info->getPriority(), [
116                'copyFrom' => $info,
117                'metadata' => [
118                    'CentralAuthSource' => 'Local',
119                ],
120            ] );
121        } elseif ( $forceEmptyPersist ) {
122            return new SessionInfo( $this->priority, [
123                'id' => null,
124                'provider' => $this,
125                'idIsSafe' => true,
126                'persisted' => true,
127                'metadata' => [
128                    'CentralAuthSource' => 'Local',
129                ],
130            ] );
131        } else {
132            return null;
133        }
134    }
135
136    /**
137     * @param WebRequest $request
138     * @return SessionInfo|null
139     */
140    public function provideSessionInfo( WebRequest $request ) {
141        if ( !$this->enable ) {
142            $this->logger->debug( __METHOD__ . ': Not enabled, falling back to core sessions' );
143            return self::returnParentSessionInfo( $request );
144        }
145
146        $info = [
147            'id' => $this->getCookie( $request, $this->params['sessionName'], '' )
148        ];
149        if ( !SessionManager::validateSessionId( $info['id'] ) ) {
150            unset( $info['id'] );
151        }
152
153        $userName = null;
154        $token = null;
155        $from = null;
156
157        $prefix = $this->centralCookieOptions['prefix'];
158        $userCookie = $this->getCookie( $request, 'User', $prefix );
159        $tokenCookie = $this->getCookie( $request, 'Token', $prefix );
160        if ( $userCookie !== null && $tokenCookie !== null ) {
161            $userName = $userCookie;
162            $token = $tokenCookie;
163            $from = 'cookies';
164        } else {
165            $id = $this->getCookie( $request, $this->params['centralSessionName'], '' );
166            if ( $id !== null ) {
167                $data = $this->sessionManager->getCentralSessionById( $id );
168                if ( isset( $data['pending_name'] ) || isset( $data['pending_guid'] ) ) {
169                    $this->logger->debug( __METHOD__ . ': uninitialized session' );
170                } elseif ( isset( $data['token'] ) && isset( $data['user'] ) ) {
171                    $token = $data['token'];
172                    $userName = $data['user'];
173                    $from = 'session';
174                } else {
175                    $this->logger->debug( __METHOD__ . ': uninitialized session' );
176                }
177            }
178        }
179        if ( $userName === null || $token === null ) {
180            return self::returnParentSessionInfo( $request );
181        }
182
183        // Sanity check to avoid session ID collisions, as reported on T21158
184        if ( $userCookie === null ) {
185            $this->logger->debug(
186                __METHOD__ . ': no User cookie, so unable to check for session mismatch'
187            );
188            return self::returnParentSessionInfo( $request );
189        } elseif ( $userCookie != $userName ) {
190            $this->logger->debug(
191                __METHOD__ . ': Session ID/User mismatch. Possible session collision. ' .
192                    "Expected: $userName; actual: $userCookie"
193            );
194            return self::returnParentSessionInfo( $request );
195        }
196
197        // Clean up username
198        $userName = $this->userNameUtils->getCanonical( $userName, UserNameUtils::RIGOR_VALID );
199        if ( !$userName ) {
200            $this->logger->debug( __METHOD__ . ': invalid username' );
201            return self::returnParentSessionInfo( $request );
202        }
203        if ( !$this->userNameUtils->isUsable( $userName ) ) {
204            $this->logger->warning(
205                __METHOD__ . ': username {username} is not usable on this wiki', [
206                    'username' => $userName,
207                ]
208            );
209            return self::returnParentSessionInfo( $request, true );
210        }
211
212        // Try the central user
213        $centralUser = CentralAuthUser::getInstanceByName( $userName );
214
215        // Skip if they're being renamed
216        if ( $centralUser->renameInProgress() ) {
217            $this->logger->debug( __METHOD__ . ': rename in progress' );
218            // No fallback here, just fail it because our SessionCheckMetadata
219            // hook will do so anyway.
220            return null;
221        }
222
223        if ( !$centralUser->exists() ) {
224            $this->logger->debug( __METHOD__ . ': global account doesn\'t exist' );
225            return self::returnParentSessionInfo( $request );
226        }
227        if ( !$centralUser->isAttached() ) {
228            $userIdentity = $this->userIdentityLookup->getUserIdentityByName( $userName );
229            if ( $userIdentity && $userIdentity->isRegistered() ) {
230                $this->logger->debug( __METHOD__ . ': not attached and local account exists' );
231                return self::returnParentSessionInfo( $request, true );
232            }
233        }
234        if ( $centralUser->authenticateWithToken( $token ) != 'ok' ) {
235            $this->logger->debug( __METHOD__ . ': token mismatch' );
236            // At this point, don't log in with a local session anymore
237            return null;
238        }
239
240        $this->logger->debug( __METHOD__ . ": logged in from $from" );
241
242        $info += [
243            'userInfo' => UserInfo::newFromName( $userName, true ),
244            'provider' => $this,
245            // CA sessions are always persistent
246            'persisted' => true,
247            'remembered' => $tokenCookie !== null,
248            'metadata' => [
249                'CentralAuthSource' => 'CentralAuth',
250            ],
251        ];
252
253        return new SessionInfo( $this->priority, $info );
254    }
255
256    /** @inheritDoc */
257    public function refreshSessionInfo( SessionInfo $info, WebRequest $request, &$metadata ) {
258        // Sanity check on the metadata, to avoid T124409
259        if ( isset( $metadata['CentralAuthSource'] ) ) {
260            $name = $info->getUserInfo()->getName();
261            if ( $name !== null ) {
262                if ( !$this->enable ) {
263                    $source = 'Local';
264                } else {
265                    $centralUser = CentralAuthUser::getInstanceByName( $name );
266                    $centralUserExists = $centralUser->exists();
267                    if ( $centralUserExists && $centralUser->isAttached() ) {
268                        $source = 'CentralAuth';
269                    } elseif ( $centralUserExists ) {
270                        $userIdentity = $this->userIdentityLookup
271                            ->getUserIdentityByName( $name, IDBAccessObject::READ_LATEST );
272                        if ( !$userIdentity || !$userIdentity->isRegistered() ) {
273                            $source = 'CentralAuth';
274                        } else {
275                            $source = 'Local';
276                        }
277                    } else {
278                        $source = 'Local';
279                    }
280                }
281                if ( $metadata['CentralAuthSource'] !== $source ) {
282                    $this->logger->warning(
283                        'Session "{session}": CentralAuth saved source {saved} ' .
284                            '!= expected source {expected}',
285                        [
286                            'session' => $info->__toString(),
287                            'saved' => $metadata['CentralAuthSource'],
288                            'expected' => $source,
289                        ]
290                    );
291                    return false;
292                }
293            }
294        }
295
296        return true;
297    }
298
299    /** @inheritDoc */
300    public function sessionIdWasReset( SessionBackend $session, $oldId ) {
301        if ( !$this->enable ) {
302            return;
303        }
304
305        // We need a Session to pass to CentralAuthSessionManager::setCentralSession()
306        // to reset the session ID, so create one on a new FauxRequest.
307        $s = $session->getSession( new FauxRequest() );
308
309        // We also need to fetch the current central data to pass to
310        // CentralAuthSessionManager::setCentralSession() when resetting the ID.
311        $data = $this->sessionManager->getCentralSession( $s );
312
313        $this->sessionManager->setCentralSession( $data, true, $s );
314    }
315
316    /** @inheritDoc */
317    protected function sessionDataToExport( $user ) {
318        $data = parent::sessionDataToExport( $user );
319
320        // CentralAuth needs to prevent core login-from-session to
321        // avoid bugs like T124409
322        $centralUser = CentralAuthUser::getInstance( $user );
323        if ( $centralUser->isAttached() ) {
324            unset( $data['wsToken'] );
325        }
326
327        return $data;
328    }
329
330    /** @inheritDoc */
331    protected function cookieDataToExport( $user, $remember ) {
332        // If we're going to set CA cookies, don't remember in core cookies.
333        if ( $remember ) {
334            $centralUser = CentralAuthUser::getInstance( $user );
335            $remember = !$centralUser->isAttached();
336        }
337
338        return parent::cookieDataToExport( $user, $remember );
339    }
340
341    /**
342     * @param SessionBackend $session
343     * @param WebRequest $request
344     */
345    public function persistSession( SessionBackend $session, WebRequest $request ) {
346        parent::persistSession( $session, $request );
347
348        if ( !$this->enable ) {
349            return;
350        }
351
352        $response = $request->response();
353        if ( $response->headersSent() ) {
354            // Can't do anything now
355            return;
356        }
357
358        $s = $session->getSession( $request );
359
360        $user = $session->getUser();
361        $centralUser = CentralAuthUser::getInstance( $user );
362
363        if ( $centralUser->exists() && ( $centralUser->isAttached() || !$user->getId() ) ) {
364            // CentralAuth needs to prevent core login-from-session to
365            // avoid bugs like T124409
366            $data = &$session->getData();
367            if ( array_key_exists( 'wsToken', $data ) ) {
368                unset( $data['wsToken'] );
369                $session->dirty();
370            }
371            unset( $data );
372
373            $metadata = $session->getProviderMetadata();
374            $metadata['CentralAuthSource'] = 'CentralAuth';
375            $session->setProviderMetadata( $metadata );
376
377            $remember = $session->shouldRememberUser();
378
379            $options = $this->centralCookieOptions;
380
381            // We only save the user into the central session if it's not a
382            // "pending" session, but we still need the ID to set the cookie.
383            $data = $this->sessionManager->getCentralSession( $s );
384            if ( isset( $data['pending_name'] ) ) {
385                $remember = false;
386            } else {
387                $data['user'] = $centralUser->getName();
388                $data['token'] = $centralUser->getAuthToken();
389            }
390            $centralSessionId = $this->sessionManager->setCentralSession( $data, false, $s );
391
392            $cookies = [
393                'User' => (string)$centralUser->getName(),
394                'Token' => $remember ? (string)$centralUser->getAuthToken() : false,
395            ];
396            foreach ( $cookies as $name => $value ) {
397                if ( $value === false ) {
398                    $response->clearCookie( $name, $options );
399                } else {
400                    $expirationDuration = $this->getLoginCookieExpiration( $name, $remember );
401                    $expiration = $expirationDuration ? $expirationDuration + time() : null;
402                    $response->setCookie( $name, (string)$value, $expiration, $options );
403                }
404            }
405
406            $response->setCookie( $this->params['centralSessionName'], $centralSessionId, null,
407                [ 'prefix' => '' ] + $options );
408        } else {
409            $metadata = $session->getProviderMetadata();
410            $metadata['CentralAuthSource'] = 'Local';
411            $session->setProviderMetadata( $metadata );
412        }
413    }
414
415    /**
416     * @param WebRequest $request
417     */
418    public function unpersistSession( WebRequest $request ) {
419        parent::unpersistSession( $request );
420
421        if ( !$this->enable ) {
422            return;
423        }
424
425        $response = $request->response();
426        if ( $response->headersSent() ) {
427            // Can't do anything now
428            $this->logger->debug( __METHOD__ . ': Headers already sent' );
429            return;
430        }
431
432        $expiry = time() - 86400;
433        $response->clearCookie( 'User', $this->centralCookieOptions );
434        $response->clearCookie( 'Token', $this->centralCookieOptions );
435        $response->clearCookie( $this->params['centralSessionName'],
436            [ 'prefix' => '' ] + $this->centralCookieOptions );
437    }
438
439    /** @inheritDoc */
440    public function invalidateSessionsForUser( User $user ) {
441        $centralUser = CentralAuthUser::getPrimaryInstance( $user );
442        if ( $centralUser->exists() && ( $centralUser->isAttached() || !$user->isRegistered() ) ) {
443            $centralUser->resetAuthToken();
444        }
445    }
446
447    /** @inheritDoc */
448    public function preventSessionsForUser( $username ) {
449        $username = $this->userNameUtils->getCanonical( $username, UserNameUtils::RIGOR_VALID );
450        if ( !$username ) {
451            return;
452        }
453
454        $centralUser = CentralAuthUser::getPrimaryInstanceByName( $username );
455        if ( !$centralUser->exists() ) {
456            return;
457        }
458
459        // Reset the user's password to something invalid and reset the token,
460        // if it's not already invalid.
461        $config = RequestContext::getMain()->getConfig();
462        $passwordFactory = new PasswordFactory(
463            $config->get( MainConfigNames::PasswordConfig ),
464            $config->get( MainConfigNames::PasswordDefault )
465        );
466
467        try {
468            $password = $passwordFactory->newFromCiphertext( $centralUser->getPassword() );
469        } catch ( PasswordError $e ) {
470            return;
471        }
472        if ( !$password instanceof InvalidPassword ) {
473            $centralUser->setPassword( null, true );
474        }
475    }
476
477    /**
478     * @inheritDoc
479     */
480    protected function setForceHTTPSCookie( $set, ?SessionBackend $backend, WebRequest $request ) {
481        // Do nothing. We don't support mixed-protocol HTTP/HTTPS wikis in CentralAuth,
482        // so this cookie is not needed.
483    }
484
485    /** @inheritDoc */
486    protected function setLoggedOutCookie( $loggedOut, WebRequest $request ) {
487        if ( $loggedOut + 86400 > time() &&
488            $loggedOut !== (int)$this->getCookie(
489                $request, 'LoggedOut', $this->centralCookieOptions['prefix'] )
490        ) {
491            $request->response()->setCookie( 'LoggedOut', (string)$loggedOut, $loggedOut + 86400,
492                $this->centralCookieOptions );
493        }
494    }
495
496    /**
497     * @return string[]
498     */
499    public function getVaryCookies() {
500        $cookies = parent::getVaryCookies();
501
502        if ( $this->enable ) {
503            $prefix = $this->centralCookieOptions['prefix'];
504            $cookies[] = $prefix . 'Token';
505            $cookies[] = $prefix . 'LoggedOut';
506            $cookies[] = $this->params['centralSessionName'];
507        }
508
509        return $cookies;
510    }
511
512    /** @inheritDoc */
513    public function suggestLoginUsername( WebRequest $request ) {
514        $name = $this->getCookie( $request, 'User', $this->centralCookieOptions['prefix'] );
515        if ( $name !== null ) {
516            if ( $this->userNameUtils->isTemp( $name ) ) {
517                $name = false;
518            } else {
519                $name = $this->userNameUtils->getCanonical( $name, UserNameUtils::RIGOR_USABLE );
520            }
521        }
522        return ( $name === false || $name === null )
523            ? parent::suggestLoginUsername( $request )
524            : $name;
525    }
526
527    /**
528     * Fetch the central cookie domain
529     * @return string
530     */
531    public function getCentralCookieDomain() {
532        return $this->centralCookieOptions['domain'];
533    }
534
535    /** @inheritDoc */
536    protected function getExtendedLoginCookies() {
537        $cookies = parent::getExtendedLoginCookies();
538        $cookies[] = 'User';
539        return $cookies;
540    }
541
542    /** @inheritDoc */
543    public function getRememberUserDuration() {
544        // CentralAuth needs User and Token cookies to remember the user. The fallback to
545        // sessions needs UserID as well, so if that one has shorter expiration, the remember
546        // duration will depend on whether the account is attached; let's return the shorter
547        // duration in that case.
548
549        return min(
550            $this->getLoginCookieExpiration( 'User', true ),
551            $this->getLoginCookieExpiration( 'Token', true ),
552            $this->getLoginCookieExpiration( 'UserID', true )
553        ) ?: null;
554    }
555}