Session.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. <?php
  2. /**
  3. * @package Grav\Framework\Session
  4. *
  5. * @copyright Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Framework\Session;
  9. use ArrayIterator;
  10. use Exception;
  11. use Throwable;
  12. use Grav\Common\Debugger;
  13. use Grav\Common\Grav;
  14. use Grav\Common\User\Interfaces\UserInterface;
  15. use Grav\Framework\Session\Exceptions\SessionException;
  16. use RuntimeException;
  17. use function is_array;
  18. use function is_bool;
  19. use function is_string;
  20. /**
  21. * Class Session
  22. * @package Grav\Framework\Session
  23. */
  24. class Session implements SessionInterface
  25. {
  26. /** @var array */
  27. protected $options = [];
  28. /** @var bool */
  29. protected $started = false;
  30. /** @var Session */
  31. protected static $instance;
  32. /**
  33. * @inheritdoc
  34. */
  35. public static function getInstance()
  36. {
  37. if (null === self::$instance) {
  38. throw new RuntimeException("Session hasn't been initialized.", 500);
  39. }
  40. return self::$instance;
  41. }
  42. /**
  43. * Session constructor.
  44. *
  45. * @param array $options
  46. */
  47. public function __construct(array $options = [])
  48. {
  49. // Session is a singleton.
  50. if (\PHP_SAPI === 'cli') {
  51. self::$instance = $this;
  52. return;
  53. }
  54. if (null !== self::$instance) {
  55. throw new RuntimeException('Session has already been initialized.', 500);
  56. }
  57. // Destroy any existing sessions started with session.auto_start
  58. if ($this->isSessionStarted()) {
  59. session_unset();
  60. session_destroy();
  61. }
  62. // Set default options.
  63. $options += [
  64. 'cache_limiter' => 'nocache',
  65. 'use_trans_sid' => 0,
  66. 'use_cookies' => 1,
  67. 'lazy_write' => 1,
  68. 'use_strict_mode' => 1
  69. ];
  70. $this->setOptions($options);
  71. session_register_shutdown();
  72. self::$instance = $this;
  73. }
  74. /**
  75. * @inheritdoc
  76. */
  77. public function getId()
  78. {
  79. return session_id() ?: null;
  80. }
  81. /**
  82. * @inheritdoc
  83. */
  84. public function setId($id)
  85. {
  86. session_id($id);
  87. return $this;
  88. }
  89. /**
  90. * @inheritdoc
  91. */
  92. public function getName()
  93. {
  94. return session_name() ?: null;
  95. }
  96. /**
  97. * @inheritdoc
  98. */
  99. public function setName($name)
  100. {
  101. session_name($name);
  102. return $this;
  103. }
  104. /**
  105. * @inheritdoc
  106. */
  107. public function setOptions(array $options)
  108. {
  109. if (headers_sent() || \PHP_SESSION_ACTIVE === session_status()) {
  110. return;
  111. }
  112. $allowedOptions = [
  113. 'save_path' => true,
  114. 'name' => true,
  115. 'save_handler' => true,
  116. 'gc_probability' => true,
  117. 'gc_divisor' => true,
  118. 'gc_maxlifetime' => true,
  119. 'serialize_handler' => true,
  120. 'cookie_lifetime' => true,
  121. 'cookie_path' => true,
  122. 'cookie_domain' => true,
  123. 'cookie_secure' => true,
  124. 'cookie_httponly' => true,
  125. 'use_strict_mode' => true,
  126. 'use_cookies' => true,
  127. 'use_only_cookies' => true,
  128. 'cookie_samesite' => true,
  129. 'referer_check' => true,
  130. 'cache_limiter' => true,
  131. 'cache_expire' => true,
  132. 'use_trans_sid' => true,
  133. 'trans_sid_tags' => true,
  134. 'trans_sid_hosts' => true,
  135. 'sid_length' => true,
  136. 'sid_bits_per_character' => true,
  137. 'upload_progress.enabled' => true,
  138. 'upload_progress.cleanup' => true,
  139. 'upload_progress.prefix' => true,
  140. 'upload_progress.name' => true,
  141. 'upload_progress.freq' => true,
  142. 'upload_progress.min-freq' => true,
  143. 'lazy_write' => true
  144. ];
  145. foreach ($options as $key => $value) {
  146. if (is_array($value)) {
  147. // Allow nested options.
  148. foreach ($value as $key2 => $value2) {
  149. $ckey = "{$key}.{$key2}";
  150. if (isset($value2, $allowedOptions[$ckey])) {
  151. $this->setOption($ckey, $value2);
  152. }
  153. }
  154. } elseif (isset($value, $allowedOptions[$key])) {
  155. $this->setOption($key, $value);
  156. }
  157. }
  158. }
  159. /**
  160. * @inheritdoc
  161. */
  162. public function start($readonly = false)
  163. {
  164. if (\PHP_SAPI === 'cli') {
  165. return $this;
  166. }
  167. $sessionName = $this->getName();
  168. if (null === $sessionName) {
  169. return $this;
  170. }
  171. $sessionExists = isset($_COOKIE[$sessionName]);
  172. // Protection against invalid session cookie names throwing exception: http://php.net/manual/en/function.session-id.php#116836
  173. if ($sessionExists && !preg_match('/^[-,a-zA-Z0-9]{1,128}$/', $_COOKIE[$sessionName])) {
  174. unset($_COOKIE[$sessionName]);
  175. $sessionExists = false;
  176. }
  177. $options = $this->options;
  178. if ($readonly) {
  179. $options['read_and_close'] = '1';
  180. }
  181. try {
  182. $success = @session_start($options);
  183. if (!$success) {
  184. $last = error_get_last();
  185. $error = $last ? $last['message'] : 'Unknown error';
  186. throw new RuntimeException($error);
  187. }
  188. // Handle changing session id.
  189. if ($this->__isset('session_destroyed')) {
  190. $newId = $this->__get('session_new_id');
  191. if (!$newId || $this->__get('session_destroyed') < time() - 300) {
  192. // Should not happen usually. This could be attack or due to unstable network. Destroy this session.
  193. $this->invalidate();
  194. throw new RuntimeException('Obsolete session access.', 500);
  195. }
  196. // Not fully expired yet. Could be lost cookie by unstable network. Start session with new session id.
  197. session_write_close();
  198. // Start session with new session id.
  199. $useStrictMode = $options['use_strict_mode'] ?? 0;
  200. if ($useStrictMode) {
  201. ini_set('session.use_strict_mode', '0');
  202. }
  203. session_id($newId);
  204. if ($useStrictMode) {
  205. ini_set('session.use_strict_mode', '1');
  206. }
  207. $success = @session_start($options);
  208. if (!$success) {
  209. $last = error_get_last();
  210. $error = $last ? $last['message'] : 'Unknown error';
  211. throw new RuntimeException($error);
  212. }
  213. }
  214. } catch (Exception $e) {
  215. throw new SessionException('Failed to start session: ' . $e->getMessage(), 500);
  216. }
  217. $this->started = true;
  218. $this->onSessionStart();
  219. try {
  220. $user = $this->__get('user');
  221. if ($user && (!$user instanceof UserInterface || (method_exists($user, 'isValid') && !$user->isValid()))) {
  222. throw new RuntimeException('Bad user');
  223. }
  224. } catch (Throwable $e) {
  225. $this->invalidate();
  226. throw new SessionException('Invalid User object, session destroyed.', 500);
  227. }
  228. // Extend the lifetime of the session.
  229. if ($sessionExists) {
  230. $this->setCookie();
  231. }
  232. return $this;
  233. }
  234. /**
  235. * Regenerate session id but keep the current session information.
  236. *
  237. * Session id must be regenerated on login, logout or after long time has been passed.
  238. *
  239. * @return $this
  240. * @since 1.7
  241. */
  242. public function regenerateId()
  243. {
  244. if (!$this->isSessionStarted()) {
  245. return $this;
  246. }
  247. // TODO: session_create_id() segfaults in PHP 7.3 (PHP bug #73461), remove phpstan rule when removing this one.
  248. if (PHP_VERSION_ID < 70400) {
  249. $newId = 0;
  250. } else {
  251. // Session id creation may fail with some session storages.
  252. $newId = @session_create_id() ?: 0;
  253. }
  254. // Set destroyed timestamp for the old session as well as pointer to the new id.
  255. $this->__set('session_destroyed', time());
  256. $this->__set('session_new_id', $newId);
  257. // Keep the old session alive to avoid lost sessions by unstable network.
  258. if (!$newId) {
  259. /** @var Debugger $debugger */
  260. $debugger = Grav::instance()['debugger'];
  261. $debugger->addMessage('Session fixation lost session detection is turned of due to server limitations.', 'warning');
  262. session_regenerate_id(false);
  263. } else {
  264. session_write_close();
  265. // Start session with new session id.
  266. $useStrictMode = $this->options['use_strict_mode'] ?? 0;
  267. if ($useStrictMode) {
  268. ini_set('session.use_strict_mode', '0');
  269. }
  270. session_id($newId);
  271. if ($useStrictMode) {
  272. ini_set('session.use_strict_mode', '1');
  273. }
  274. $this->removeCookie();
  275. $this->onBeforeSessionStart();
  276. $success = @session_start($this->options);
  277. if (!$success) {
  278. $last = error_get_last();
  279. $error = $last ? $last['message'] : 'Unknown error';
  280. throw new RuntimeException($error);
  281. }
  282. $this->onSessionStart();
  283. }
  284. // New session does not have these.
  285. $this->__unset('session_destroyed');
  286. $this->__unset('session_new_id');
  287. return $this;
  288. }
  289. /**
  290. * @inheritdoc
  291. */
  292. public function invalidate()
  293. {
  294. $name = $this->getName();
  295. if (null !== $name) {
  296. $this->removeCookie();
  297. setcookie(
  298. $name,
  299. '',
  300. $this->getCookieOptions(-42000)
  301. );
  302. }
  303. if ($this->isSessionStarted()) {
  304. session_unset();
  305. session_destroy();
  306. }
  307. $this->started = false;
  308. return $this;
  309. }
  310. /**
  311. * @inheritdoc
  312. */
  313. public function close()
  314. {
  315. if ($this->started) {
  316. session_write_close();
  317. }
  318. $this->started = false;
  319. return $this;
  320. }
  321. /**
  322. * @inheritdoc
  323. */
  324. public function clear()
  325. {
  326. session_unset();
  327. return $this;
  328. }
  329. /**
  330. * @inheritdoc
  331. */
  332. public function getAll()
  333. {
  334. return $_SESSION;
  335. }
  336. /**
  337. * @inheritdoc
  338. */
  339. #[\ReturnTypeWillChange]
  340. public function getIterator()
  341. {
  342. return new ArrayIterator($_SESSION);
  343. }
  344. /**
  345. * @inheritdoc
  346. */
  347. public function isStarted()
  348. {
  349. return $this->started;
  350. }
  351. /**
  352. * @inheritdoc
  353. */
  354. #[\ReturnTypeWillChange]
  355. public function __isset($name)
  356. {
  357. return isset($_SESSION[$name]);
  358. }
  359. /**
  360. * @inheritdoc
  361. */
  362. #[\ReturnTypeWillChange]
  363. public function __get($name)
  364. {
  365. return $_SESSION[$name] ?? null;
  366. }
  367. /**
  368. * @inheritdoc
  369. */
  370. #[\ReturnTypeWillChange]
  371. public function __set($name, $value)
  372. {
  373. $_SESSION[$name] = $value;
  374. }
  375. /**
  376. * @inheritdoc
  377. */
  378. #[\ReturnTypeWillChange]
  379. public function __unset($name)
  380. {
  381. unset($_SESSION[$name]);
  382. }
  383. /**
  384. * http://php.net/manual/en/function.session-status.php#113468
  385. * Check if session is started nicely.
  386. * @return bool
  387. */
  388. protected function isSessionStarted()
  389. {
  390. return \PHP_SAPI !== 'cli' ? \PHP_SESSION_ACTIVE === session_status() : false;
  391. }
  392. protected function onBeforeSessionStart(): void
  393. {
  394. }
  395. protected function onSessionStart(): void
  396. {
  397. }
  398. /**
  399. * Store something in cookie temporarily.
  400. *
  401. * @param int|null $lifetime
  402. * @return array
  403. */
  404. public function getCookieOptions(int $lifetime = null): array
  405. {
  406. $params = session_get_cookie_params();
  407. return [
  408. 'expires' => time() + ($lifetime ?? $params['lifetime']),
  409. 'path' => $params['path'],
  410. 'domain' => $params['domain'],
  411. 'secure' => $params['secure'],
  412. 'httponly' => $params['httponly'],
  413. 'samesite' => $params['samesite']
  414. ];
  415. }
  416. /**
  417. * @return void
  418. */
  419. protected function setCookie(): void
  420. {
  421. $this->removeCookie();
  422. $sessionName = $this->getName();
  423. $sessionId = $this->getId();
  424. if (null === $sessionName || null === $sessionId) {
  425. return;
  426. }
  427. setcookie(
  428. $sessionName,
  429. $sessionId,
  430. $this->getCookieOptions()
  431. );
  432. }
  433. protected function removeCookie(): void
  434. {
  435. $search = " {$this->getName()}=";
  436. $cookies = [];
  437. $found = false;
  438. foreach (headers_list() as $header) {
  439. // Identify cookie headers
  440. if (strpos($header, 'Set-Cookie:') === 0) {
  441. // Add all but session cookie(s).
  442. if (!str_contains($header, $search)) {
  443. $cookies[] = $header;
  444. } else {
  445. $found = true;
  446. }
  447. }
  448. }
  449. // Nothing to do.
  450. if (false === $found) {
  451. return;
  452. }
  453. // Remove all cookies and put back all but session cookie.
  454. header_remove('Set-Cookie');
  455. foreach($cookies as $cookie) {
  456. header($cookie, false);
  457. }
  458. }
  459. /**
  460. * @param string $key
  461. * @param mixed $value
  462. * @return void
  463. */
  464. protected function setOption($key, $value)
  465. {
  466. if (!is_string($value)) {
  467. if (is_bool($value)) {
  468. $value = $value ? '1' : '0';
  469. } else {
  470. $value = (string)$value;
  471. }
  472. }
  473. $this->options[$key] = $value;
  474. ini_set("session.{$key}", $value);
  475. }
  476. }