- Currently PHP sessions are not compatible with
PSR-7
- The session object is widely used but there is no standard. A session spec would actually help solve a number of problems, including:
- standardizing between frameworks
- using other peoples implementations easily
- swapping out types of implementations so if you want to use Swoole, you can move away from the PHP sessions and switch to a Redis handler
- Lots of companies like to move their controller logic into reusable services which would be much easier with a session spec.
- Another important part of this PSR, I believe, is for a standard way to set and get the Session object on the
ServerRequestInterface
object, without this feature, I think it would be incomplete.
- Security is an issue and a potential complexity. I believe by also standardizing the hooks into the session lifecycle, such as
startup
andshutdown
this will bring enough flexibility into the session spec, so developers are able to deal with security related issues such as validating the session ID perhaps or deleting previous data etc. - Take into consideration OWASP recommendations, including:
- the default session ID name to be
id
to preventSession ID Name Fingerprinting
- the session ID should be at least 128 bits (16 bytes)
- and other recommendations such as cookie settings etc.
- the default session ID name to be
- User can set, get, check, clear or destroy sessions completely
- Middleware needs to start, close sessions, set the id, and get the id once the storage creates the ID or regenerates the ID so that it can write to the cookie
- Middleware also needs to know if the session was destroyed so it can delete the cookie
- Hooks into the session lifecycle for security purposes, needs to be handled
- It needs to work with various types of storage, e.g PHP Sessions, Redis, JWT tokens and single long-running processes e.g. Swoole.
interface SessionInterface
{
/**
* Sets a value in the session
*
* @param string $key
* @param mixed $value
* @return void
*/
public function set(string $key, $value) : void;
/**
* Gets a value from the session
*
* @param string $key
* @param mixed $default
* @return mixed
*/
public function get(string $key, $default = null);
/**
* Removes a value from the session
*
* @param string $key
* @return void
*/
public function unset(string $key): void;
/**
* Checks if the session has the key
*
* @param string $key
* @return boolean
*/
public function has(string $key): bool;
/**
* Clears the contents of the session
*
* @return void
*/
public function clear(): void;
/**
* Destroy the current session
*
* @return void
*/
public function destroy(): void;
/**
* Starts a session and loads data from storage
*
* @param string|null $sessionId
* @return boolean
*/
public function start(?string $sessionId): bool;
/**
* Closes a session and writes to storage
*
* @return boolean
*/
public function close(): bool;
/**
* Get the session Id, null means session was destroyed (or not started).
*
* @param string|null $sessionId
* @return string|null
*/
public function getId(): ?string;
/**
* Informs the session object to regenerate the session ID for the existing session data
*
* @return boolean
*/
public function regenerateId(): bool;
}
With regards to standardizing the setting and getting the session object on ServerRequestInterface
object
interface ServerRequestSessionInterface
{
public function setSession(SessionInterface $session): void;
public function getSession(): SessionInterface;
}
- not sure about the naming though if this interface is used
- hopefully this allows to extend and not break anything
- A potential issue here is if the session was not added, do you throw an exception, return
null
etc?
class SessionMiddleware implements MiddlewareInterface
{
private SessionInterface $session;
private string $cookieName = 'id';
private int $timeout = 900; // 15 minutes
private string $sameSite = 'lax';
private string $cookiePath = '/';
public function __construct(SessionInterface $session)
{
$this->session = $session;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$sessionId = $this->getSessionId($request); // Get value from cookie, session id or perhaps JWT token?
$this->session->start($sessionId);
$response = $handler->handle($request->withAttribute('session', $this->session));
$this->session->close(); // close session if still open, user may have destroyed or closed manually
return $this->addCookieToResponse($request, $response);
}
/**
* If the session was destroyed, there will be no id, so delete delete delete. If the session ID was regenerated, then
* the cookie needs to be updated.
*/
private function addCookieToResponse(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$wasDestroyed = is_null($this->session->getId());
$cookieValue = $wasDestroyed ? '' : $this->session->getId();
$cookieExpires = $wasDestroyed ? time() - 3600 : time() + $this->timeout;
return $response->withAddedHeader(
'Set-Cookie', $this->createCookieString($cookieValue, $cookieExpires, $request)
);
}
private function getSessionId(ServerRequestInterface $request): ?string
{
$cookies = $request->getCookieParams();
return $cookies[$this->cookieName] ?? null;
}
private function createCookieString(string $sessionId, int $expires, ServerRequestInterface $request): string
{
return sprintf(
'%s=%s; expires=%s; path=%s; samesite=%s;%s httponly',
$this->cookieName,
$sessionId,
gmdate(\DateTime::COOKIE, $expires),
$this->cookiePath,
$this->sameSite,
$request->getUri()->getScheme() === 'https' ? ' secure;' : null
) ;
}
}
Here is a simple example to demonstrate the concept
class PhpSession implements SessionInterface
{
private ?string $id = null;
private array $session = [];
private bool $isRegenerated = false;
private bool $isStarted = false;
public function start(?string $id): bool
{
if ($this->isStarted) {
return false;
}
$this->id = $id ?: $this->generateId();
session_id($this->id);
// Disable the PHP cookie features, credit to @pmjones for this
$this->isStarted = session_start([
'use_cookies' => false,
'use_only_cookies' => false,
'use_trans_sid' => false
]);
$this->session = $_SESSION ?? [];
return $this->isStarted;
}
public function set(string $key, $value): void
{
$this->session[$key] = $value;
}
public function get(string $key, $default = null)
{
return $this->session[$key] ?? $default;
}
public function unset(string $key): void
{
unset($this->session[$key]);
}
public function has(string $key): bool
{
return array_key_exists($key, $this->session);
}
public function clear(): void
{
$this->session = [];
}
public function destroy(): void
{
$this->session = [];
$this->close();
$this->id = null;
}
public function getId(): ?string
{
return $this->id;
}
public function regenerateId(): bool
{
$this->id = $this->generateId();
$this->isRegenerated = true;
return true;
}
private function generateId(): string
{
return bin2hex(random_bytes(16)); // OWASP recommendation
}
public function close(): bool
{
if ($this->isStarted === false) {
return false;
}
// I think there were some issues with overwriting the $_SESSION variable directly
$removed = array_diff(array_keys($_SESSION), array_keys($this->session));
foreach ($this->session as $key => $value) {
$_SESSION[$key] = $value;
}
foreach ($removed as $key) {
unset($_SESSION[$key]);
}
$closed = session_write_close();
$this->isStarted = $closed === false;
return $this->isRegenerated ? $this->regenerateSessionData() : $closed;
}
/**
* Copy session data to new ID
*
* @return boolean
*/
private function regenerateSessionData(): bool
{
$this->isRegenerated = false;
$session = $_SESSION; // data still seems to be here
$this->start($this->id); // start session with new session ID
$this->session = $session; // kansas city shuffle
return $this->close(); // save
}
}