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