Session.php 18 KB

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