Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / session / Session.php
blob16cb2ebb89989f1f69aec2cb331e03394b1cd487
1 <?php
2 /**
3 * MediaWiki session
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
20 * @file
21 * @ingroup Session
24 namespace MediaWiki\Session;
26 use BadMethodCallException;
27 use LogicException;
28 use MediaWiki\MainConfigNames;
29 use MediaWiki\MediaWikiServices;
30 use MediaWiki\Request\WebRequest;
31 use MediaWiki\User\User;
32 use MWRestrictions;
33 use Psr\Log\LoggerInterface;
34 use RuntimeException;
36 /**
37 * Manages data for an authenticated session
39 * A Session represents the fact that the current HTTP request is part of a
40 * session. There are two broad types of Sessions, based on whether they
41 * return true or false from self::canSetUser():
42 * * When true (mutable), the Session identifies multiple requests as part of
43 * a session generically, with no tie to a particular user.
44 * * When false (immutable), the Session identifies multiple requests as part
45 * of a session by identifying and authenticating the request itself as
46 * belonging to a particular user.
48 * The Session object also serves as a replacement for PHP's $_SESSION,
49 * managing access to per-session data.
51 * @ingroup Session
52 * @since 1.27
54 class Session implements \Countable, \Iterator, \ArrayAccess {
55 /** @var null|string[] Encryption algorithm to use */
56 private static $encryptionAlgorithm = null;
58 /** @var SessionBackend Session backend (can't be type-hinted, see DummySessionBackend in tests) */
59 private $backend;
61 /** @var int Session index */
62 private $index;
64 private LoggerInterface $logger;
66 /**
67 * @param SessionBackend $backend
68 * @param int $index
69 * @param LoggerInterface $logger
71 public function __construct( SessionBackend $backend, $index, LoggerInterface $logger ) {
72 $this->backend = $backend;
73 $this->index = $index;
74 $this->logger = $logger;
77 public function __destruct() {
78 $this->backend->deregisterSession( $this->index );
81 /**
82 * Returns the session ID
83 * @return string
85 public function getId() {
86 return $this->backend->getId();
89 /**
90 * Returns the SessionId object
91 * @internal For internal use by WebRequest
92 * @return SessionId
94 public function getSessionId() {
95 return $this->backend->getSessionId();
98 /**
99 * Changes the session ID
100 * @return string New ID (might be the same as the old)
102 public function resetId() {
103 return $this->backend->resetId();
107 * Fetch the SessionProvider for this session
108 * @return SessionProviderInterface
110 public function getProvider() {
111 return $this->backend->getProvider();
115 * Indicate whether this session is persisted across requests
117 * For example, if cookies are set.
119 * @return bool
121 public function isPersistent() {
122 return $this->backend->isPersistent();
126 * Make this session persisted across requests
128 * If the session is already persistent, equivalent to calling
129 * $this->renew().
131 public function persist() {
132 $this->backend->persist();
136 * Make this session not be persisted across requests
138 * This will remove persistence information (e.g. delete cookies)
139 * from the associated WebRequest(s), and delete session data in the
140 * backend. The session data will still be available via get() until
141 * the end of the request.
143 public function unpersist() {
144 $this->backend->unpersist();
148 * Indicate whether the user should be remembered independently of the
149 * session ID.
150 * @return bool
152 public function shouldRememberUser() {
153 return $this->backend->shouldRememberUser();
157 * Set whether the user should be remembered independently of the session
158 * ID.
159 * @param bool $remember
161 public function setRememberUser( $remember ) {
162 $this->backend->setRememberUser( $remember );
166 * Returns the request associated with this session
167 * @return WebRequest
169 public function getRequest() {
170 return $this->backend->getRequest( $this->index );
174 * Returns the authenticated user for this session
175 * @return User
177 public function getUser(): User {
178 return $this->backend->getUser();
182 * Fetch the rights allowed the user when this session is active.
183 * @return null|string[] Allowed user rights, or null to allow all.
185 public function getAllowedUserRights() {
186 return $this->backend->getAllowedUserRights();
190 * Fetch any restrictions imposed on logins or actions when this
191 * session is active.
192 * @return MWRestrictions|null
194 public function getRestrictions(): ?MWRestrictions {
195 return $this->backend->getRestrictions();
199 * Indicate whether the session user info can be changed
200 * @return bool
202 public function canSetUser() {
203 return $this->backend->canSetUser();
207 * Set a new user for this session
208 * @note This should only be called when the user has been authenticated
209 * @param User $user User to set on the session.
210 * This may become a "UserValue" in the future, or User may be refactored
211 * into such.
213 public function setUser( $user ) {
214 $this->backend->setUser( $user );
218 * Get a suggested username for the login form
219 * @return string|null
221 public function suggestLoginUsername() {
222 return $this->backend->suggestLoginUsername( $this->index );
226 * Get the expected value of the forceHTTPS cookie. This reflects whether
227 * session cookies were sent with the Secure attribute. If $wgForceHTTPS
228 * is true, the forceHTTPS cookie is not sent and this value is ignored.
230 * @return bool
232 public function shouldForceHTTPS() {
233 return $this->backend->shouldForceHTTPS();
237 * Set the value of the forceHTTPS cookie. This reflects whether session
238 * cookies were sent with the Secure attribute. If $wgForceHTTPS is true,
239 * the forceHTTPS cookie is not sent, and this value is ignored.
241 * @param bool $force
243 public function setForceHTTPS( $force ) {
244 $this->backend->setForceHTTPS( $force );
248 * Fetch the "logged out" timestamp
249 * @return int
251 public function getLoggedOutTimestamp() {
252 return $this->backend->getLoggedOutTimestamp();
256 * @param int $ts
258 public function setLoggedOutTimestamp( $ts ) {
259 $this->backend->setLoggedOutTimestamp( $ts );
263 * Fetch provider metadata
264 * @note For use by SessionProvider subclasses only
265 * @return mixed
267 public function getProviderMetadata() {
268 return $this->backend->getProviderMetadata();
272 * Delete all session data and clear the user (if possible)
274 public function clear() {
275 $data = &$this->backend->getData();
276 if ( $data ) {
277 $data = [];
278 $this->backend->dirty();
280 if ( $this->backend->canSetUser() ) {
281 $this->backend->setUser( MediaWikiServices::getInstance()->getUserFactory()->newAnonymous() );
283 $this->backend->save();
287 * Resets the TTL in the backend store if the session is near expiring, and
288 * re-persists the session to any active WebRequests if persistent.
290 public function renew() {
291 $this->backend->renew();
295 * Fetch a copy of this session attached to an alternative WebRequest
297 * Actions on the copy will affect this session too, and vice versa.
299 * @param WebRequest $request Any existing session associated with this
300 * WebRequest object will be overwritten.
301 * @return Session
303 public function sessionWithRequest( WebRequest $request ) {
304 $request->setSessionId( $this->backend->getSessionId() );
305 return $this->backend->getSession( $request );
309 * Fetch a value from the session
310 * @param string|int $key
311 * @param mixed|null $default Returned if $this->exists( $key ) would be false
312 * @return mixed
314 public function get( $key, $default = null ) {
315 $data = &$this->backend->getData();
316 return array_key_exists( $key, $data ) ? $data[$key] : $default;
320 * Test if a value exists in the session
321 * @note Unlike isset(), null values are considered to exist.
322 * @param string|int $key
323 * @return bool
325 public function exists( $key ) {
326 $data = &$this->backend->getData();
327 return array_key_exists( $key, $data );
331 * Set a value in the session
332 * @param string|int $key
333 * @param mixed $value
335 public function set( $key, $value ) {
336 $data = &$this->backend->getData();
337 if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) {
338 $data[$key] = $value;
339 $this->backend->dirty();
344 * Remove a value from the session
345 * @param string|int $key
347 public function remove( $key ) {
348 $data = &$this->backend->getData();
349 if ( array_key_exists( $key, $data ) ) {
350 unset( $data[$key] );
351 $this->backend->dirty();
356 * Check if a CSRF token is set for the session
358 * @since 1.37
359 * @param string $key Token key
360 * @return bool
362 public function hasToken( string $key = 'default' ): bool {
363 $secrets = $this->get( 'wsTokenSecrets' );
364 if ( !is_array( $secrets ) ) {
365 return false;
367 return isset( $secrets[$key] ) && is_string( $secrets[$key] );
371 * Fetch a CSRF token from the session
373 * Note that this does not persist the session, which you'll probably want
374 * to do if you want the token to actually be useful.
376 * @param string|string[] $salt Token salt
377 * @param string $key Token key
378 * @return Token
380 public function getToken( $salt = '', $key = 'default' ) {
381 $new = false;
382 $secrets = $this->get( 'wsTokenSecrets' );
383 if ( !is_array( $secrets ) ) {
384 $secrets = [];
386 if ( isset( $secrets[$key] ) && is_string( $secrets[$key] ) ) {
387 $secret = $secrets[$key];
388 } else {
389 $secret = \MWCryptRand::generateHex( 32 );
390 $secrets[$key] = $secret;
391 $this->set( 'wsTokenSecrets', $secrets );
392 $new = true;
394 if ( is_array( $salt ) ) {
395 $salt = implode( '|', $salt );
397 return new Token( $secret, (string)$salt, $new );
401 * Remove a CSRF token from the session
403 * The next call to self::getToken() with $key will generate a new secret.
405 * @param string $key Token key
407 public function resetToken( $key = 'default' ) {
408 $secrets = $this->get( 'wsTokenSecrets' );
409 if ( is_array( $secrets ) && isset( $secrets[$key] ) ) {
410 unset( $secrets[$key] );
411 $this->set( 'wsTokenSecrets', $secrets );
416 * Remove all CSRF tokens from the session
418 public function resetAllTokens() {
419 $this->remove( 'wsTokenSecrets' );
423 * Fetch the secret keys for self::setSecret() and self::getSecret().
424 * @return string[] Encryption key, HMAC key
426 private function getSecretKeys() {
427 $mainConfig = MediaWikiServices::getInstance()->getMainConfig();
428 $sessionSecret = $mainConfig->get( MainConfigNames::SessionSecret );
429 $secretKey = $mainConfig->get( MainConfigNames::SecretKey );
430 $sessionPbkdf2Iterations = $mainConfig->get( MainConfigNames::SessionPbkdf2Iterations );
431 $wikiSecret = $sessionSecret ?: $secretKey;
432 $userSecret = $this->get( 'wsSessionSecret', null );
433 if ( $userSecret === null ) {
434 $userSecret = \MWCryptRand::generateHex( 32 );
435 $this->set( 'wsSessionSecret', $userSecret );
437 $iterations = $this->get( 'wsSessionPbkdf2Iterations', null );
438 if ( $iterations === null ) {
439 $iterations = $sessionPbkdf2Iterations;
440 $this->set( 'wsSessionPbkdf2Iterations', $iterations );
443 $keymats = openssl_pbkdf2( $wikiSecret, $userSecret, 64, $iterations, 'sha256' );
444 return [
445 substr( $keymats, 0, 32 ),
446 substr( $keymats, 32, 32 ),
451 * Decide what type of encryption to use, based on system capabilities.
452 * @return array
454 private static function getEncryptionAlgorithm() {
455 if ( self::$encryptionAlgorithm === null ) {
456 if ( function_exists( 'openssl_encrypt' ) ) {
457 $methods = openssl_get_cipher_methods();
458 if ( in_array( 'aes-256-ctr', $methods, true ) ) {
459 self::$encryptionAlgorithm = [ 'openssl', 'aes-256-ctr' ];
460 return self::$encryptionAlgorithm;
462 if ( in_array( 'aes-256-cbc', $methods, true ) ) {
463 self::$encryptionAlgorithm = [ 'openssl', 'aes-256-cbc' ];
464 return self::$encryptionAlgorithm;
468 throw new BadMethodCallException(
469 'Encryption is not available. You need to install the PHP OpenSSL extension.'
473 return self::$encryptionAlgorithm;
477 * Set a value in the session, encrypted
479 * This relies on the secrecy of $wgSecretKey (by default), or $wgSessionSecret.
481 * @param string|int $key
482 * @param mixed $value
484 public function setSecret( $key, $value ) {
485 [ $encKey, $hmacKey ] = $this->getSecretKeys();
486 $serialized = serialize( $value );
488 // The code for encryption (with OpenSSL) and sealing is taken from
489 // Chris Steipp's OATHAuthUtils class in Extension::OATHAuth.
491 // Encrypt
492 $iv = random_bytes( 16 );
493 $algorithm = self::getEncryptionAlgorithm();
494 switch ( $algorithm[0] ) {
495 case 'openssl':
496 $ciphertext = openssl_encrypt( $serialized, $algorithm[1], $encKey, OPENSSL_RAW_DATA, $iv );
497 if ( $ciphertext === false ) {
498 throw new \UnexpectedValueException( 'Encryption failed: ' . openssl_error_string() );
500 break;
501 default:
502 throw new LogicException( 'invalid algorithm' );
505 // Seal
506 $sealed = base64_encode( $iv ) . '.' . base64_encode( $ciphertext );
507 $hmac = hash_hmac( 'sha256', $sealed, $hmacKey, true );
508 $encrypted = base64_encode( $hmac ) . '.' . $sealed;
510 // Store
511 $this->set( $key, $encrypted );
515 * Fetch a value from the session that was set with self::setSecret()
516 * @param string|int $key
517 * @param mixed|null $default Returned if $this->exists( $key ) would be false or decryption fails
518 * @return mixed
520 public function getSecret( $key, $default = null ) {
521 // Fetch
522 $encrypted = $this->get( $key, null );
523 if ( $encrypted === null ) {
524 return $default;
527 // The code for unsealing, checking, and decrypting (with OpenSSL) is
528 // taken from Chris Steipp's OATHAuthUtils class in
529 // Extension::OATHAuth.
531 // Unseal and check
532 $pieces = explode( '.', $encrypted, 4 );
533 if ( count( $pieces ) !== 3 ) {
534 $ex = new RuntimeException( 'Invalid sealed-secret format' );
535 $this->logger->warning( $ex->getMessage(), [ 'exception' => $ex ] );
536 return $default;
538 [ $hmac, $iv, $ciphertext ] = $pieces;
539 [ $encKey, $hmacKey ] = $this->getSecretKeys();
540 $integCalc = hash_hmac( 'sha256', $iv . '.' . $ciphertext, $hmacKey, true );
541 if ( !hash_equals( $integCalc, base64_decode( $hmac ) ) ) {
542 $ex = new RuntimeException( 'Sealed secret has been tampered with, aborting.' );
543 $this->logger->warning( $ex->getMessage(), [ 'exception' => $ex ] );
544 return $default;
547 // Decrypt
548 $algorithm = self::getEncryptionAlgorithm();
549 switch ( $algorithm[0] ) {
550 case 'openssl':
551 $serialized = openssl_decrypt( base64_decode( $ciphertext ), $algorithm[1], $encKey,
552 OPENSSL_RAW_DATA, base64_decode( $iv ) );
553 if ( $serialized === false ) {
554 $ex = new RuntimeException( 'Decyption failed: ' . openssl_error_string() );
555 $this->logger->debug( $ex->getMessage(), [ 'exception' => $ex ] );
556 return $default;
558 break;
559 default:
560 throw new \LogicException( 'invalid algorithm' );
563 $value = unserialize( $serialized );
564 if ( $value === false && $serialized !== serialize( false ) ) {
565 $value = $default;
567 return $value;
571 * Delay automatic saving while multiple updates are being made
573 * Calls to save() or clear() will not be delayed.
575 * @return \Wikimedia\ScopedCallback When this goes out of scope, a save will be triggered
577 public function delaySave() {
578 return $this->backend->delaySave();
582 * This will update the backend data and might re-persist the session
583 * if needed.
585 public function save() {
586 $this->backend->save();
589 // region Interface methods
590 /** @name Interface methods
591 * @{
594 /** @inheritDoc */
595 public function count(): int {
596 $data = &$this->backend->getData();
597 return count( $data );
600 /** @inheritDoc */
601 #[\ReturnTypeWillChange]
602 public function current() {
603 $data = &$this->backend->getData();
604 return current( $data );
607 /** @inheritDoc */
608 #[\ReturnTypeWillChange]
609 public function key() {
610 $data = &$this->backend->getData();
611 return key( $data );
614 /** @inheritDoc */
615 public function next(): void {
616 $data = &$this->backend->getData();
617 next( $data );
620 /** @inheritDoc */
621 public function rewind(): void {
622 $data = &$this->backend->getData();
623 reset( $data );
626 /** @inheritDoc */
627 public function valid(): bool {
628 $data = &$this->backend->getData();
629 return key( $data ) !== null;
633 * @note Despite the name, this seems to be intended to implement isset()
634 * rather than array_key_exists(). So do that.
635 * @inheritDoc
637 public function offsetExists( $offset ): bool {
638 $data = &$this->backend->getData();
639 return isset( $data[$offset] );
643 * @note This supports indirect modifications but can't mark the session
644 * dirty when those happen. SessionBackend::save() checks the hash of the
645 * data to detect such changes.
646 * @note Accessing a nonexistent key via this mechanism causes that key to
647 * be created with a null value, and does not raise a PHP warning.
648 * @inheritDoc
650 #[\ReturnTypeWillChange]
651 public function &offsetGet( $offset ) {
652 $data = &$this->backend->getData();
653 if ( !array_key_exists( $offset, $data ) ) {
654 $ex = new LogicException( "Undefined index (auto-adds to session with a null value): $offset" );
655 $this->logger->debug( $ex->getMessage(), [ 'exception' => $ex ] );
657 return $data[$offset];
660 /** @inheritDoc */
661 public function offsetSet( $offset, $value ): void {
662 $this->set( $offset, $value );
665 /** @inheritDoc */
666 public function offsetUnset( $offset ): void {
667 $this->remove( $offset );
670 /** @} */
671 // endregion -- end of Interface methods