Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.79% covered (success)
97.79%
177 / 181
89.47% covered (warning)
89.47%
17 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
CookieSessionProvider
97.79% covered (success)
97.79%
177 / 181
89.47% covered (warning)
89.47%
17 / 19
60
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 postInitSetup
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 provideSessionInfo
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
10
 persistsSessionId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canChangeUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 persistSession
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
9.14
 unpersistSession
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 setForceHTTPSCookie
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 setLoggedOutCookie
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getVaryCookies
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 suggestLoginUsername
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 getUserInfoFromCookies
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getCookie
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 cookieDataToExport
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 sessionDataToExport
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 whyNoSession
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRememberUserDuration
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getExtendedLoginCookies
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLoginCookieExpiration
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * MediaWiki cookie-based session provider interface
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Session
22 */
23
24namespace MediaWiki\Session;
25
26use MediaWiki\MainConfigNames;
27use MediaWiki\Request\WebRequest;
28use MediaWiki\User\User;
29use MediaWiki\User\UserRigorOptions;
30
31/**
32 * A CookieSessionProvider persists sessions using cookies
33 *
34 * @ingroup Session
35 * @since 1.27
36 */
37class CookieSessionProvider extends SessionProvider {
38
39    /** @var mixed[] */
40    protected $params = [];
41
42    /** @var mixed[] */
43    protected $cookieOptions = [];
44
45    /**
46     * @param array $params Keys include:
47     *  - priority: (required) Priority of the returned sessions
48     *  - sessionName: Session cookie name. Doesn't honor 'prefix'. Defaults to
49     *    $wgSessionName, or $wgCookiePrefix . '_session' if that is unset.
50     *  - cookieOptions: Options to pass to WebRequest::setCookie():
51     *    - prefix: Cookie prefix, defaults to $wgCookiePrefix
52     *    - path: Cookie path, defaults to $wgCookiePath
53     *    - domain: Cookie domain, defaults to $wgCookieDomain
54     *    - secure: Cookie secure flag, defaults to $wgCookieSecure
55     *    - httpOnly: Cookie httpOnly flag, defaults to $wgCookieHttpOnly
56     *    - sameSite: Cookie SameSite attribute, defaults to $wgCookieSameSite
57     */
58    public function __construct( $params = [] ) {
59        parent::__construct();
60
61        $params += [
62            'cookieOptions' => [],
63            // @codeCoverageIgnoreStart
64        ];
65        // @codeCoverageIgnoreEnd
66
67        if ( !isset( $params['priority'] ) ) {
68            throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' );
69        }
70        if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
71            $params['priority'] > SessionInfo::MAX_PRIORITY
72        ) {
73            throw new \InvalidArgumentException( __METHOD__ . ': Invalid priority' );
74        }
75
76        if ( !is_array( $params['cookieOptions'] ) ) {
77            throw new \InvalidArgumentException( __METHOD__ . ': cookieOptions must be an array' );
78        }
79
80        $this->priority = $params['priority'];
81        $this->cookieOptions = $params['cookieOptions'];
82        $this->params = $params;
83        unset( $this->params['priority'] );
84        unset( $this->params['cookieOptions'] );
85    }
86
87    protected function postInitSetup() {
88        $this->params += [
89            'sessionName' =>
90                $this->getConfig()->get( MainConfigNames::SessionName )
91                ?: $this->getConfig()->get( MainConfigNames::CookiePrefix ) . '_session',
92        ];
93
94        $sameSite = $this->getConfig()->get( MainConfigNames::CookieSameSite );
95
96        // @codeCoverageIgnoreStart
97        $this->cookieOptions += [
98            // @codeCoverageIgnoreEnd
99            'prefix' => $this->getConfig()->get( MainConfigNames::CookiePrefix ),
100            'path' => $this->getConfig()->get( MainConfigNames::CookiePath ),
101            'domain' => $this->getConfig()->get( MainConfigNames::CookieDomain ),
102            'secure' => $this->getConfig()->get( MainConfigNames::CookieSecure )
103                || $this->getConfig()->get( MainConfigNames::ForceHTTPS ),
104            'httpOnly' => $this->getConfig()->get( MainConfigNames::CookieHttpOnly ),
105            'sameSite' => $sameSite,
106        ];
107    }
108
109    public function provideSessionInfo( WebRequest $request ) {
110        $sessionId = $this->getCookie( $request, $this->params['sessionName'], '' );
111        $info = [
112            'provider' => $this,
113            'forceHTTPS' => $this->getCookie( $request, 'forceHTTPS', '', false )
114        ];
115        if ( SessionManager::validateSessionId( $sessionId ) ) {
116            $info['id'] = $sessionId;
117            $info['persisted'] = true;
118        }
119
120        [ $userId, $userName, $token ] = $this->getUserInfoFromCookies( $request );
121        if ( $userId !== null ) {
122            try {
123                $userInfo = UserInfo::newFromId( $userId );
124            } catch ( \InvalidArgumentException $ex ) {
125                return null;
126            }
127
128            if ( $userName !== null && $userInfo->getName() !== $userName ) {
129                $this->logger->warning(
130                    'Session "{session}" requested with mismatched UserID and UserName cookies.',
131                    [
132                        'session' => $sessionId,
133                        'mismatch' => [
134                            'userid' => $userId,
135                            'cookie_username' => $userName,
136                            'username' => $userInfo->getName(),
137                        ],
138                    ] );
139                return null;
140            }
141
142            if ( $token !== null ) {
143                if ( !hash_equals( $userInfo->getToken(), $token ) ) {
144                    $this->logger->warning(
145                        'Session "{session}" requested with invalid Token cookie.',
146                        [
147                            'session' => $sessionId,
148                            'userid' => $userId,
149                            'username' => $userInfo->getName(),
150                        ] );
151                    return null;
152                }
153                $info['userInfo'] = $userInfo->verified();
154                $info['persisted'] = true; // If we have user+token, it should be
155            } elseif ( isset( $info['id'] ) ) {
156                $info['userInfo'] = $userInfo;
157            } else {
158                // No point in returning, loadSessionInfoFromStore() will
159                // reject it anyway.
160                return null;
161            }
162        } elseif ( isset( $info['id'] ) ) {
163            // No UserID cookie, so insist that the session is anonymous.
164            // Note: this event occurs for several normal activities:
165            // * anon visits Special:UserLogin
166            // * anon browsing after seeing Special:UserLogin
167            // * anon browsing after edit or preview
168            $this->logger->debug(
169                'Session "{session}" requested without UserID cookie',
170                [
171                    'session' => $info['id'],
172                ] );
173            $info['userInfo'] = UserInfo::newAnonymous();
174        } else {
175            // No session ID and no user is the same as an empty session, so
176            // there's no point.
177            return null;
178        }
179
180        return new SessionInfo( $this->priority, $info );
181    }
182
183    public function persistsSessionId() {
184        return true;
185    }
186
187    public function canChangeUser() {
188        return true;
189    }
190
191    public function persistSession( SessionBackend $session, WebRequest $request ) {
192        $response = $request->response();
193        if ( $response->headersSent() ) {
194            // Can't do anything now
195            $this->logger->debug( __METHOD__ . ': Headers already sent' );
196            return;
197        }
198
199        $user = $session->getUser();
200
201        $cookies = $this->cookieDataToExport( $user, $session->shouldRememberUser() );
202        $sessionData = $this->sessionDataToExport( $user );
203
204        $options = $this->cookieOptions;
205
206        $forceHTTPS = $session->shouldForceHTTPS() || $user->requiresHTTPS();
207        if ( $forceHTTPS ) {
208            $options['secure'] = $this->getConfig()->get( MainConfigNames::CookieSecure )
209                || $this->getConfig()->get( MainConfigNames::ForceHTTPS );
210        }
211
212        $response->setCookie( $this->params['sessionName'], $session->getId(), null,
213            [ 'prefix' => '' ] + $options
214        );
215
216        foreach ( $cookies as $key => $value ) {
217            if ( $value === false ) {
218                $response->clearCookie( $key, $options );
219            } else {
220                $expirationDuration = $this->getLoginCookieExpiration( $key, $session->shouldRememberUser() );
221                $expiration = $expirationDuration ? $expirationDuration + time() : null;
222                $response->setCookie( $key, (string)$value, $expiration, $options );
223            }
224        }
225
226        $this->setForceHTTPSCookie( $forceHTTPS, $session, $request );
227        $this->setLoggedOutCookie( $session->getLoggedOutTimestamp(), $request );
228
229        if ( $sessionData ) {
230            $session->addData( $sessionData );
231        }
232    }
233
234    public function unpersistSession( WebRequest $request ) {
235        $response = $request->response();
236        if ( $response->headersSent() ) {
237            // Can't do anything now
238            $this->logger->debug( __METHOD__ . ': Headers already sent' );
239            return;
240        }
241
242        $cookies = [
243            'UserID' => false,
244            'Token' => false,
245        ];
246
247        $response->clearCookie(
248            $this->params['sessionName'], [ 'prefix' => '' ] + $this->cookieOptions
249        );
250
251        foreach ( $cookies as $key => $value ) {
252            $response->clearCookie( $key, $this->cookieOptions );
253        }
254
255        $this->setForceHTTPSCookie( false, null, $request );
256    }
257
258    /**
259     * Set the "forceHTTPS" cookie, unless $wgForceHTTPS prevents it.
260     *
261     * @param bool $set Whether the cookie should be set or not
262     * @param SessionBackend|null $backend
263     * @param WebRequest $request
264     */
265    protected function setForceHTTPSCookie( $set, ?SessionBackend $backend, WebRequest $request ) {
266        if ( $this->getConfig()->get( MainConfigNames::ForceHTTPS ) ) {
267            // No need to send a cookie if the wiki is always HTTPS (T256095)
268            return;
269        }
270        $response = $request->response();
271        if ( $set ) {
272            if ( $backend->shouldRememberUser() ) {
273                $expirationDuration = $this->getLoginCookieExpiration(
274                    'forceHTTPS',
275                    true
276                );
277                $expiration = $expirationDuration ? $expirationDuration + time() : null;
278            } else {
279                $expiration = null;
280            }
281            $response->setCookie( 'forceHTTPS', 'true', $expiration,
282                [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
283        } else {
284            $response->clearCookie( 'forceHTTPS',
285                [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
286        }
287    }
288
289    /**
290     * @param int $loggedOut timestamp
291     * @param WebRequest $request
292     */
293    protected function setLoggedOutCookie( $loggedOut, WebRequest $request ) {
294        if ( $loggedOut + 86400 > time() &&
295            $loggedOut !== (int)$this->getCookie( $request, 'LoggedOut', $this->cookieOptions['prefix'] )
296        ) {
297            $request->response()->setCookie( 'LoggedOut', (string)$loggedOut, $loggedOut + 86400,
298                $this->cookieOptions );
299        }
300    }
301
302    public function getVaryCookies() {
303        return [
304            // Vary on token and session because those are the real authn
305            // determiners. UserID and UserName don't matter without those.
306            $this->cookieOptions['prefix'] . 'Token',
307            $this->cookieOptions['prefix'] . 'LoggedOut',
308            $this->params['sessionName'],
309            'forceHTTPS',
310        ];
311    }
312
313    public function suggestLoginUsername( WebRequest $request ) {
314        $name = $this->getCookie( $request, 'UserName', $this->cookieOptions['prefix'] );
315        if ( $name !== null ) {
316            if ( $this->userNameUtils->isTemp( $name ) ) {
317                $name = false;
318            } else {
319                $name = $this->userNameUtils->getCanonical( $name, UserRigorOptions::RIGOR_USABLE );
320            }
321        }
322        return $name === false ? null : $name;
323    }
324
325    /**
326     * Fetch the user identity from cookies
327     * @param WebRequest $request
328     * @return array (string|null $id, string|null $username, string|null $token)
329     */
330    protected function getUserInfoFromCookies( $request ) {
331        $prefix = $this->cookieOptions['prefix'];
332        return [
333            $this->getCookie( $request, 'UserID', $prefix ),
334            $this->getCookie( $request, 'UserName', $prefix ),
335            $this->getCookie( $request, 'Token', $prefix ),
336        ];
337    }
338
339    /**
340     * Get a cookie. Contains an auth-specific hack.
341     * @param WebRequest $request
342     * @param string $key
343     * @param string $prefix
344     * @param mixed|null $default
345     * @return mixed
346     */
347    protected function getCookie( $request, $key, $prefix, $default = null ) {
348        $value = $request->getCookie( $key, $prefix, $default );
349        if ( $value === 'deleted' ) {
350            // PHP uses this value when deleting cookies. A legitimate cookie will never have
351            // this value (usernames start with uppercase, token is longer, other auth cookies
352            // are booleans or integers). Seeing this means that in a previous request we told the
353            // client to delete the cookie, but it has poor cookie handling. Pretend the cookie is
354            // not there to avoid invalidating the session.
355            return null;
356        }
357        return $value;
358    }
359
360    /**
361     * Return the data to store in cookies
362     * @param User $user
363     * @param bool $remember
364     * @return array $cookies Set value false to unset the cookie
365     */
366    protected function cookieDataToExport( $user, $remember ) {
367        if ( $user->isAnon() ) {
368            return [
369                'UserID' => false,
370                'Token' => false,
371            ];
372        } else {
373            return [
374                'UserID' => $user->getId(),
375                'UserName' => $user->getName(),
376                'Token' => $remember ? (string)$user->getToken() : false,
377            ];
378        }
379    }
380
381    /**
382     * Return extra data to store in the session
383     * @param User $user
384     * @return array
385     */
386    protected function sessionDataToExport( $user ) {
387        return [];
388    }
389
390    public function whyNoSession() {
391        return wfMessage( 'sessionprovider-nocookies' );
392    }
393
394    public function getRememberUserDuration() {
395        return min( $this->getLoginCookieExpiration( 'UserID', true ),
396            $this->getLoginCookieExpiration( 'Token', true ) ) ?: null;
397    }
398
399    /**
400     * Gets the list of cookies that must be set to the 'remember me' duration,
401     * if $wgExtendedLoginCookieExpiration is in use.
402     *
403     * @return string[] Array of unprefixed cookie keys
404     */
405    protected function getExtendedLoginCookies() {
406        return [ 'UserID', 'UserName', 'Token' ];
407    }
408
409    /**
410     * Returns the lifespan of the login cookies, in seconds. 0 means until the end of the session.
411     *
412     * Cookies that are session-length do not call this function.
413     *
414     * @param string $cookieName
415     * @param bool $shouldRememberUser Whether the user should be remembered
416     *   long-term
417     * @return int Cookie expiration time in seconds; 0 for session cookies
418     */
419    protected function getLoginCookieExpiration( $cookieName, $shouldRememberUser ) {
420        $extendedCookies = $this->getExtendedLoginCookies();
421        $normalExpiration = $this->getConfig()->get( MainConfigNames::CookieExpiration );
422
423        if ( $shouldRememberUser && in_array( $cookieName, $extendedCookies, true ) ) {
424            $extendedExpiration = $this->getConfig()->get( MainConfigNames::ExtendedLoginCookieExpiration );
425
426            return ( $extendedExpiration !== null ) ? (int)$extendedExpiration : (int)$normalExpiration;
427        } else {
428            return (int)$normalExpiration;
429        }
430    }
431}