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