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.
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.
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
24 namespace MediaWiki\Session
;
26 use Psr\Log\LoggerInterface
;
31 * Manages data for an an authenticated session
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.
42 * The Session object also serves as a replacement for PHP's $_SESSION,
43 * managing access to per-session data.
48 final class Session
implements \Countable
, \Iterator
, \ArrayAccess
{
49 /** @var SessionBackend Session backend */
52 /** @var int Session index */
55 /** @var LoggerInterface */
59 * @param SessionBackend $backend
61 * @param LoggerInterface $logger
63 public function __construct( SessionBackend
$backend, $index, LoggerInterface
$logger ) {
64 $this->backend
= $backend;
65 $this->index
= $index;
66 $this->logger
= $logger;
69 public function __destruct() {
70 $this->backend
->deregisterSession( $this->index
);
74 * Returns the session ID
77 public function getId() {
78 return $this->backend
->getId();
82 * Returns the SessionId object
83 * @private For internal use by WebRequest
86 public function getSessionId() {
87 return $this->backend
->getSessionId();
91 * Changes the session ID
92 * @return string New ID (might be the same as the old)
94 public function resetId() {
95 return $this->backend
->resetId();
99 * Fetch the SessionProvider for this session
100 * @return SessionProviderInterface
102 public function getProvider() {
103 return $this->backend
->getProvider();
107 * Indicate whether this session is persisted across requests
109 * For example, if cookies are set.
113 public function isPersistent() {
114 return $this->backend
->isPersistent();
118 * Make this session persisted across requests
120 * If the session is already persistent, equivalent to calling
123 public function persist() {
124 $this->backend
->persist();
128 * Make this session not be persisted across requests
130 public function unpersist() {
131 $this->backend
->unpersist();
135 * Indicate whether the user should be remembered independently of the
139 public function shouldRememberUser() {
140 return $this->backend
->shouldRememberUser();
144 * Set whether the user should be remembered independently of the session
146 * @param bool $remember
148 public function setRememberUser( $remember ) {
149 $this->backend
->setRememberUser( $remember );
153 * Returns the request associated with this session
156 public function getRequest() {
157 return $this->backend
->getRequest( $this->index
);
161 * Returns the authenticated user for this session
164 public function getUser() {
165 return $this->backend
->getUser();
169 * Fetch the rights allowed the user when this session is active.
170 * @return null|string[] Allowed user rights, or null to allow all.
172 public function getAllowedUserRights() {
173 return $this->backend
->getAllowedUserRights();
177 * Indicate whether the session user info can be changed
180 public function canSetUser() {
181 return $this->backend
->canSetUser();
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
191 public function setUser( $user ) {
192 $this->backend
->setUser( $user );
196 * Get a suggested username for the login form
197 * @return string|null
199 public function suggestLoginUsername() {
200 return $this->backend
->suggestLoginUsername( $this->index
);
204 * Whether HTTPS should be forced
207 public function shouldForceHTTPS() {
208 return $this->backend
->shouldForceHTTPS();
212 * Set whether HTTPS should be forced
215 public function setForceHTTPS( $force ) {
216 $this->backend
->setForceHTTPS( $force );
220 * Fetch the "logged out" timestamp
223 public function getLoggedOutTimestamp() {
224 return $this->backend
->getLoggedOutTimestamp();
228 * Set the "logged out" timestamp
231 public function setLoggedOutTimestamp( $ts ) {
232 $this->backend
->setLoggedOutTimestamp( $ts );
236 * Fetch provider metadata
237 * @protected For use by SessionProvider subclasses only
240 public function getProviderMetadata() {
241 return $this->backend
->getProviderMetadata();
245 * Delete all session data and clear the user (if possible)
247 public function clear() {
248 $data = &$this->backend
->getData();
251 $this->backend
->dirty();
253 if ( $this->backend
->canSetUser() ) {
254 $this->backend
->setUser( new User
);
256 $this->backend
->save();
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.
265 public function renew() {
266 $this->backend
->renew();
270 * Fetch a copy of this session attached to an alternative WebRequest
272 * Actions on the copy will affect this session too, and vice versa.
274 * @param WebRequest $request Any existing session associated with this
275 * WebRequest object will be overwritten.
278 public function sessionWithRequest( WebRequest
$request ) {
279 $request->setSessionId( $this->backend
->getSessionId() );
280 return $this->backend
->getSession( $request );
284 * Fetch a value from the session
285 * @param string|int $key
286 * @param mixed $default Returned if $this->exists( $key ) would be false
289 public function get( $key, $default = null ) {
290 $data = &$this->backend
->getData();
291 return array_key_exists( $key, $data ) ?
$data[$key] : $default;
295 * Test if a value exists in the session
296 * @note Unlike isset(), null values are considered to exist.
297 * @param string|int $key
300 public function exists( $key ) {
301 $data = &$this->backend
->getData();
302 return array_key_exists( $key, $data );
306 * Set a value in the session
307 * @param string|int $key
308 * @param mixed $value
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();
319 * Remove a value from the session
320 * @param string|int $key
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();
331 * Fetch a CSRF token from the session
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.
336 * @param string|string[] $salt Token salt
337 * @param string $key Token key
340 public function getToken( $salt = '', $key = 'default' ) {
342 $secrets = $this->get( 'wsTokenSecrets' );
343 if ( !is_array( $secrets ) ) {
346 if ( isset( $secrets[$key] ) && is_string( $secrets[$key] ) ) {
347 $secret = $secrets[$key];
349 $secret = \MWCryptRand
::generateHex( 32 );
350 $secrets[$key] = $secret;
351 $this->set( 'wsTokenSecrets', $secrets );
354 if ( is_array( $salt ) ) {
355 $salt = implode( '|', $salt );
357 return new Token( $secret, (string)$salt, $new );
361 * Remove a CSRF token from the session
363 * The next call to self::getToken() with $key will generate a new secret.
365 * @param string $key Token key
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 );
376 * Remove all CSRF tokens from the session
378 public function resetAllTokens() {
379 $this->remove( 'wsTokenSecrets' );
383 * Fetch the secret keys for self::setSecret() and self::getSecret().
384 * @return string[] Encryption key, HMAC key
386 private function getSecretKeys() {
387 global $wgSessionSecret, $wgSecretKey;
389 $wikiSecret = $wgSessionSecret ?
: $wgSecretKey;
390 $userSecret = $this->get( 'wsSessionSecret', null );
391 if ( $userSecret === null ) {
392 $userSecret = \MWCryptRand
::generateHex( 32 );
393 $this->set( 'wsSessionSecret', $userSecret );
396 $keymats = hash_pbkdf2( 'sha256', $wikiSecret, $userSecret, 10001, 64, true );
398 substr( $keymats, 0, 32 ),
399 substr( $keymats, 32, 32 ),
404 * Set a value in the session, encrypted
406 * This relies on the secrecy of $wgSecretKey (by default), or $wgSessionSecret.
408 * @param string|int $key
409 * @param mixed $value
411 public function setSecret( $key, $value ) {
412 global $wgSessionInsecureSecrets;
414 list( $encKey, $hmacKey ) = $this->getSecretKeys();
415 $serialized = serialize( $value );
417 // The code for encryption (with OpenSSL) and sealing is taken from
418 // Chris Steipp's OATHAuthUtils class in Extension::OATHAuth.
421 // @todo: import a pure-PHP library for AES instead of doing $wgSessionInsecureSecrets
422 $iv = \MWCryptRand
::generate( 16, true );
423 if ( function_exists( 'openssl_encrypt' ) ) {
424 $ciphertext = openssl_encrypt( $serialized, 'aes-256-ctr', $encKey, OPENSSL_RAW_DATA
, $iv );
425 if ( $ciphertext === false ) {
426 throw new UnexpectedValueException( 'Encryption failed: ' . openssl_error_string() );
428 } elseif ( function_exists( 'mcrypt_encrypt' ) ) {
429 $ciphertext = mcrypt_encrypt( 'rijndael-128', $encKey, $serialized, 'ctr', $iv );
430 if ( $ciphertext === false ) {
431 throw new UnexpectedValueException( 'Encryption failed' );
433 } elseif ( $wgSessionInsecureSecrets ) {
434 $ex = new \
Exception( 'No encryption is available, storing data as plain text' );
435 $this->logger
->warning( $ex->getMessage(), [ 'exception' => $ex ] );
436 $ciphertext = $serialized;
438 throw new \
BadMethodCallException(
439 'Encryption is not available. You really should install the PHP OpenSSL extension, ' .
440 'or failing that the mcrypt extension. But if you really can\'t and you\'re willing ' .
441 'to accept insecure storage of sensitive session data, set ' .
442 '$wgSessionInsecureSecrets = true in LocalSettings.php to make this exception go away.'
447 $sealed = base64_encode( $iv ) . '.' . base64_encode( $ciphertext );
448 $hmac = hash_hmac( 'sha256', $sealed, $hmacKey, true );
449 $encrypted = base64_encode( $hmac ) . '.' . $sealed;
452 $this->set( $key, $encrypted );
456 * Fetch a value from the session that was set with self::setSecret()
457 * @param string|int $key
458 * @param mixed $default Returned if $this->exists( $key ) would be false or decryption fails
461 public function getSecret( $key, $default = null ) {
462 global $wgSessionInsecureSecrets;
465 $encrypted = $this->get( $key, null );
466 if ( $encrypted === null ) {
470 // The code for unsealing, checking, and decrypting (with OpenSSL) is
471 // taken from Chris Steipp's OATHAuthUtils class in
472 // Extension::OATHAuth.
475 $pieces = explode( '.', $encrypted );
476 if ( count( $pieces ) !== 3 ) {
477 $ex = new \
Exception( 'Invalid sealed-secret format' );
478 $this->logger
->warning( $ex->getMessage(), [ 'exception' => $ex ] );
481 list( $hmac, $iv, $ciphertext ) = $pieces;
482 list( $encKey, $hmacKey ) = $this->getSecretKeys();
483 $integCalc = hash_hmac( 'sha256', $iv . '.' . $ciphertext, $hmacKey, true );
484 if ( !hash_equals( $integCalc, base64_decode( $hmac ) ) ) {
485 $ex = new \
Exception( 'Sealed secret has been tampered with, aborting.' );
486 $this->logger
->warning( $ex->getMessage(), [ 'exception' => $ex ] );
491 // @todo: import a pure-PHP library for AES instead of doing $wgSessionInsecureSecrets
492 if ( function_exists( 'openssl_decrypt' ) ) {
493 $serialized = openssl_decrypt(
494 base64_decode( $ciphertext ), 'aes-256-ctr', $encKey, OPENSSL_RAW_DATA
, base64_decode( $iv )
496 if ( $serialized === false ) {
497 $ex = new \
Exception( 'Decyption failed: ' . openssl_error_string() );
498 $this->logger
->debug( $ex->getMessage(), [ 'exception' => $ex ] );
501 } elseif ( function_exists( 'mcrypt_decrypt' ) ) {
502 $serialized = mcrypt_decrypt(
503 'rijndael-128', $encKey, base64_decode( $ciphertext ), 'ctr', base64_decode( $iv )
505 if ( $serialized === false ) {
506 $ex = new \
Exception( 'Decyption failed' );
507 $this->logger
->debug( $ex->getMessage(), [ 'exception' => $ex ] );
510 } elseif ( $wgSessionInsecureSecrets ) {
511 $ex = new \
Exception(
512 'No encryption is available, retrieving data that was stored as plain text'
514 $this->logger
->warning( $ex->getMessage(), [ 'exception' => $ex ] );
515 $serialized = base64_decode( $ciphertext );
517 throw new \
BadMethodCallException(
518 'Encryption is not available. You really should install the PHP OpenSSL extension, ' .
519 'or failing that the mcrypt extension. But if you really can\'t and you\'re willing ' .
520 'to accept insecure storage of sensitive session data, set ' .
521 '$wgSessionInsecureSecrets = true in LocalSettings.php to make this exception go away.'
525 $value = unserialize( $serialized );
526 if ( $value === false && $serialized !== serialize( false ) ) {
533 * Delay automatic saving while multiple updates are being made
535 * Calls to save() or clear() will not be delayed.
537 * @return \ScopedCallback When this goes out of scope, a save will be triggered
539 public function delaySave() {
540 return $this->backend
->delaySave();
546 public function save() {
547 $this->backend
->save();
551 * @name Interface methods
555 public function count() {
556 $data = &$this->backend
->getData();
557 return count( $data );
560 public function current() {
561 $data = &$this->backend
->getData();
562 return current( $data );
565 public function key() {
566 $data = &$this->backend
->getData();
570 public function next() {
571 $data = &$this->backend
->getData();
575 public function rewind() {
576 $data = &$this->backend
->getData();
580 public function valid() {
581 $data = &$this->backend
->getData();
582 return key( $data ) !== null;
586 * @note Despite the name, this seems to be intended to implement isset()
587 * rather than array_key_exists(). So do that.
589 public function offsetExists( $offset ) {
590 $data = &$this->backend
->getData();
591 return isset( $data[$offset] );
595 * @note This supports indirect modifications but can't mark the session
596 * dirty when those happen. SessionBackend::save() checks the hash of the
597 * data to detect such changes.
598 * @note Accessing a nonexistent key via this mechanism causes that key to
599 * be created with a null value, and does not raise a PHP warning.
601 public function &offsetGet( $offset ) {
602 $data = &$this->backend
->getData();
603 if ( !array_key_exists( $offset, $data ) ) {
604 $ex = new \
Exception( "Undefined index (auto-adds to session with a null value): $offset" );
605 $this->logger
->debug( $ex->getMessage(), [ 'exception' => $ex ] );
607 return $data[$offset];
610 public function offsetSet( $offset, $value ) {
611 $this->set( $offset, $value );
614 public function offsetUnset( $offset ) {
615 $this->remove( $offset );