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