Merge "Added Range support to FileBackend::streamFile()"
[lhc/web/wiklou.git] / includes / session / Session.php
1 <?php
2 /**
3 * MediaWiki session
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 Psr\Log\LoggerInterface;
27 use User;
28 use WebRequest;
29
30 /**
31 * Manages data for an an authenticated session
32 *
33 * A Session represents the fact that the current HTTP request is part of a
34 * session. There are two broad types of Sessions, based on whether they
35 * return true or false from self::canSetUser():
36 * * When true (mutable), the Session identifies multiple requests as part of
37 * a session generically, with no tie to a particular user.
38 * * When false (immutable), the Session identifies multiple requests as part
39 * of a session by identifying and authenticating the request itself as
40 * belonging to a particular user.
41 *
42 * The Session object also serves as a replacement for PHP's $_SESSION,
43 * managing access to per-session data.
44 *
45 * @ingroup Session
46 * @since 1.27
47 */
48 final class Session implements \Countable, \Iterator, \ArrayAccess {
49 /** @var SessionBackend Session backend */
50 private $backend;
51
52 /** @var int Session index */
53 private $index;
54
55 /** @var LoggerInterface */
56 private $logger;
57
58 /**
59 * @param SessionBackend $backend
60 * @param int $index
61 * @param LoggerInterface $logger
62 */
63 public function __construct( SessionBackend $backend, $index, LoggerInterface $logger ) {
64 $this->backend = $backend;
65 $this->index = $index;
66 $this->logger = $logger;
67 }
68
69 public function __destruct() {
70 $this->backend->deregisterSession( $this->index );
71 }
72
73 /**
74 * Returns the session ID
75 * @return string
76 */
77 public function getId() {
78 return $this->backend->getId();
79 }
80
81 /**
82 * Returns the SessionId object
83 * @private For internal use by WebRequest
84 * @return SessionId
85 */
86 public function getSessionId() {
87 return $this->backend->getSessionId();
88 }
89
90 /**
91 * Changes the session ID
92 * @return string New ID (might be the same as the old)
93 */
94 public function resetId() {
95 return $this->backend->resetId();
96 }
97
98 /**
99 * Fetch the SessionProvider for this session
100 * @return SessionProviderInterface
101 */
102 public function getProvider() {
103 return $this->backend->getProvider();
104 }
105
106 /**
107 * Indicate whether this session is persisted across requests
108 *
109 * For example, if cookies are set.
110 *
111 * @return bool
112 */
113 public function isPersistent() {
114 return $this->backend->isPersistent();
115 }
116
117 /**
118 * Make this session persisted across requests
119 *
120 * If the session is already persistent, equivalent to calling
121 * $this->renew().
122 */
123 public function persist() {
124 $this->backend->persist();
125 }
126
127 /**
128 * Make this session not be persisted across requests
129 */
130 public function unpersist() {
131 $this->backend->unpersist();
132 }
133
134 /**
135 * Indicate whether the user should be remembered independently of the
136 * session ID.
137 * @return bool
138 */
139 public function shouldRememberUser() {
140 return $this->backend->shouldRememberUser();
141 }
142
143 /**
144 * Set whether the user should be remembered independently of the session
145 * ID.
146 * @param bool $remember
147 */
148 public function setRememberUser( $remember ) {
149 $this->backend->setRememberUser( $remember );
150 }
151
152 /**
153 * Returns the request associated with this session
154 * @return WebRequest
155 */
156 public function getRequest() {
157 return $this->backend->getRequest( $this->index );
158 }
159
160 /**
161 * Returns the authenticated user for this session
162 * @return User
163 */
164 public function getUser() {
165 return $this->backend->getUser();
166 }
167
168 /**
169 * Fetch the rights allowed the user when this session is active.
170 * @return null|string[] Allowed user rights, or null to allow all.
171 */
172 public function getAllowedUserRights() {
173 return $this->backend->getAllowedUserRights();
174 }
175
176 /**
177 * Indicate whether the session user info can be changed
178 * @return bool
179 */
180 public function canSetUser() {
181 return $this->backend->canSetUser();
182 }
183
184 /**
185 * Set a new user for this session
186 * @note This should only be called when the user has been authenticated
187 * @param User $user User to set on the session.
188 * This may become a "UserValue" in the future, or User may be refactored
189 * into such.
190 */
191 public function setUser( $user ) {
192 $this->backend->setUser( $user );
193 }
194
195 /**
196 * Get a suggested username for the login form
197 * @return string|null
198 */
199 public function suggestLoginUsername() {
200 return $this->backend->suggestLoginUsername( $this->index );
201 }
202
203 /**
204 * Whether HTTPS should be forced
205 * @return bool
206 */
207 public function shouldForceHTTPS() {
208 return $this->backend->shouldForceHTTPS();
209 }
210
211 /**
212 * Set whether HTTPS should be forced
213 * @param bool $force
214 */
215 public function setForceHTTPS( $force ) {
216 $this->backend->setForceHTTPS( $force );
217 }
218
219 /**
220 * Fetch the "logged out" timestamp
221 * @return int
222 */
223 public function getLoggedOutTimestamp() {
224 return $this->backend->getLoggedOutTimestamp();
225 }
226
227 /**
228 * Set the "logged out" timestamp
229 * @param int $ts
230 */
231 public function setLoggedOutTimestamp( $ts ) {
232 $this->backend->setLoggedOutTimestamp( $ts );
233 }
234
235 /**
236 * Fetch provider metadata
237 * @protected For use by SessionProvider subclasses only
238 * @return mixed
239 */
240 public function getProviderMetadata() {
241 return $this->backend->getProviderMetadata();
242 }
243
244 /**
245 * Delete all session data and clear the user (if possible)
246 */
247 public function clear() {
248 $data = &$this->backend->getData();
249 if ( $data ) {
250 $data = [];
251 $this->backend->dirty();
252 }
253 if ( $this->backend->canSetUser() ) {
254 $this->backend->setUser( new User );
255 }
256 $this->backend->save();
257 }
258
259 /**
260 * Renew the session
261 *
262 * Resets the TTL in the backend store if the session is near expiring, and
263 * re-persists the session to any active WebRequests if persistent.
264 */
265 public function renew() {
266 $this->backend->renew();
267 }
268
269 /**
270 * Fetch a copy of this session attached to an alternative WebRequest
271 *
272 * Actions on the copy will affect this session too, and vice versa.
273 *
274 * @param WebRequest $request Any existing session associated with this
275 * WebRequest object will be overwritten.
276 * @return Session
277 */
278 public function sessionWithRequest( WebRequest $request ) {
279 $request->setSessionId( $this->backend->getSessionId() );
280 return $this->backend->getSession( $request );
281 }
282
283 /**
284 * Fetch a value from the session
285 * @param string|int $key
286 * @param mixed $default Returned if $this->exists( $key ) would be false
287 * @return mixed
288 */
289 public function get( $key, $default = null ) {
290 $data = &$this->backend->getData();
291 return array_key_exists( $key, $data ) ? $data[$key] : $default;
292 }
293
294 /**
295 * Test if a value exists in the session
296 * @note Unlike isset(), null values are considered to exist.
297 * @param string|int $key
298 * @return bool
299 */
300 public function exists( $key ) {
301 $data = &$this->backend->getData();
302 return array_key_exists( $key, $data );
303 }
304
305 /**
306 * Set a value in the session
307 * @param string|int $key
308 * @param mixed $value
309 */
310 public function set( $key, $value ) {
311 $data = &$this->backend->getData();
312 if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) {
313 $data[$key] = $value;
314 $this->backend->dirty();
315 }
316 }
317
318 /**
319 * Remove a value from the session
320 * @param string|int $key
321 */
322 public function remove( $key ) {
323 $data = &$this->backend->getData();
324 if ( array_key_exists( $key, $data ) ) {
325 unset( $data[$key] );
326 $this->backend->dirty();
327 }
328 }
329
330 /**
331 * Fetch a CSRF token from the session
332 *
333 * Note that this does not persist the session, which you'll probably want
334 * to do if you want the token to actually be useful.
335 *
336 * @param string|string[] $salt Token salt
337 * @param string $key Token key
338 * @return Token
339 */
340 public function getToken( $salt = '', $key = 'default' ) {
341 $new = false;
342 $secrets = $this->get( 'wsTokenSecrets' );
343 if ( !is_array( $secrets ) ) {
344 $secrets = [];
345 }
346 if ( isset( $secrets[$key] ) && is_string( $secrets[$key] ) ) {
347 $secret = $secrets[$key];
348 } else {
349 $secret = \MWCryptRand::generateHex( 32 );
350 $secrets[$key] = $secret;
351 $this->set( 'wsTokenSecrets', $secrets );
352 $new = true;
353 }
354 if ( is_array( $salt ) ) {
355 $salt = implode( '|', $salt );
356 }
357 return new Token( $secret, (string)$salt, $new );
358 }
359
360 /**
361 * Remove a CSRF token from the session
362 *
363 * The next call to self::getToken() with $key will generate a new secret.
364 *
365 * @param string $key Token key
366 */
367 public function resetToken( $key = 'default' ) {
368 $secrets = $this->get( 'wsTokenSecrets' );
369 if ( is_array( $secrets ) && isset( $secrets[$key] ) ) {
370 unset( $secrets[$key] );
371 $this->set( 'wsTokenSecrets', $secrets );
372 }
373 }
374
375 /**
376 * Remove all CSRF tokens from the session
377 */
378 public function resetAllTokens() {
379 $this->remove( 'wsTokenSecrets' );
380 }
381
382 /**
383 * Fetch the secret keys for self::setSecret() and self::getSecret().
384 * @return string[] Encryption key, HMAC key
385 */
386 private function getSecretKeys() {
387 global $wgSessionSecret, $wgSecretKey, $wgSessionPbkdf2Iterations;
388
389 $wikiSecret = $wgSessionSecret ?: $wgSecretKey;
390 $userSecret = $this->get( 'wsSessionSecret', null );
391 if ( $userSecret === null ) {
392 $userSecret = \MWCryptRand::generateHex( 32 );
393 $this->set( 'wsSessionSecret', $userSecret );
394 }
395 $iterations = $this->get( 'wsSessionPbkdf2Iterations', null );
396 if ( $iterations === null ) {
397 $iterations = $wgSessionPbkdf2Iterations;
398 $this->set( 'wsSessionPbkdf2Iterations', $iterations );
399 }
400
401 $keymats = hash_pbkdf2( 'sha256', $wikiSecret, $userSecret, $iterations, 64, true );
402 return [
403 substr( $keymats, 0, 32 ),
404 substr( $keymats, 32, 32 ),
405 ];
406 }
407
408 /**
409 * Decide what type of encryption to use, based on system capabilities.
410 * @return array
411 */
412 private function getEncryptionAlgorithm() {
413 global $wgSessionInsecureSecrets;
414
415 if (
416 function_exists( 'openssl_encrypt' )
417 && in_array( 'aes-256-ctr', openssl_get_cipher_methods(), true )
418 ) {
419 return [ 'openssl', 'aes-256-ctr' ];
420 } elseif (
421 function_exists( 'mcrypt_encrypt' )
422 && in_array( 'rijndael-128', mcrypt_list_algorithms(), true )
423 && in_array( 'ctr', mcrypt_list_modes(), true )
424 ) {
425 return [ 'mcrypt', 'rijndael-128', 'ctr' ];
426 } elseif ( $wgSessionInsecureSecrets ) {
427 // @todo: import a pure-PHP library for AES instead of this
428 return [ 'insecure' ];
429 } else {
430 throw new \BadMethodCallException(
431 'Encryption is not available. You really should install the PHP OpenSSL extension, ' .
432 'or failing that the mcrypt extension. But if you really can\'t and you\'re willing ' .
433 'to accept insecure storage of sensitive session data, set ' .
434 '$wgSessionInsecureSecrets = true in LocalSettings.php to make this exception go away.'
435 );
436 }
437
438 }
439
440 /**
441 * Set a value in the session, encrypted
442 *
443 * This relies on the secrecy of $wgSecretKey (by default), or $wgSessionSecret.
444 *
445 * @param string|int $key
446 * @param mixed $value
447 */
448 public function setSecret( $key, $value ) {
449 list( $encKey, $hmacKey ) = $this->getSecretKeys();
450 $serialized = serialize( $value );
451
452 // The code for encryption (with OpenSSL) and sealing is taken from
453 // Chris Steipp's OATHAuthUtils class in Extension::OATHAuth.
454
455 // Encrypt
456 // @todo: import a pure-PHP library for AES instead of doing $wgSessionInsecureSecrets
457 $iv = \MWCryptRand::generate( 16, true );
458 $algorithm = $this->getEncryptionAlgorithm();
459 switch ( $algorithm[0] ) {
460 case 'openssl':
461 $ciphertext = openssl_encrypt( $serialized, $algorithm[1], $encKey, OPENSSL_RAW_DATA, $iv );
462 if ( $ciphertext === false ) {
463 throw new \UnexpectedValueException( 'Encryption failed: ' . openssl_error_string() );
464 }
465 break;
466 case 'mcrypt':
467 $ciphertext = mcrypt_encrypt( $algorithm[1], $encKey, $serialized, $algorithm[2], $iv );
468 if ( $ciphertext === false ) {
469 throw new \UnexpectedValueException( 'Encryption failed' );
470 }
471 break;
472 case 'insecure':
473 $ex = new \Exception( 'No encryption is available, storing data as plain text' );
474 $this->logger->warning( $ex->getMessage(), [ 'exception' => $ex ] );
475 $ciphertext = $serialized;
476 break;
477 default:
478 throw new \LogicException( 'invalid algorithm' );
479 }
480
481 // Seal
482 $sealed = base64_encode( $iv ) . '.' . base64_encode( $ciphertext );
483 $hmac = hash_hmac( 'sha256', $sealed, $hmacKey, true );
484 $encrypted = base64_encode( $hmac ) . '.' . $sealed;
485
486 // Store
487 $this->set( $key, $encrypted );
488 }
489
490 /**
491 * Fetch a value from the session that was set with self::setSecret()
492 * @param string|int $key
493 * @param mixed $default Returned if $this->exists( $key ) would be false or decryption fails
494 * @return mixed
495 */
496 public function getSecret( $key, $default = null ) {
497 // Fetch
498 $encrypted = $this->get( $key, null );
499 if ( $encrypted === null ) {
500 return $default;
501 }
502
503 // The code for unsealing, checking, and decrypting (with OpenSSL) is
504 // taken from Chris Steipp's OATHAuthUtils class in
505 // Extension::OATHAuth.
506
507 // Unseal and check
508 $pieces = explode( '.', $encrypted );
509 if ( count( $pieces ) !== 3 ) {
510 $ex = new \Exception( 'Invalid sealed-secret format' );
511 $this->logger->warning( $ex->getMessage(), [ 'exception' => $ex ] );
512 return $default;
513 }
514 list( $hmac, $iv, $ciphertext ) = $pieces;
515 list( $encKey, $hmacKey ) = $this->getSecretKeys();
516 $integCalc = hash_hmac( 'sha256', $iv . '.' . $ciphertext, $hmacKey, true );
517 if ( !hash_equals( $integCalc, base64_decode( $hmac ) ) ) {
518 $ex = new \Exception( 'Sealed secret has been tampered with, aborting.' );
519 $this->logger->warning( $ex->getMessage(), [ 'exception' => $ex ] );
520 return $default;
521 }
522
523 // Decrypt
524 $algorithm = $this->getEncryptionAlgorithm();
525 switch ( $algorithm[0] ) {
526 case 'openssl':
527 $serialized = openssl_decrypt( base64_decode( $ciphertext ), $algorithm[1], $encKey,
528 OPENSSL_RAW_DATA, base64_decode( $iv ) );
529 if ( $serialized === false ) {
530 $ex = new \Exception( 'Decyption failed: ' . openssl_error_string() );
531 $this->logger->debug( $ex->getMessage(), [ 'exception' => $ex ] );
532 return $default;
533 }
534 break;
535 case 'mcrypt':
536 $serialized = mcrypt_decrypt( $algorithm[1], $encKey, base64_decode( $ciphertext ),
537 $algorithm[2], base64_decode( $iv ) );
538 if ( $serialized === false ) {
539 $ex = new \Exception( 'Decyption failed' );
540 $this->logger->debug( $ex->getMessage(), [ 'exception' => $ex ] );
541 return $default;
542 }
543 break;
544 case 'insecure':
545 $ex = new \Exception(
546 'No encryption is available, retrieving data that was stored as plain text'
547 );
548 $this->logger->warning( $ex->getMessage(), [ 'exception' => $ex ] );
549 $serialized = base64_decode( $ciphertext );
550 break;
551 default:
552 throw new \LogicException( 'invalid algorithm' );
553 }
554
555 $value = unserialize( $serialized );
556 if ( $value === false && $serialized !== serialize( false ) ) {
557 $value = $default;
558 }
559 return $value;
560 }
561
562 /**
563 * Delay automatic saving while multiple updates are being made
564 *
565 * Calls to save() or clear() will not be delayed.
566 *
567 * @return \ScopedCallback When this goes out of scope, a save will be triggered
568 */
569 public function delaySave() {
570 return $this->backend->delaySave();
571 }
572
573 /**
574 * Save the session
575 */
576 public function save() {
577 $this->backend->save();
578 }
579
580 /**
581 * @name Interface methods
582 * @{
583 */
584
585 public function count() {
586 $data = &$this->backend->getData();
587 return count( $data );
588 }
589
590 public function current() {
591 $data = &$this->backend->getData();
592 return current( $data );
593 }
594
595 public function key() {
596 $data = &$this->backend->getData();
597 return key( $data );
598 }
599
600 public function next() {
601 $data = &$this->backend->getData();
602 next( $data );
603 }
604
605 public function rewind() {
606 $data = &$this->backend->getData();
607 reset( $data );
608 }
609
610 public function valid() {
611 $data = &$this->backend->getData();
612 return key( $data ) !== null;
613 }
614
615 /**
616 * @note Despite the name, this seems to be intended to implement isset()
617 * rather than array_key_exists(). So do that.
618 */
619 public function offsetExists( $offset ) {
620 $data = &$this->backend->getData();
621 return isset( $data[$offset] );
622 }
623
624 /**
625 * @note This supports indirect modifications but can't mark the session
626 * dirty when those happen. SessionBackend::save() checks the hash of the
627 * data to detect such changes.
628 * @note Accessing a nonexistent key via this mechanism causes that key to
629 * be created with a null value, and does not raise a PHP warning.
630 */
631 public function &offsetGet( $offset ) {
632 $data = &$this->backend->getData();
633 if ( !array_key_exists( $offset, $data ) ) {
634 $ex = new \Exception( "Undefined index (auto-adds to session with a null value): $offset" );
635 $this->logger->debug( $ex->getMessage(), [ 'exception' => $ex ] );
636 }
637 return $data[$offset];
638 }
639
640 public function offsetSet( $offset, $value ) {
641 $this->set( $offset, $value );
642 }
643
644 public function offsetUnset( $offset ) {
645 $this->remove( $offset );
646 }
647
648 /**@}*/
649
650 }