vendor/symfony/http-kernel/EventListener/AbstractSessionListener.php line 108

Open in your IDE?
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\HttpKernel\EventListener;
  11. use Psr\Container\ContainerInterface;
  12. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  13. use Symfony\Component\HttpFoundation\Cookie;
  14. use Symfony\Component\HttpFoundation\Session\Session;
  15. use Symfony\Component\HttpFoundation\Session\SessionInterface;
  16. use Symfony\Component\HttpFoundation\Session\SessionUtils;
  17. use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
  18. use Symfony\Component\HttpKernel\Event\RequestEvent;
  19. use Symfony\Component\HttpKernel\Event\ResponseEvent;
  20. use Symfony\Component\HttpKernel\Exception\UnexpectedSessionUsageException;
  21. use Symfony\Component\HttpKernel\KernelEvents;
  22. use Symfony\Contracts\Service\ResetInterface;
  23. /**
  24. * Sets the session onto the request on the "kernel.request" event and saves
  25. * it on the "kernel.response" event.
  26. *
  27. * In addition, if the session has been started it overrides the Cache-Control
  28. * header in such a way that all caching is disabled in that case.
  29. * If you have a scenario where caching responses with session information in
  30. * them makes sense, you can disable this behaviour by setting the header
  31. * AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER on the response.
  32. *
  33. * @author Johannes M. Schmitt <schmittjoh@gmail.com>
  34. * @author Tobias Schultze <http://tobion.de>
  35. */
  36. abstract class AbstractSessionListener implements EventSubscriberInterface, ResetInterface
  37. {
  38. public const NO_AUTO_CACHE_CONTROL_HEADER = 'Symfony-Session-NoAutoCacheControl';
  39. /**
  40. * @internal
  41. */
  42. protected $container;
  43. private $sessionUsageStack = [];
  44. private $debug;
  45. /**
  46. * @var array<string, mixed>
  47. */
  48. private $sessionOptions;
  49. /**
  50. * @internal
  51. */
  52. public function __construct(?ContainerInterface $container = null, bool $debug = false, array $sessionOptions = [])
  53. {
  54. $this->container = $container;
  55. $this->debug = $debug;
  56. $this->sessionOptions = $sessionOptions;
  57. }
  58. /**
  59. * @internal
  60. */
  61. public function onKernelRequest(RequestEvent $event)
  62. {
  63. if (!$event->isMainRequest()) {
  64. return;
  65. }
  66. $request = $event->getRequest();
  67. if (!$request->hasSession()) {
  68. // This variable prevents calling `$this->getSession()` twice in case the Request (and the below factory) is cloned
  69. $sess = null;
  70. $request->setSessionFactory(function () use (&$sess, $request) {
  71. if (!$sess) {
  72. $sess = $this->getSession();
  73. $request->setSession($sess);
  74. /*
  75. * For supporting sessions in php runtime with runners like roadrunner or swoole, the session
  76. * cookie needs to be read from the cookie bag and set on the session storage.
  77. *
  78. * Do not set it when a native php session is active.
  79. */
  80. if ($sess && !$sess->isStarted() && \PHP_SESSION_ACTIVE !== session_status()) {
  81. $sessionId = $sess->getId() ?: $request->cookies->get($sess->getName(), '');
  82. $sess->setId($sessionId);
  83. }
  84. }
  85. return $sess;
  86. });
  87. }
  88. $session = $this->container && $this->container->has('initialized_session') ? $this->container->get('initialized_session') : null;
  89. $this->sessionUsageStack[] = $session instanceof Session ? $session->getUsageIndex() : 0;
  90. }
  91. /**
  92. * @internal
  93. */
  94. public function onKernelResponse(ResponseEvent $event)
  95. {
  96. if (!$event->isMainRequest() || (!$this->container->has('initialized_session') && !$event->getRequest()->hasSession())) {
  97. return;
  98. }
  99. $response = $event->getResponse();
  100. $autoCacheControl = !$response->headers->has(self::NO_AUTO_CACHE_CONTROL_HEADER);
  101. // Always remove the internal header if present
  102. $response->headers->remove(self::NO_AUTO_CACHE_CONTROL_HEADER);
  103. if (!$session = $this->container && $this->container->has('initialized_session') ? $this->container->get('initialized_session') : ($event->getRequest()->hasSession() ? $event->getRequest()->getSession() : null)) {
  104. return;
  105. }
  106. if ($session->isStarted()) {
  107. /*
  108. * Saves the session, in case it is still open, before sending the response/headers.
  109. *
  110. * This ensures several things in case the developer did not save the session explicitly:
  111. *
  112. * * If a session save handler without locking is used, it ensures the data is available
  113. * on the next request, e.g. after a redirect. PHPs auto-save at script end via
  114. * session_register_shutdown is executed after fastcgi_finish_request. So in this case
  115. * the data could be missing the next request because it might not be saved the moment
  116. * the new request is processed.
  117. * * A locking save handler (e.g. the native 'files') circumvents concurrency problems like
  118. * the one above. But by saving the session before long-running things in the terminate event,
  119. * we ensure the session is not blocked longer than needed.
  120. * * When regenerating the session ID no locking is involved in PHPs session design. See
  121. * https://bugs.php.net/61470 for a discussion. So in this case, the session must
  122. * be saved anyway before sending the headers with the new session ID. Otherwise session
  123. * data could get lost again for concurrent requests with the new ID. One result could be
  124. * that you get logged out after just logging in.
  125. *
  126. * This listener should be executed as one of the last listeners, so that previous listeners
  127. * can still operate on the open session. This prevents the overhead of restarting it.
  128. * Listeners after closing the session can still work with the session as usual because
  129. * Symfonys session implementation starts the session on demand. So writing to it after
  130. * it is saved will just restart it.
  131. */
  132. $session->save();
  133. /*
  134. * For supporting sessions in php runtime with runners like roadrunner or swoole the session
  135. * cookie need to be written on the response object and should not be written by PHP itself.
  136. */
  137. $sessionName = $session->getName();
  138. $sessionId = $session->getId();
  139. $sessionOptions = $this->getSessionOptions($this->sessionOptions);
  140. $sessionCookiePath = $sessionOptions['cookie_path'] ?? '/';
  141. $sessionCookieDomain = $sessionOptions['cookie_domain'] ?? null;
  142. $sessionCookieSecure = $sessionOptions['cookie_secure'] ?? false;
  143. $sessionCookieHttpOnly = $sessionOptions['cookie_httponly'] ?? true;
  144. $sessionCookieSameSite = $sessionOptions['cookie_samesite'] ?? Cookie::SAMESITE_LAX;
  145. $sessionUseCookies = $sessionOptions['use_cookies'] ?? true;
  146. SessionUtils::popSessionCookie($sessionName, $sessionId);
  147. if ($sessionUseCookies) {
  148. $request = $event->getRequest();
  149. $requestSessionCookieId = $request->cookies->get($sessionName);
  150. $isSessionEmpty = $session->isEmpty() && empty($_SESSION); // checking $_SESSION to keep compatibility with native sessions
  151. if ($requestSessionCookieId && $isSessionEmpty) {
  152. // PHP internally sets the session cookie value to "deleted" when setcookie() is called with empty string $value argument
  153. // which happens in \Symfony\Component\HttpFoundation\Session\Storage\Handler\AbstractSessionHandler::destroy
  154. // when the session gets invalidated (for example on logout) so we must handle this case here too
  155. // otherwise we would send two Set-Cookie headers back with the response
  156. SessionUtils::popSessionCookie($sessionName, 'deleted');
  157. $response->headers->clearCookie(
  158. $sessionName,
  159. $sessionCookiePath,
  160. $sessionCookieDomain,
  161. $sessionCookieSecure,
  162. $sessionCookieHttpOnly,
  163. $sessionCookieSameSite
  164. );
  165. } elseif ($sessionId !== $requestSessionCookieId && !$isSessionEmpty) {
  166. $expire = 0;
  167. $lifetime = $sessionOptions['cookie_lifetime'] ?? null;
  168. if ($lifetime) {
  169. $expire = time() + $lifetime;
  170. }
  171. $response->headers->setCookie(
  172. Cookie::create(
  173. $sessionName,
  174. $sessionId,
  175. $expire,
  176. $sessionCookiePath,
  177. $sessionCookieDomain,
  178. $sessionCookieSecure,
  179. $sessionCookieHttpOnly,
  180. false,
  181. $sessionCookieSameSite
  182. )
  183. );
  184. }
  185. }
  186. }
  187. if ($session instanceof Session ? $session->getUsageIndex() === end($this->sessionUsageStack) : !$session->isStarted()) {
  188. return;
  189. }
  190. if ($autoCacheControl) {
  191. $maxAge = $response->headers->hasCacheControlDirective('public') ? 0 : (int) $response->getMaxAge();
  192. $response
  193. ->setExpires(new \DateTimeImmutable('+'.$maxAge.' seconds'))
  194. ->setPrivate()
  195. ->setMaxAge($maxAge)
  196. ->headers->addCacheControlDirective('must-revalidate');
  197. }
  198. if (!$event->getRequest()->attributes->get('_stateless', false)) {
  199. return;
  200. }
  201. if ($this->debug) {
  202. throw new UnexpectedSessionUsageException('Session was used while the request was declared stateless.');
  203. }
  204. if ($this->container->has('logger')) {
  205. $this->container->get('logger')->warning('Session was used while the request was declared stateless.');
  206. }
  207. }
  208. /**
  209. * @internal
  210. */
  211. public function onFinishRequest(FinishRequestEvent $event)
  212. {
  213. if ($event->isMainRequest()) {
  214. array_pop($this->sessionUsageStack);
  215. }
  216. }
  217. /**
  218. * @internal
  219. */
  220. public function onSessionUsage(): void
  221. {
  222. if (!$this->debug) {
  223. return;
  224. }
  225. if ($this->container && $this->container->has('session_collector')) {
  226. $this->container->get('session_collector')();
  227. }
  228. if (!$requestStack = $this->container && $this->container->has('request_stack') ? $this->container->get('request_stack') : null) {
  229. return;
  230. }
  231. $stateless = false;
  232. $clonedRequestStack = clone $requestStack;
  233. while (null !== ($request = $clonedRequestStack->pop()) && !$stateless) {
  234. $stateless = $request->attributes->get('_stateless');
  235. }
  236. if (!$stateless) {
  237. return;
  238. }
  239. if (!$session = $this->container && $this->container->has('initialized_session') ? $this->container->get('initialized_session') : $requestStack->getCurrentRequest()->getSession()) {
  240. return;
  241. }
  242. if ($session->isStarted()) {
  243. $session->save();
  244. }
  245. throw new UnexpectedSessionUsageException('Session was used while the request was declared stateless.');
  246. }
  247. /**
  248. * @internal
  249. */
  250. public static function getSubscribedEvents(): array
  251. {
  252. return [
  253. KernelEvents::REQUEST => ['onKernelRequest', 128],
  254. // low priority to come after regular response listeners, but higher than StreamedResponseListener
  255. KernelEvents::RESPONSE => ['onKernelResponse', -1000],
  256. KernelEvents::FINISH_REQUEST => ['onFinishRequest'],
  257. ];
  258. }
  259. /**
  260. * @internal
  261. */
  262. public function reset(): void
  263. {
  264. if (\PHP_SESSION_ACTIVE === session_status()) {
  265. session_abort();
  266. }
  267. session_unset();
  268. $_SESSION = [];
  269. if (!headers_sent()) { // session id can only be reset when no headers were so we check for headers_sent first
  270. session_id('');
  271. }
  272. }
  273. /**
  274. * Gets the session object.
  275. *
  276. * @internal
  277. *
  278. * @return SessionInterface|null
  279. */
  280. abstract protected function getSession();
  281. private function getSessionOptions(array $sessionOptions): array
  282. {
  283. $mergedSessionOptions = [];
  284. foreach (session_get_cookie_params() as $key => $value) {
  285. $mergedSessionOptions['cookie_'.$key] = $value;
  286. }
  287. foreach ($sessionOptions as $key => $value) {
  288. // do the same logic as in the NativeSessionStorage
  289. if ('cookie_secure' === $key && 'auto' === $value) {
  290. continue;
  291. }
  292. $mergedSessionOptions[$key] = $value;
  293. }
  294. return $mergedSessionOptions;
  295. }
  296. }