Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / session / CookieSessionProvider.php
blobbf60313727ec34b6757e4c917f441019d22a8df1
1 <?php
2 /**
3 * MediaWiki cookie-based session provider interface
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.
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.
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
20 * @file
21 * @ingroup Session
24 namespace MediaWiki\Session;
26 use InvalidArgumentException;
27 use MediaWiki\MainConfigNames;
28 use MediaWiki\Request\WebRequest;
29 use MediaWiki\User\User;
30 use MediaWiki\User\UserRigorOptions;
32 /**
33 * A CookieSessionProvider persists sessions using cookies
35 * @ingroup Session
36 * @since 1.27
38 class CookieSessionProvider extends SessionProvider {
40 /** @var mixed[] */
41 protected $params = [];
43 /** @var mixed[] */
44 protected $cookieOptions = [];
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
59 public function __construct( $params = [] ) {
60 parent::__construct();
62 $params += [
63 'cookieOptions' => [],
64 // @codeCoverageIgnoreStart
66 // @codeCoverageIgnoreEnd
68 if ( !isset( $params['priority'] ) ) {
69 throw new InvalidArgumentException( __METHOD__ . ': priority must be specified' );
71 if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
72 $params['priority'] > SessionInfo::MAX_PRIORITY
73 ) {
74 throw new InvalidArgumentException( __METHOD__ . ': Invalid priority' );
77 if ( !is_array( $params['cookieOptions'] ) ) {
78 throw new InvalidArgumentException( __METHOD__ . ': cookieOptions must be an array' );
81 $this->priority = $params['priority'];
82 $this->cookieOptions = $params['cookieOptions'];
83 $this->params = $params;
84 unset( $this->params['priority'] );
85 unset( $this->params['cookieOptions'] );
88 protected function postInitSetup() {
89 $this->params += [
90 'sessionName' =>
91 $this->getConfig()->get( MainConfigNames::SessionName )
92 ?: $this->getConfig()->get( MainConfigNames::CookiePrefix ) . '_session',
95 $sameSite = $this->getConfig()->get( MainConfigNames::CookieSameSite );
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,
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 )
116 if ( SessionManager::validateSessionId( $sessionId ) ) {
117 $info['id'] = $sessionId;
118 $info['persisted'] = true;
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;
129 if ( $userName !== null && $userInfo->getName() !== $userName ) {
130 $this->logger->warning(
131 'Session "{session}" requested with mismatched UserID and UserName cookies.',
133 'session' => $sessionId,
134 'mismatch' => [
135 'userid' => $userId,
136 'cookie_username' => $userName,
137 'username' => $userInfo->getName(),
139 ] );
140 return null;
143 if ( $token !== null ) {
144 if ( !hash_equals( $userInfo->getToken(), $token ) ) {
145 $this->logger->warning(
146 'Session "{session}" requested with invalid Token cookie.',
148 'session' => $sessionId,
149 'userid' => $userId,
150 'username' => $userInfo->getName(),
151 ] );
152 return null;
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;
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',
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;
181 return new SessionInfo( $this->priority, $info );
184 public function persistsSessionId() {
185 return true;
188 public function canChangeUser() {
189 return true;
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;
200 $user = $session->getUser();
202 $cookies = $this->cookieDataToExport( $user, $session->shouldRememberUser() );
203 $sessionData = $this->sessionDataToExport( $user );
205 $options = $this->cookieOptions;
207 $forceHTTPS = $session->shouldForceHTTPS() || $user->requiresHTTPS();
208 if ( $forceHTTPS ) {
209 $options['secure'] = $this->getConfig()->get( MainConfigNames::CookieSecure )
210 || $this->getConfig()->get( MainConfigNames::ForceHTTPS );
213 $response->setCookie( $this->params['sessionName'], $session->getId(), null,
214 [ 'prefix' => '' ] + $options
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 );
227 $this->setForceHTTPSCookie( $forceHTTPS, $session, $request );
228 $this->setLoggedOutCookie( $session->getLoggedOutTimestamp(), $request );
230 if ( $sessionData ) {
231 $session->addData( $sessionData );
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;
243 $cookies = [
244 'UserID' => false,
245 'Token' => false,
248 $response->clearCookie(
249 $this->params['sessionName'], [ 'prefix' => '' ] + $this->cookieOptions
252 foreach ( $cookies as $key => $value ) {
253 $response->clearCookie( $key, $this->cookieOptions );
256 $this->setForceHTTPSCookie( false, null, $request );
260 * Set the "forceHTTPS" cookie, unless $wgForceHTTPS prevents it.
262 * @param bool $set Whether the cookie should be set or not
263 * @param SessionBackend|null $backend
264 * @param WebRequest $request
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;
271 $response = $request->response();
272 if ( $set ) {
273 if ( $backend->shouldRememberUser() ) {
274 $expirationDuration = $this->getLoginCookieExpiration(
275 'forceHTTPS',
276 true
278 $expiration = $expirationDuration ? $expirationDuration + time() : null;
279 } else {
280 $expiration = null;
282 $response->setCookie( 'forceHTTPS', 'true', $expiration,
283 [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
284 } else {
285 $response->clearCookie( 'forceHTTPS',
286 [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
291 * @param int $loggedOut timestamp
292 * @param WebRequest $request
294 protected function setLoggedOutCookie( $loggedOut, WebRequest $request ) {
295 if ( $loggedOut + 86400 > time() &&
296 $loggedOut !== (int)$this->getCookie( $request, 'LoggedOut', $this->cookieOptions['prefix'] )
298 $request->response()->setCookie( 'LoggedOut', (string)$loggedOut, $loggedOut + 86400,
299 $this->cookieOptions );
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',
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 );
323 return $name === false ? null : $name;
327 * Fetch the user identity from cookies
328 * @param WebRequest $request
329 * @return array (string|null $id, string|null $username, string|null $token)
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 ),
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
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;
358 return $value;
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
367 protected function cookieDataToExport( $user, $remember ) {
368 if ( $user->isAnon() ) {
369 return [
370 'UserID' => false,
371 'Token' => false,
373 } else {
374 return [
375 'UserID' => $user->getId(),
376 'UserName' => $user->getName(),
377 'Token' => $remember ? (string)$user->getToken() : false,
383 * Return extra data to store in the session
384 * @param User $user
385 * @return array
387 protected function sessionDataToExport( $user ) {
388 return [];
391 public function whyNoSession() {
392 return wfMessage( 'sessionprovider-nocookies' );
395 public function getRememberUserDuration() {
396 return min( $this->getLoginCookieExpiration( 'UserID', true ),
397 $this->getLoginCookieExpiration( 'Token', true ) ) ?: null;
401 * Gets the list of cookies that must be set to the 'remember me' duration,
402 * if $wgExtendedLoginCookieExpiration is in use.
404 * @return string[] Array of unprefixed cookie keys
406 protected function getExtendedLoginCookies() {
407 return [ 'UserID', 'UserName', 'Token' ];
411 * Returns the lifespan of the login cookies, in seconds. 0 means until the end of the session.
413 * Cookies that are session-length do not call this function.
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
420 protected function getLoginCookieExpiration( $cookieName, $shouldRememberUser ) {
421 $extendedCookies = $this->getExtendedLoginCookies();
422 $normalExpiration = $this->getConfig()->get( MainConfigNames::CookieExpiration );
424 if ( $shouldRememberUser && in_array( $cookieName, $extendedCookies, true ) ) {
425 $extendedExpiration = $this->getConfig()->get( MainConfigNames::ExtendedLoginCookieExpiration );
427 return ( $extendedExpiration !== null ) ? (int)$extendedExpiration : (int)$normalExpiration;
428 } else {
429 return (int)$normalExpiration;