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

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