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