refactor!: build the session on symfony/http-foundation - #6659
Open
gharlan wants to merge 9 commits into
Open
Conversation
`Login::startSession()` implied a login for anything that just needs a session in the frontend — a cart, a multistep form (#2065). The session moves out of the login into `Http\Session`, and its mechanics are handed to http-foundation, which is a direct dependency already: Session::start(); // was Login::startSession() Core::getRequest()->getSession(); // the session itself `session.*` leaves the config.yml. The cookie parameters were the php ini settings all along, which is exactly what `NativeSessionStorage` takes, so they become options next to a handler per environment: Session::$backendOptions['cookie_domain'] = 'backend.example.org'; Session::$backendHandler = new PdoSessionHandler($pdo); The handlers shipped with http-foundation (pdo, redis, memcached, …) make the storage exchangeable, which the save path could not do. The data keeps its shape: both environments get an attribute bag whose storage key is the namespace used so far, and a bag shares its array with `$_SESSION` by reference. Existing sessions survive, and code reading `$_SESSION[<namespace>]` directly still works. The backend login keeps reading the backend bag in the frontend, where a logged in backend user is detected this way. `REX_SESSID` is dropped. It was introduced in 2015 as the userland workaround from the strict_sessions rfc, which the issue behind it quotes literally, and the rfc's actual solution — `session.use_strict_mode` — has been enabled unconditionally since #5583. A session is still never started implicitly: the accessors keep throwing if it is not started, even though the bags of http-foundation would start it.
It was added for the .htaccess check in standard.js, which sets its cookie with the same parameters. That check is gone since #6391, the property has had no consumer since.
The `cookie_secure` option defaults to `auto` now, the value symfony uses for this in its own configuration: the flag is set for requests over https and left alone otherwise, so an instance served over http is unaffected, as is one behind a proxy where php does not see the https request. A session cookie without the flag on an https site is sent over an accidental http request as well, which is what the flag prevents. Instances serving both http and https get separate sessions per scheme from now on.
`session_write_close()` and `session_abort()` release the session lock while a long response is sent. Called directly they leave the session object claiming that it is started, so a later `Session::start()` does nothing and everything written from then on is silently dropped — where the same code used to fail loudly, because restarting a session after the response has been sent is not possible in the first place. `Session::close()` saves through http-foundation, which keeps that state straight, and `Session::abort()` drops the session object, since http-foundation has no abort of its own. What can not work still can not work, but it says so.
`Request::session()`, `setSession()`, `unsetSession()` and `clearSession()` were
delegating to the session object after the previous commits. What is left of
them is the type casting, and that exists because `$_GET` and `$_POST` deliver
everything as a string — the session keeps the types it was given, so in all
call sites in core the cast was a no-op. The session is not request input, and
`Request` is about request input.
Session::start()->get('x');
Session::start()->set('x', $value);
Session::start()->remove('x');
Session::start()->clear();
Their other service was refusing to work on a session that was not started,
which http-foundation would silently start on the first access — a session
started by a mere read sends a cookie for every visitor and makes the response
uncacheable. That check moves into `SessionStorage`, where it also covers
`Core::getRequest()->getSession()`, and it lets a closed session be read, which
is what happens while a response is being sent.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2065, and removes
session— the last nested block — from theconfig.yml.Login::startSession()implied a login for everything that just needs a session in the frontend (a cart, a multistep form, the case in the issue). The session moves intoHttp\Session, and its mechanics are handed tosymfony/http-foundation, which is a direct dependency already:The config becomes what it always was
The
session.<env>.cookie.*keys are thesession.*php ini settings, which is exactly whatNativeSessionStoragetakes as options. So they become options plus a handler, per environment:The handlers shipped with http-foundation (pdo, redis, memcached, mongodb, migrating, …) make the storage exchangeable — something the
save_pathkey could not do, and the reason to take http-foundation's session rather than to build our own.The data keeps its shape
Both environments get an
AttributeBagwhose storage key is the namespace used so far (<instanceId>/<instanceId>_backend), and a bag shares its array with$_SESSIONby reference (loadSession()→$bag->initialize($session[$key])). Verified against a running instance: a symfony session put on top of an existing REDAXO session seesbackend_loginand the csrf tokens, and writes go both ways. So existing sessions survive an update, and addons reading$_SESSION[<namespace>]directly keep working.BackendLoginkeeps reading the backend bag also in the frontend — that is how a logged in backend user is detected there, and it is why there are two bags instead of one.REX_SESSIDis droppedIt was introduced in 2015 (#323) as the userland workaround from the strict_sessions rfc — the issue quotes that section of the rfc literally, including its
$_SESSION['valid_id'] = session_id()snippet. The rfc's actual solution issession.use_strict_mode, which REDAXO enables unconditionally since #5583. What the check additionally did — dropping the login when someone else regenerated the id — was a side effect, and a hostile one for instances sharing a php session.Session fixation stays covered by strict mode plus the regeneration on login, which is now
$session->migrate(true).Deliberately unchanged
A session is never started implicitly. The accessors keep throwing when none is started, although http-foundation would start one on any bag access — an implicit session in the frontend is what ruins cacheability later.
Verified against the instance
BackendLogin::hasSession()istruewith the cookie,falsewithout)PdoSessionHandleragainst the instance's MariaDB: table created, session survives requestsWhile testing, one spot turned up that read the removed config key: the "stay logged in" cookie took its
secureandsamesitefromsession.backend.cookieand would have silently fallen back to the defaults. It follows the session cookie parameters now.Rector
Login::startSession()→Session::start(),Login::getCookieParams()→Session::getCookieParams(),Request::getSessionNamespace()→Session::getNamespace(), all with the rename rule already in use.Requestloses its session methodsRequest::session(),setSession(),unsetSession()andclearSession()are gone. After the switch they were delegating to the session object, and what was left of them is the type casting — which exists because$_GETand$_POSTdeliver everything as a string. The session keeps the types it was given: in every one of the ~20 call sites in core the cast was a no-op ('boolean'on a value written astrue,'int'on one written as an int,'array[string]'on an array of strings).Their other service is preserved elsewhere: they refused to work on a session that was not started, and http-foundation would silently start one on first access. That check moves into
SessionStorage, so it also coversCore::getRequest()->getSession(). Verified: reading the session in the frontend without starting it throwsSession not started, call Session::start() before.and sends no cookie; afterSession::start()the same code works and the cookie is sent. A session that was closed to release its lock stays readable.There is no rector path for this one — the signature and the receiver both change, so it needs a rule of its own or manual work in addons.
Added after review of the open questions
rex.cookie_paramsis gone. It was added in useconfig.ymlcookie settings forrex_htaccess_check-cookie #5214 so thatstandard.jscould set therex_htaccess_checkcookie with the same parameters. That check was removed in .htaccess-Check entfernt #6391, and nothing has used the property since — neither in core nor in the addons here.cookie_securedefaults toauto, the value symfony uses for this in its own configuration: the flag is set for requests over https, and left alone otherwise. A session cookie without it is sent over an accidental http request too. An instance served over http is unaffected, as is one behind a proxy where php does not see the https request — the failure mode is "no hardening", never a broken login. Instances serving both schemes get separate sessions per scheme from now on.session_write_close()andsession_abort()release the session lock while a response is sent (Response::sendFile(),Response::sendContent()beforeRESPONSE_SHUTDOWN,MediaManager). Called directly they leave the session object claiming to be started, so a laterSession::start()silently does nothing.Session::close()andSession::abort()keep that state straight — measured: writing inRESPONSE_SHUTDOWNis now a loud "Failed to start the session because headers have already been sent" instead of vanishing (it never worked, a session cannot be restarted after output).The remaining direct
session_*calls stay as they are:session_id(),session_name()andsession_status()ask php for a fact, and go through paths (UserSession,BackendLogin::hasSession(), the guards inRequest) that deliberately do not want to create or start a session object first.