Merge "Improve docs for Title::getInternalURL/getCanonicalURL"
[lhc/web/wiklou.git] / includes / session / CookieSessionProvider.php
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
24 namespace MediaWiki\Session;
25
26 use Config;
27 use User;
28 use WebRequest;
29
30 /**
31 * A CookieSessionProvider persists sessions using cookies
32 *
33 * @ingroup Session
34 * @since 1.27
35 */
36 class CookieSessionProvider extends SessionProvider {
37
38 /** @var mixed[] */
39 protected $params = [];
40
41 /** @var mixed[] */
42 protected $cookieOptions = [];
43
44 /**
45 * @param array $params Keys include:
46 * - priority: (required) Priority of the returned sessions
47 * - callUserSetCookiesHook: Whether to call the deprecated hook
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 */
57 public function __construct( $params = [] ) {
58 parent::__construct();
59
60 $params += [
61 'cookieOptions' => [],
62 // @codeCoverageIgnoreStart
63 ];
64 // @codeCoverageIgnoreEnd
65
66 if ( !isset( $params['priority'] ) ) {
67 throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' );
68 }
69 if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
70 $params['priority'] > SessionInfo::MAX_PRIORITY
71 ) {
72 throw new \InvalidArgumentException( __METHOD__ . ': Invalid priority' );
73 }
74
75 if ( !is_array( $params['cookieOptions'] ) ) {
76 throw new \InvalidArgumentException( __METHOD__ . ': cookieOptions must be an array' );
77 }
78
79 $this->priority = $params['priority'];
80 $this->cookieOptions = $params['cookieOptions'];
81 $this->params = $params;
82 unset( $this->params['priority'] );
83 unset( $this->params['cookieOptions'] );
84 }
85
86 public function setConfig( Config $config ) {
87 parent::setConfig( $config );
88
89 // @codeCoverageIgnoreStart
90 $this->params += [
91 // @codeCoverageIgnoreEnd
92 'callUserSetCookiesHook' => false,
93 'sessionName' =>
94 $config->get( 'SessionName' ) ?: $config->get( 'CookiePrefix' ) . '_session',
95 ];
96
97 // @codeCoverageIgnoreStart
98 $this->cookieOptions += [
99 // @codeCoverageIgnoreEnd
100 'prefix' => $config->get( 'CookiePrefix' ),
101 'path' => $config->get( 'CookiePath' ),
102 'domain' => $config->get( 'CookieDomain' ),
103 'secure' => $config->get( 'CookieSecure' ),
104 'httpOnly' => $config->get( 'CookieHttpOnly' ),
105 ];
106 }
107
108 public function provideSessionInfo( WebRequest $request ) {
109 $sessionId = $this->getCookie( $request, $this->params['sessionName'], '' );
110 $info = [
111 'provider' => $this,
112 'forceHTTPS' => $this->getCookie( $request, 'forceHTTPS', '', false )
113 ];
114 if ( SessionManager::validateSessionId( $sessionId ) ) {
115 $info['id'] = $sessionId;
116 $info['persisted'] = true;
117 }
118
119 list( $userId, $userName, $token ) = $this->getUserInfoFromCookies( $request );
120 if ( $userId !== null ) {
121 try {
122 $userInfo = UserInfo::newFromId( $userId );
123 } catch ( \InvalidArgumentException $ex ) {
124 return null;
125 }
126
127 // Sanity check
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 // Legacy hook
205 if ( $this->params['callUserSetCookiesHook'] && !$user->isAnon() ) {
206 \Hooks::run( 'UserSetCookies', [ $user, &$sessionData, &$cookies ] );
207 }
208
209 $options = $this->cookieOptions;
210
211 $forceHTTPS = $session->shouldForceHTTPS() || $user->requiresHTTPS();
212 if ( $forceHTTPS ) {
213 // Don't set the secure flag if the request came in
214 // over "http", for backwards compat.
215 // @todo Break that backwards compat properly.
216 $options['secure'] = $this->config->get( 'CookieSecure' );
217 }
218
219 $response->setCookie( $this->params['sessionName'], $session->getId(), null,
220 [ 'prefix' => '' ] + $options
221 );
222
223 foreach ( $cookies as $key => $value ) {
224 if ( $value === false ) {
225 $response->clearCookie( $key, $options );
226 } else {
227 $expirationDuration = $this->getLoginCookieExpiration( $key, $session->shouldRememberUser() );
228 $expiration = $expirationDuration ? $expirationDuration + time() : null;
229 $response->setCookie( $key, (string)$value, $expiration, $options );
230 }
231 }
232
233 $this->setForceHTTPSCookie( $forceHTTPS, $session, $request );
234 $this->setLoggedOutCookie( $session->getLoggedOutTimestamp(), $request );
235
236 if ( $sessionData ) {
237 $session->addData( $sessionData );
238 }
239 }
240
241 public function unpersistSession( WebRequest $request ) {
242 $response = $request->response();
243 if ( $response->headersSent() ) {
244 // Can't do anything now
245 $this->logger->debug( __METHOD__ . ': Headers already sent' );
246 return;
247 }
248
249 $cookies = [
250 'UserID' => false,
251 'Token' => false,
252 ];
253
254 $response->clearCookie(
255 $this->params['sessionName'], [ 'prefix' => '' ] + $this->cookieOptions
256 );
257
258 foreach ( $cookies as $key => $value ) {
259 $response->clearCookie( $key, $this->cookieOptions );
260 }
261
262 $this->setForceHTTPSCookie( false, null, $request );
263 }
264
265 /**
266 * Set the "forceHTTPS" cookie
267 * @param bool $set Whether the cookie should be set or not
268 * @param SessionBackend|null $backend
269 * @param WebRequest $request
270 */
271 protected function setForceHTTPSCookie(
272 $set, SessionBackend $backend = null, WebRequest $request
273 ) {
274 $response = $request->response();
275 if ( $set ) {
276 if ( $backend->shouldRememberUser() ) {
277 $expirationDuration = $this->getLoginCookieExpiration(
278 'forceHTTPS',
279 true
280 );
281 $expiration = $expirationDuration ? $expirationDuration + time() : null;
282 } else {
283 $expiration = null;
284 }
285 $response->setCookie( 'forceHTTPS', 'true', $expiration,
286 [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
287 } else {
288 $response->clearCookie( 'forceHTTPS',
289 [ 'prefix' => '', 'secure' => false ] + $this->cookieOptions );
290 }
291 }
292
293 /**
294 * Set the "logged out" cookie
295 * @param int $loggedOut timestamp
296 * @param WebRequest $request
297 */
298 protected function setLoggedOutCookie( $loggedOut, WebRequest $request ) {
299 if ( $loggedOut + 86400 > time() &&
300 $loggedOut !== (int)$this->getCookie( $request, 'LoggedOut', $this->cookieOptions['prefix'] )
301 ) {
302 $request->response()->setCookie( 'LoggedOut', $loggedOut, $loggedOut + 86400,
303 $this->cookieOptions );
304 }
305 }
306
307 public function getVaryCookies() {
308 return [
309 // Vary on token and session because those are the real authn
310 // determiners. UserID and UserName don't matter without those.
311 $this->cookieOptions['prefix'] . 'Token',
312 $this->cookieOptions['prefix'] . 'LoggedOut',
313 $this->params['sessionName'],
314 'forceHTTPS',
315 ];
316 }
317
318 public function suggestLoginUsername( WebRequest $request ) {
319 $name = $this->getCookie( $request, 'UserName', $this->cookieOptions['prefix'] );
320 if ( $name !== null ) {
321 $name = User::getCanonicalName( $name, 'usable' );
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 $session
386 */
387 protected function sessionDataToExport( $user ) {
388 // If we're calling the legacy hook, we should populate $session
389 // like User::setCookies() did.
390 if ( !$user->isAnon() && $this->params['callUserSetCookiesHook'] ) {
391 return [
392 'wsUserID' => $user->getId(),
393 'wsToken' => $user->getToken(),
394 'wsUserName' => $user->getName(),
395 ];
396 }
397
398 return [];
399 }
400
401 public function whyNoSession() {
402 return wfMessage( 'sessionprovider-nocookies' );
403 }
404
405 public function getRememberUserDuration() {
406 return min( $this->getLoginCookieExpiration( 'UserID', true ),
407 $this->getLoginCookieExpiration( 'Token', true ) ) ?: null;
408 }
409
410 /**
411 * Gets the list of cookies that must be set to the 'remember me' duration,
412 * if $wgExtendedLoginCookieExpiration is in use.
413 *
414 * @return string[] Array of unprefixed cookie keys
415 */
416 protected function getExtendedLoginCookies() {
417 return [ 'UserID', 'UserName', 'Token' ];
418 }
419
420 /**
421 * Returns the lifespan of the login cookies, in seconds. 0 means until the end of the session.
422 *
423 * Cookies that are session-length do not call this function.
424 *
425 * @param string $cookieName
426 * @param bool $shouldRememberUser Whether the user should be remembered
427 * long-term
428 * @return int Cookie expiration time in seconds; 0 for session cookies
429 */
430 protected function getLoginCookieExpiration( $cookieName, $shouldRememberUser ) {
431 $extendedCookies = $this->getExtendedLoginCookies();
432 $normalExpiration = $this->config->get( 'CookieExpiration' );
433
434 if ( $shouldRememberUser && in_array( $cookieName, $extendedCookies, true ) ) {
435 $extendedExpiration = $this->config->get( 'ExtendedLoginCookieExpiration' );
436
437 return ( $extendedExpiration !== null ) ? (int)$extendedExpiration : (int)$normalExpiration;
438 } else {
439 return (int)$normalExpiration;
440 }
441 }
442 }