Initial commit

This commit is contained in:
isUnknown 2025-10-03 07:46:23 +02:00
commit efa5624dab
687 changed files with 162710 additions and 0 deletions

View file

@ -0,0 +1,176 @@
<?php
namespace Kirby\Session;
/**
* AutoSession - simplified session handler with fully automatic session creation
*
* @package Kirby Session
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class AutoSession
{
protected Sessions $sessions;
protected array $options;
protected Session $createdSession;
/**
* Creates a new AutoSession instance
*
* @param \Kirby\Session\SessionStore|string $store SessionStore object or a path to the storage directory (uses the FileSessionStore)
* @param array $options Optional additional options:
* - `durationNormal`: Duration of normal sessions in seconds; defaults to 2 hours
* - `durationLong`: Duration of "remember me" sessions in seconds; defaults to 2 weeks
* - `timeout`: Activity timeout in seconds (integer or false for none); *only* used for normal sessions; defaults to `1800` (half an hour)
* - `cookieDomain`: Domain to set the cookie to (this disables the cookie path restriction); defaults to none (default browser behavior)
* - `cookieName`: Name to use for the session cookie; defaults to `kirby_session`
* - `gcInterval`: How often should the garbage collector be run?; integer or `false` for never; defaults to `100`
*/
public function __construct(
SessionStore|string $store,
array $options = []
) {
// merge options with defaults
$this->options = [
'durationNormal' => 7200,
'durationLong' => 1209600,
'timeout' => 1800,
'cookieDomain' => null,
'cookieName' => 'kirby_session',
'gcInterval' => 100,
...$options
];
// create an internal instance of the low-level Sessions class
$this->sessions = new Sessions($store, [
'cookieDomain' => $this->options['cookieDomain'],
'cookieName' => $this->options['cookieName'],
'gcInterval' => $this->options['gcInterval']
]);
}
/**
* Returns the automatic session
*
* @param array $options Optional additional options:
* - `detect`: Whether to allow sessions in the `Authorization` HTTP header (`true`) or only in the session cookie (`false`); defaults to `false`
* - `createMode`: When creating a new session, should it be set as a cookie or is it going to be transmitted manually to be used in a header?; defaults to `cookie`
* - `long`: Whether the session is a long "remember me" session or a normal session; defaults to `false`
*/
public function get(array $options = []): Session
{
// merge options with defaults
$options = [
'detect' => false,
'createMode' => 'cookie',
'long' => false,
...$options
];
// determine expiry options based on the session type
if ($options['long'] === true) {
$duration = $this->options['durationLong'];
$timeout = false;
} else {
$duration = $this->options['durationNormal'];
$timeout = $this->options['timeout'];
}
// get the current session
$session = match ($options['detect']) {
true => $this->sessions->currentDetected(),
default => $this->sessions->current()
};
// create a new session
if ($session === null) {
$session = $this->createdSession ?? $this->sessions->create([
'mode' => $options['createMode'],
'startTime' => time(),
'expiryTime' => time() + $duration,
'timeout' => $timeout,
'renewable' => true,
]);
// cache the newly created session to ensure
// that we don't create multiple
$this->createdSession = $session;
}
// update the session configuration if the $options changed
// always use the less strict value for compatibility with features
// that depend on the less strict behavior
if ($duration > $session->duration()) {
// the duration needs to be extended
$session->duration($duration);
}
if ($session->timeout() !== false) {
// a timeout exists
if ($timeout === false) {
// it needs to be completely disabled
$session->timeout(false);
} elseif (is_int($timeout) && $timeout > $session->timeout()) {
// it needs to be extended
$session->timeout($timeout);
}
}
// if the session has been created and was not yet initialized,
// update the mode to a custom mode;
// don't update back to cookie mode because the
// "special" behavior always wins
if ($session->token() === null && $options['createMode'] !== 'cookie') {
$session->mode($options['createMode']);
}
return $session;
}
/**
* Creates a new empty session that is *not* automatically
* transmitted to the client;
* Useful for custom applications like a password reset link
* Does *not* affect the automatic session
*
* @param array $options Optional additional options:
* - `startTime`: Time the session starts being valid (date string or timestamp); defaults to `now`
* - `expiryTime`: Time the session expires (date string or timestamp); defaults to `+ 2 hours`
* - `timeout`: Activity timeout in seconds (integer or false for none); defaults to `1800` (half an hour)
* - `renewable`: Should it be possible to extend the expiry date?; defaults to `true`
*/
public function createManually(array $options = []): Session
{
// only ever allow manual transmission mode
// to prevent overwriting our "auto" session
$options['mode'] = 'manual';
return $this->sessions->create($options);
}
/**
* Returns the specified Session object
* @since 3.3.1
*
* @param string $token Session token, either including or without the key
*/
public function getManually(string $token): Session
{
return $this->sessions->get($token, 'manual');
}
/**
* Deletes all expired sessions
*
* If the `gcInterval` is configured, this is done automatically
* when initializing the AutoSession class
*/
public function collectGarbage(): void
{
$this->sessions->collectGarbage();
}
}

View file

@ -0,0 +1,480 @@
<?php
namespace Kirby\Session;
use FilesystemIterator;
use Kirby\Exception\Exception;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Toolkit\Str;
/**
* @package Kirby Session
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class FileSessionStore extends SessionStore
{
protected string $path;
// state of the session files
protected array $handles = [];
protected array $isLocked = [];
/**
* Creates a new instance of the file session store
*
* @param string $path Path to the storage directory
*/
public function __construct(string $path)
{
// create the directory if it doesn't already exist
Dir::make($path, true);
// store the canonicalized path
$this->path = realpath($path);
// make sure it is usable for storage
if (is_writable($this->path) === false) {
throw new Exception(
key: 'session.filestore.dirNotWritable',
data: ['path' => $this->path],
fallback: 'The session storage directory "' . $path . '" is not writable',
translate: false,
httpCode: 500
);
}
}
/**
* Creates a new session ID with the given expiry time
*
* Needs to make sure that the session does not already exist
* and needs to reserve it by locking it exclusively.
*
* @param int $expiryTime Timestamp
* @return string Randomly generated session ID (without timestamp)
*/
public function createId(int $expiryTime): string
{
clearstatcache();
do {
// use helper from the abstract SessionStore class
$id = static::generateId();
$name = $this->name($expiryTime, $id);
$path = $this->path($name);
} while (file_exists($path));
// reserve the file
touch($path);
$this->lock($expiryTime, $id);
// ensure that no other thread already wrote to the same file,
// otherwise try again (very unlikely scenario!)
$contents = $this->get($expiryTime, $id);
if ($contents !== '') {
// @codeCoverageIgnoreStart
$this->unlock($expiryTime, $id);
return $this->createId($expiryTime);
// @codeCoverageIgnoreEnd
}
return $id;
}
/**
* Checks if the given session exists
*
* @param int $expiryTime Timestamp
* @param string $id Session ID
* @return bool true: session exists,
* false: session doesn't exist
*/
public function exists(int $expiryTime, string $id): bool
{
$name = $this->name($expiryTime, $id);
$path = $this->path($name);
clearstatcache();
return is_file($path) === true;
}
/**
* Locks the given session exclusively
*
* Needs to throw an Exception on error.
*
* @param int $expiryTime Timestamp
* @param string $id Session ID
*/
public function lock(int $expiryTime, string $id): void
{
$name = $this->name($expiryTime, $id);
// check if the file is already locked
if (isset($this->isLocked[$name]) === true) {
return;
}
// lock it exclusively
$handle = $this->handle($name);
$result = flock($handle, LOCK_EX);
// @codeCoverageIgnoreStart
if ($result !== true) {
throw new Exception(
key: 'session.filestore.unexpectedFilesystemError',
fallback: 'Unexpected file system error',
translate: false,
httpCode: 500
);
}
// @codeCoverageIgnoreEnd
// make a note that the file is now locked
$this->isLocked[$name] = true;
}
/**
* Removes all locks on the given session
*
* Needs to throw an Exception on error.
*
* @param int $expiryTime Timestamp
* @param string $id Session ID
*/
public function unlock(int $expiryTime, string $id): void
{
$name = $this->name($expiryTime, $id);
// check if the file is already unlocked or doesn't exist
if (isset($this->isLocked[$name]) === false) {
return;
}
if ($this->exists($expiryTime, $id) === false) {
unset($this->isLocked[$name]);
return;
}
// remove the exclusive lock
$handle = $this->handle($name);
$result = flock($handle, LOCK_UN);
// @codeCoverageIgnoreStart
if ($result !== true) {
throw new Exception(
key: 'session.filestore.unexpectedFilesystemError',
fallback: 'Unexpected file system error',
translate: false,
httpCode: 500
);
}
// @codeCoverageIgnoreEnd
// make a note that the file is no longer locked
unset($this->isLocked[$name]);
}
/**
* Returns the stored session data of the given session
*
* Needs to throw an Exception on error.
*
* @param int $expiryTime Timestamp
* @param string $id Session ID
*/
public function get(int $expiryTime, string $id): string
{
$name = $this->name($expiryTime, $id);
$path = $this->path($name);
$handle = $this->handle($name);
// set read lock to prevent other threads from corrupting
// the data while we read it; only if we don't already have
// a write lock, which is even better
if (isset($this->isLocked[$name]) === false) {
$result = flock($handle, LOCK_SH);
if ($result !== true) {
// @codeCoverageIgnoreStart
throw new Exception(
key: 'session.filestore.unexpectedFilesystemError',
fallback: 'Unexpected file system error',
translate: false,
httpCode: 500
);
// @codeCoverageIgnoreEnd
}
}
clearstatcache();
$filesize = filesize($path);
if ($filesize > 0) {
// always read the whole file
rewind($handle);
$string = fread($handle, $filesize);
} else {
// we don't need to read empty files
$string = '';
}
// remove the shared lock if we set one above
if (isset($this->isLocked[$name]) === false) {
$result = flock($handle, LOCK_UN);
if ($result !== true) {
// @codeCoverageIgnoreStart
throw new Exception(
key: 'session.filestore.unexpectedFilesystemError',
fallback: 'Unexpected file system error',
translate: false,
httpCode: 500
);
// @codeCoverageIgnoreEnd
}
}
return $string;
}
/**
* Stores data to the given session
*
* Needs to make sure that the session exists.
* Needs to throw an Exception on error.
*
* @param int $expiryTime Timestamp
* @param string $id Session ID
* @param string $data Session data to write
*/
public function set(int $expiryTime, string $id, string $data): void
{
$name = $this->name($expiryTime, $id);
$handle = $this->handle($name);
// validate that we have an exclusive lock already
if (isset($this->isLocked[$name]) === false) {
throw new LogicException(
key: 'session.filestore.notLocked',
data: ['name' => $name],
fallback: 'Cannot write to session "' . $name . '", because it is not locked',
translate: false,
httpCode: 500
);
}
// delete all file contents first
if (rewind($handle) !== true || ftruncate($handle, 0) !== true) {
// @codeCoverageIgnoreStart
throw new Exception(
key: 'session.filestore.unexpectedFilesystemError',
fallback: 'Unexpected file system error',
translate: false,
httpCode: 500
);
// @codeCoverageIgnoreEnd
}
// write the new contents
$result = fwrite($handle, $data);
if (is_int($result) === false || $result === 0) {
// @codeCoverageIgnoreStart
throw new Exception(
key: 'session.filestore.unexpectedFilesystemError',
fallback: 'Unexpected file system error',
translate: false,
httpCode: 500
);
// @codeCoverageIgnoreEnd
}
}
/**
* Deletes the given session
*
* Needs to throw an Exception on error.
*
* @param int $expiryTime Timestamp
* @param string $id Session ID
*/
public function destroy(int $expiryTime, string $id): void
{
$name = $this->name($expiryTime, $id);
$path = $this->path($name);
// close the file, otherwise we can't delete it on Windows;
// deletion is *not* thread-safe because of this, but
// resurrection of the file is prevented in $this->set() because of
// the check in $this->handle() every time any method is called
$this->unlock($expiryTime, $id);
$this->closeHandle($name);
// we don't need to delete files that don't exist anymore
if ($this->exists($expiryTime, $id) === false) {
return;
}
// file still exists, delete it
if (@F::unlink($path) !== true) {
// @codeCoverageIgnoreStart
throw new Exception(
key: 'session.filestore.unexpectedFilesystemError',
fallback: 'Unexpected file system error',
translate: false,
httpCode: 500
);
// @codeCoverageIgnoreEnd
}
}
/**
* Deletes all expired sessions
*
* Needs to throw an Exception on error.
*/
public function collectGarbage(): void
{
$iterator = new FilesystemIterator($this->path);
$currentTime = time();
foreach ($iterator as $file) {
// make sure that the file is a session file
// prevents deleting files like .gitignore or other unrelated files
if (preg_match('/^[0-9]+\.[a-z0-9]+\.sess$/', $file->getFilename()) !== 1) {
continue;
}
// extract the data from the filename
$name = $file->getBasename('.sess');
$expiryTime = (int)Str::before($name, '.');
$id = Str::after($name, '.');
if ($expiryTime < $currentTime) {
// the session has expired, delete it
$this->destroy($expiryTime, $id);
}
}
}
/**
* Cleans up the open locks and file handles
*
* @codeCoverageIgnore
*/
public function __destruct()
{
// unlock all locked files
foreach ($this->isLocked as $name => $locked) {
$expiryTime = (int)Str::before($name, '.');
$id = Str::after($name, '.');
$this->unlock($expiryTime, $id);
}
// close all file handles
foreach ($this->handles as $name => $handle) {
$this->closeHandle($name);
}
}
/**
* Returns the combined name based on expiry time and ID
*
* @param int $expiryTime Timestamp
* @param string $id Session ID
*/
protected function name(int $expiryTime, string $id): string
{
// protect against path traversal
return $expiryTime . '.' . basename($id);
}
/**
* Returns the full path to the session file
*
* @param string $name Combined name
*/
protected function path(string $name): string
{
return $this->path . '/' . $name . '.sess';
}
/**
* Returns a PHP file handle for a session
*
* @param string $name Combined name
* @return resource File handle
*/
protected function handle(string $name)
{
// always verify that the file still exists, even if we
// already have a handle; ensures thread-safeness for
// recently deleted sessions, see $this->destroy()
$path = $this->path($name);
clearstatcache();
if (is_file($path) === false) {
throw new NotFoundException(
key: 'session.filestore.notFound',
data: ['name' => $name],
fallback: 'Session file "' . $name . '" does not exist',
translate: false,
httpCode: 404
);
}
// return from cache
if (isset($this->handles[$name]) === true) {
return $this->handles[$name];
}
// open a new handle
$handle = @fopen($path, 'r+b');
if (is_resource($handle) === false) {
throw new Exception(
key: 'session.filestore.notOpened',
data: ['name' => $name],
fallback: 'Session file "' . $name . '" could not be opened',
translate: false,
httpCode: 500
);
}
return $this->handles[$name] = $handle;
}
/**
* Closes an open file handle
*
* @param string $name Combined name
*/
protected function closeHandle(string $name): void
{
if (isset($this->handles[$name]) === false) {
return;
}
$handle = $this->handles[$name];
unset($this->handles[$name]);
$result = fclose($handle);
if ($result !== true) {
// @codeCoverageIgnoreStart
throw new Exception(
key: 'session.filestore.unexpectedFilesystemError',
fallback: 'Unexpected file system error',
translate: false,
httpCode: 500
);
// @codeCoverageIgnoreEnd
}
}
}

View file

@ -0,0 +1,832 @@
<?php
namespace Kirby\Session;
use Kirby\Exception\BadMethodCallException;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Http\Cookie;
use Kirby\Http\Url;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\SymmetricCrypto;
/**
* @package Kirby Session
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class Session
{
// parent data
protected string $mode;
// parts of the token
protected int|null $tokenExpiry = null;
protected string|null $tokenId = null;
protected string|null $tokenKey = null;
// persistent data
protected int $startTime;
protected int $expiryTime;
protected int $duration;
protected int|false $timeout = false;
protected int|null $lastActivity = null;
protected bool $renewable;
protected SessionData $data;
protected array|null $newSession = null;
// temporary state flags
protected bool $updatingLastActivity = false;
protected bool $destroyed = false;
protected bool $writeMode = false;
protected bool $needsRetransmission = false;
/**
* Creates a new Session instance
*
* @param \Kirby\Session\Sessions $sessions Parent sessions object
* @param string|null $token Session token or null for a new session
* @param array $options Optional additional options:
* - `mode`: Token transmission mode (cookie or manual); defaults to `cookie`
* - `startTime`: Time the session starts being valid (date string or timestamp); defaults to `now`
* - `expiryTime`: Time the session expires (date string or timestamp); defaults to `+ 2 hours`
* - `timeout`: Activity timeout in seconds (integer or false for none); defaults to `1800` (half an hour)
* - `renewable`: Should it be possible to extend the expiry date?; defaults to `true`
*/
public function __construct(
protected Sessions $sessions,
string|null $token,
array $options
) {
$this->mode = $options['mode'] ?? 'cookie';
// ensure that all changes are committed on script termination
register_shutdown_function([$this, 'commit']);
if (is_string($token) === true) {
// existing session
// set the token as instance vars
$this->parseToken($token);
// initialize, but only try to write to the session if not read-only
// (only the case for moved sessions)
$this->init();
if ($this->tokenKey !== null) {
$this->autoRenew();
}
return;
}
// new session ($token = null)
// set data based on options
$this->startTime = static::timeToTimestamp($options['startTime'] ?? time());
$this->expiryTime = static::timeToTimestamp($options['expiryTime'] ?? '+ 2 hours', $this->startTime);
$this->duration = $this->expiryTime - $this->startTime;
$this->timeout = $options['timeout'] ?? 1800;
$this->renewable = $options['renewable'] ?? true;
$this->data = new SessionData($this, []);
// validate persistent data
if (time() > $this->expiryTime) {
// session must not already be expired, but the start time may be in the future
throw new InvalidArgumentException(
data: [
'method' => 'Session::__construct',
'argument' => '$options[\'expiryTime\']'
],
translate: false
);
}
if ($this->duration < 0) {
// expiry time must be after start time
throw new InvalidArgumentException(
data: [
'method' => 'Session::__construct',
'argument' => '$options[\'startTime\' & \'expiryTime\']'
],
translate: false
);
}
// set activity time if a timeout was requested
if (is_int($this->timeout) === true) {
$this->lastActivity = time();
}
}
/**
* Gets the session token or null if the session doesn't have a token yet
*/
public function token(): string|null
{
if ($this->tokenExpiry !== null) {
$token = $this->tokenExpiry . '.' . $this->tokenId;
if (is_string($this->tokenKey) === true) {
$token .= '.' . $this->tokenKey;
}
return $token;
}
return null;
}
/**
* Gets or sets the transmission mode
* Setting only works for new sessions that haven't been transmitted yet
*
* @param string|null $mode Optional new transmission mode
* @return string Transmission mode
*/
public function mode(string|null $mode = null): string
{
if ($mode !== null) {
// only allow this if this is a new session, otherwise the change
// might not be applied correctly to the current request
if ($this->token() !== null) {
throw new InvalidArgumentException(
data: ['method' => 'Session::mode', 'argument' => '$mode'],
translate: false
);
}
$this->mode = $mode;
}
return $this->mode;
}
/**
* Gets the session start time
*
* @return int Timestamp
*/
public function startTime(): int
{
return $this->startTime;
}
/**
* Gets or sets the session expiry time
* Setting the expiry time also updates the duration and regenerates the session token
*
* @param string|int|null $expiryTime Optional new expiry timestamp or time string to set
* @return int Timestamp
*/
public function expiryTime(string|int|null $expiryTime = null): int
{
if ($expiryTime !== null) {
// convert to a timestamp
$expiryTime = static::timeToTimestamp($expiryTime);
// verify that the expiry time is not in the past
if ($expiryTime <= time()) {
throw new InvalidArgumentException(
data: [
'method' => 'Session::expiryTime',
'argument' => '$expiryTime'
],
translate: false
);
}
$this->prepareForWriting();
$this->expiryTime = $expiryTime;
$this->duration = $expiryTime - time();
$this->regenerateTokenIfNotNew();
}
return $this->expiryTime;
}
/**
* Gets or sets the session duration
* Setting the duration also updates the expiry time and regenerates the session token
*
* @param int|null $duration Optional new duration in seconds to set
* @return int Number of seconds
*/
public function duration(int|null $duration = null): int
{
if ($duration !== null) {
// verify that the duration is at least 1 second
if ($duration < 1) {
throw new InvalidArgumentException(
data: [
'method' => 'Session::duration',
'argument' => '$duration'
],
translate: false
);
}
$this->prepareForWriting();
$this->duration = $duration;
$this->expiryTime = time() + $duration;
$this->regenerateTokenIfNotNew();
}
return $this->duration;
}
/**
* Gets or sets the session timeout
*
* @param int|false|null $timeout Optional new timeout to set or false to disable timeout
* @return int|false Number of seconds or false for "no timeout"
*/
public function timeout(int|false|null $timeout = null): int|false
{
if ($timeout !== null) {
// verify that the timeout is at least 1 second
if (is_int($timeout) === true && $timeout < 1) {
throw new InvalidArgumentException(
data: [
'method' => 'Session::timeout',
'argument' => '$timeout'
],
translate: false
);
}
$this->prepareForWriting();
$this->timeout = $timeout;
$this->lastActivity = is_int($timeout) ? time() : null;
}
return $this->timeout;
}
/**
* Gets or sets the renewable flag
* Automatically renews the session if renewing gets enabled
*
* @param bool|null $renewable Optional new renewable flag to set
*/
public function renewable(bool|null $renewable = null): bool
{
if ($renewable !== null) {
$this->prepareForWriting();
$this->renewable = $renewable;
$this->autoRenew();
}
return $this->renewable;
}
/**
* Returns the session data object
*
* @return \Kirby\Session\SessionData
*/
public function data()
{
return $this->data;
}
/**
* Magic call method that proxies all calls to session data methods
*
* @param string $name Method name (one of set, increment, decrement, get, pull, remove, clear)
* @param array $arguments Method arguments
*/
public function __call(string $name, array $arguments): mixed
{
// validate that we can handle the called method
$methods = [
'clear',
'decrement',
'get',
'increment',
'pull',
'remove',
'set'
];
if (in_array($name, $methods, true) === false) {
throw new BadMethodCallException(
data: ['method' => 'Session::' . $name],
translate: false
);
}
return $this->data()->$name(...$arguments);
}
/**
* Writes all changes to the session to the session store
*/
public function commit(): void
{
// nothing to do if nothing changed or the session
// has been just created or destroyed
/**
* @todo The $this->destroyed check gets flagged by Psalm for unknown reasons
* @psalm-suppress ParadoxicalCondition
*/
if (
$this->writeMode !== true ||
$this->tokenExpiry === null ||
$this->destroyed === true
) {
return;
}
// collect all data
if ($this->newSession !== null) {
// the token has changed
// we are writing to the old session:
// it only gets the reference to the new session
// and a shortened expiry time (30 second grace period)
$data = [
'startTime' => $this->startTime(),
'expiryTime' => time() + 30,
'newSession' => $this->newSession[0]
];
// include the token key for the new session if we
// have access to the PHP `sodium` extension;
// otherwise (if no encryption is possible), the token key
// is omitted, which makes the new session read-only
// when accessed through the old session
if ($crypto = $this->crypto()) {
// encrypt the new token key with the old token key
// so that attackers with read access to the session file
// (e.g. via directory traversal) cannot impersonate the new session
$data['newSessionKey'] = $crypto->encrypt($this->newSession[1]);
}
} else {
$data = [
'startTime' => $this->startTime(),
'expiryTime' => $this->expiryTime(),
'duration' => $this->duration(),
'timeout' => $this->timeout(),
'lastActivity' => $this->lastActivity,
'renewable' => $this->renewable(),
'data' => $this->data()->get()
];
}
// encode the data and attach an HMAC
$data = serialize($data);
$data = hash_hmac('sha256', $data, $this->tokenKey) . "\n" . $data;
// store the data
$this->sessions->store()->set($this->tokenExpiry, $this->tokenId, $data);
$this->sessions->store()->unlock($this->tokenExpiry, $this->tokenId);
$this->writeMode = false;
}
/**
* Entirely destroys the session
*/
public function destroy(): void
{
// no need to destroy new or destroyed sessions
if ($this->tokenExpiry === null || $this->destroyed === true) {
return;
}
// remove session file
$this->sessions->store()->destroy($this->tokenExpiry, $this->tokenId);
$this->destroyed = true;
$this->writeMode = false;
$this->needsRetransmission = false;
// remove cookie
if ($this->mode === 'cookie') {
Cookie::remove($this->sessions->cookieName());
}
}
/**
* Renews the session with the same session duration
* Renewing also regenerates the session token
*/
public function renew(): void
{
if ($this->renewable() !== true) {
throw new LogicException(
key: 'session.notRenewable',
fallback: 'Cannot renew a session that is not renewable, call $session->renewable(true) first',
translate: false,
);
}
$this->prepareForWriting();
$this->expiryTime = time() + $this->duration();
$this->regenerateTokenIfNotNew();
}
/**
* Regenerates the session token
* The old token will keep its validity for a 30 second grace period
*/
public function regenerateToken(): void
{
// don't do anything for destroyed sessions
if ($this->destroyed === true) {
return;
}
$this->prepareForWriting();
// generate new token
$tokenExpiry = $this->expiryTime;
$tokenId = $this->sessions->store()->createId($tokenExpiry);
$tokenKey = bin2hex(random_bytes(32));
// mark the old session as moved if there is one
if ($this->tokenExpiry !== null) {
$this->newSession = [$tokenExpiry . '.' . $tokenId, $tokenKey];
$this->commit();
// we are now in the context of the new session
$this->newSession = null;
}
// set new data as instance vars
$this->tokenExpiry = $tokenExpiry;
$this->tokenId = $tokenId;
$this->tokenKey = $tokenKey;
// the new session needs to be written for the first time
$this->writeMode = true;
// (re)transmit session token
if ($this->mode === 'cookie') {
$cookieDomain = $this->sessions->cookieDomain();
Cookie::set($this->sessions->cookieName(), $this->token(), [
'lifetime' => $this->tokenExpiry,
'path' => $cookieDomain ? '/' : Url::index(['host' => null, 'trailingSlash' => true]),
'domain' => $cookieDomain,
'secure' => Url::scheme() === 'https',
'httpOnly' => true,
'sameSite' => 'Lax'
]);
} else {
$this->needsRetransmission = true;
}
// update cache of the Sessions instance with the new token
$this->sessions->updateCache($this);
}
/**
* Returns whether the session token needs to be retransmitted to the client
* Only relevant in header and manual modes
*/
public function needsRetransmission(): bool
{
return $this->needsRetransmission;
}
/**
* Ensures that all pending changes are written
* to disk before the object is destructed
*
* @return void
*/
public function __destruct()
{
$this->commit();
}
/**
* Initially generates the token for new sessions
* Used internally
*/
public function ensureToken(): void
{
if ($this->tokenExpiry === null) {
$this->regenerateToken();
}
}
/**
* Puts the session into write mode by acquiring a lock
* and reloading the data
* @unstable
*/
public function prepareForWriting(): void
{
// verify that we need to get into write mode:
// - new sessions are only written to if the token has explicitly been ensured
// using $session->ensureToken() -> lazy session creation
// - destroyed sessions are never written to
// - no need to lock and re-init if we are already in write mode
/**
* @todo The $this->destroyed check gets flagged by Psalm for unknown reasons
* @psalm-suppress ParadoxicalCondition
*/
if (
$this->tokenExpiry === null ||
$this->destroyed === true ||
$this->writeMode === true
) {
return;
}
// don't allow writing for read-only sessions
// (only the case for moved sessions when the PHP `sodium` extension is not available)
/**
* @todo This check gets flagged by Psalm for unknown reasons
* @psalm-suppress ParadoxicalCondition
*/
if ($this->tokenKey === null) {
throw new LogicException(
key: 'session.readonly',
data: ['token' => $this->token()],
fallback: 'Session "' . $this->token() . '" is currently read-only because it was accessed via an old session token',
translate: false
);
}
$this->sessions->store()->lock($this->tokenExpiry, $this->tokenId);
$this->init();
$this->writeMode = true;
}
/**
* Returns a symmetric crypto instance based on the
* token key of the session
*/
protected function crypto(): SymmetricCrypto|null
{
if (
$this->tokenKey === null ||
SymmetricCrypto::isAvailable() === false
) {
return null; // @codeCoverageIgnore
}
return new SymmetricCrypto(secretKey: hex2bin($this->tokenKey));
}
/**
* Parses a token string into its parts and sets them as instance vars
*
* @param string $token Session token
* @param bool $withoutKey If true, $token is passed without key
*/
protected function parseToken(string $token, bool $withoutKey = false): void
{
// split the token into its parts
$parts = explode('.', $token);
// only continue if the token has exactly the right amount of parts
$expectedParts = ($withoutKey === true) ? 2 : 3;
if (count($parts) !== $expectedParts) {
throw new InvalidArgumentException(
data: [
'method' => 'Session::parseToken',
'argument' => '$token'
],
translate: false
);
}
$tokenExpiry = (int)$parts[0];
$tokenId = $parts[1];
$tokenKey = ($withoutKey === true) ? null : $parts[2];
// verify that all parts were parsed correctly using reassembly
$expectedToken = $tokenExpiry . '.' . $tokenId;
if ($withoutKey === false) {
$expectedToken .= '.' . $tokenKey;
}
if ($expectedToken !== $token) {
throw new InvalidArgumentException(
data: [
'method' => 'Session::parseToken',
'argument' => '$token'
],
translate: false
);
}
$this->tokenExpiry = $tokenExpiry;
$this->tokenId = $tokenId;
$this->tokenKey = $tokenKey;
}
/**
* Makes sure that the given value is a valid timestamp
*
* @param string|int $time Timestamp or date string (must be supported by `strtotime()`)
* @param int|null $now Timestamp to use as a base for the calculation of relative dates
* @return int Timestamp value
*/
protected static function timeToTimestamp(
string|int $time,
int|null $now = null
): int {
// default to current time as $now
$now ??= time();
// convert date strings to a timestamp first
if (is_string($time) === true) {
$time = strtotime($time, $now);
}
return $time;
}
/**
* Loads the session data from the session store
*/
protected function init(): void
{
// sessions that are new, written to or that have been destroyed should never be initialized
if (
$this->tokenExpiry === null ||
$this->writeMode === true ||
$this->destroyed === true
) {
// unexpected error that shouldn't occur
throw new Exception(translate: false); // @codeCoverageIgnore
}
// make sure that the session exists
if ($this->sessions->store()->exists($this->tokenExpiry, $this->tokenId) !== true) {
throw new NotFoundException(
key: 'session.notFound',
data: ['token' => $this->token()],
fallback: 'Session "' . $this->token() . '" does not exist',
translate: false,
httpCode: 404
);
}
// get the session data from the store
$data = $this->sessions->store()->get(
$this->tokenExpiry,
$this->tokenId
);
// verify HMAC
// skip if we don't have the key (only the case for moved sessions)
$hmac = Str::before($data, "\n");
$data = trim(Str::after($data, "\n"));
if (
$this->tokenKey !== null &&
hash_equals(hash_hmac('sha256', $data, $this->tokenKey), $hmac) !== true
) {
throw new LogicException(
key: 'session.invalid',
data: ['token' => $this->token()],
fallback: 'Session "' . $this->token() . '" is invalid',
translate: false,
httpCode: 500
);
}
// decode the serialized data
$data = @unserialize($data);
if ($data === false) {
throw new LogicException(
key: 'session.invalid',
data: ['token' => $this->token()],
fallback: 'Session "' . $this->token() . '" is invalid',
translate: false,
httpCode: 500
);
}
// verify start and expiry time
if (
time() < $data['startTime'] ||
time() > $data['expiryTime']
) {
throw new LogicException(
key: 'session.invalid',
data: ['token' => $this->token()],
fallback: 'Session "' . $this->token() . '" is invalid',
translate: false,
httpCode: 500
);
}
// follow to the new session if there is one
if (isset($data['newSession']) === true) {
// decrypt the token key if provided and we have access to
// the PHP `sodium` extension for decryption
if (
isset($data['newSessionKey']) === true &&
$crypto = $this->crypto()
) {
$tokenKey = $crypto->decrypt($data['newSessionKey']);
$this->parseToken($data['newSession'] . '.' . $tokenKey);
$this->init();
return;
}
// otherwise initialize without the token key (read-only mode)
$this->parseToken($data['newSession'], true);
$this->init();
return;
}
// verify timeout
if (is_int($data['timeout']) === true) {
if (time() - $data['lastActivity'] > $data['timeout']) {
throw new LogicException(
key: 'session.invalid',
data: ['token' => $this->token()],
fallback: 'Session "' . $this->token() . '" is invalid',
translate: false,
httpCode: 500
);
}
// set a new activity timestamp, but only every few minutes for better performance
// don't do this if another call to init() is already doing it to prevent endless loops;
// also don't do this for read-only sessions
if (
$this->updatingLastActivity === false &&
$this->tokenKey !== null &&
time() - $data['lastActivity'] > $data['timeout'] / 15
) {
$this->updatingLastActivity = true;
$this->prepareForWriting();
// the remaining init steps have been done by prepareForWriting()
$this->lastActivity = time();
$this->updatingLastActivity = false;
return;
}
}
// (re)initialize all instance variables
$this->startTime = $data['startTime'];
$this->expiryTime = $data['expiryTime'];
$this->duration = $data['duration'];
$this->timeout = $data['timeout'];
$this->lastActivity = $data['lastActivity'];
$this->renewable = $data['renewable'];
// reload data into existing object to avoid breaking memory references
if (isset($this->data) === true) {
$this->data()->reload($data['data']);
} else {
$this->data = new SessionData($this, $data['data']);
}
}
/**
* Regenerate session token, but only if there is already one
*/
protected function regenerateTokenIfNotNew(): void
{
if ($this->tokenExpiry !== null) {
$this->regenerateToken();
}
}
/**
* Automatically renews the session if possible and necessary
*/
protected function autoRenew(): void
{
// check if the session needs renewal at all
if ($this->needsRenewal() !== true) {
return;
}
// re-load the session and check again to make sure that no other thread
// already renewed the session in the meantime
$this->prepareForWriting();
if ($this->needsRenewal() === true) {
$this->renew();
}
}
/**
* Checks if the session can be renewed and if the last renewal
* was more than half a session duration ago
*/
protected function needsRenewal(): bool
{
return
$this->renewable() === true &&
$this->expiryTime() - time() < $this->duration() / 2;
}
}

View file

@ -0,0 +1,215 @@
<?php
namespace Kirby\Session;
use Kirby\Exception\LogicException;
use Kirby\Toolkit\A;
/**
* The session object can be used to
* store visitor preferences for your
* site throughout various requests.
*
* @package Kirby Session
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class SessionData
{
/**
* Creates a new SessionData instance
*
* @codeCoverageIgnore
* @param \Kirby\Session\Session $session Session object this data belongs to
* @param array $data Currently stored session data
*/
public function __construct(
protected Session $session,
protected array $data
) {
}
/**
* Sets one or multiple session values by key
*
* @param string|array $key The key to define or a key-value array with multiple values
* @param mixed $value The value for the passed key (only if one $key is passed)
*/
public function set(
string|array $key,
mixed $value = null
): void {
$this->session->ensureToken();
$this->session->prepareForWriting();
if (is_string($key) === true) {
$this->data[$key] = $value;
} else {
$this->data = array_replace($this->data, $key);
}
}
/**
* Increments one or multiple session values by a specified amount
*
* @param string|array $key The key to increment or an array with multiple keys
* @param int $by Increment by which amount?
* @param int|null $max Maximum amount (value is not incremented further)
*/
public function increment(
string|array $key,
int $by = 1,
int|null $max = null
): void {
// if array passed, call method recursively
if (is_array($key) === true) {
foreach ($key as $k) {
$this->increment($k, $by, $max);
}
return;
}
// make sure we have the correct values before getting
$this->session->prepareForWriting();
$value = $this->get($key, 0);
if (is_int($value) === false) {
throw new LogicException(
key: 'session.data.increment.nonInt',
data: ['key' => $key],
fallback: 'Session value "' . $key . '" is not an integer and cannot be incremented',
translate: false
);
}
// increment the value, but ensure $max constraint
if (is_int($max) === true && $value + $by > $max) {
// set the value to $max
// but not if the current $value is already larger than $max
$value = max($value, $max);
} else {
$value += $by;
}
$this->set($key, $value);
}
/**
* Decrements one or multiple session values by a specified amount
*
* @param string|array $key The key to decrement or an array with multiple keys
* @param int $by Decrement by which amount?
* @param int|null $min Minimum amount (value is not decremented further)
*/
public function decrement(
string|array $key,
int $by = 1,
int|null $min = null
): void {
// if array passed, call method recursively
if (is_array($key) === true) {
foreach ($key as $k) {
$this->decrement($k, $by, $min);
}
return;
}
// make sure we have the correct values before getting
$this->session->prepareForWriting();
$value = $this->get($key, 0);
if (is_int($value) === false) {
throw new LogicException(
key: 'session.data.decrement.nonInt',
data: ['key' => $key],
fallback: 'Session value "' . $key . '" is not an integer and cannot be decremented',
translate: false
);
}
// decrement the value, but ensure $min constraint
if (is_int($min) === true && $value - $by < $min) {
// set the value to $min
// but not if the current $value is already smaller than $min
$value = min($value, $min);
} else {
$value -= $by;
}
$this->set($key, $value);
}
/**
* Returns one or all session values by key
*
* @param string|null $key The key to get or null for the entire data array
* @param mixed $default Optional default value to return if the key is not defined
*/
public function get(
string|null $key = null,
mixed $default = null
): mixed {
if ($key === null) {
return $this->data;
}
return $this->data[$key] ?? $default;
}
/**
* Retrieves a value and removes it afterwards
*
* @param string $key The key to get
* @param mixed $default Optional default value to return if the key is not defined
*/
public function pull(string $key, mixed $default = null): mixed
{
// make sure we have the correct value before getting
// we do this here (but not in get) as we need to write anyway
$this->session->prepareForWriting();
$value = $this->get($key, $default);
$this->remove($key);
return $value;
}
/**
* Removes one or multiple session values by key
*
* @param string|array $key The key to remove or an array with multiple keys
*/
public function remove(string|array $key): void
{
$this->session->prepareForWriting();
foreach (A::wrap($key) as $k) {
unset($this->data[$k]);
}
}
/**
* Clears all session data
*/
public function clear(): void
{
$this->session->prepareForWriting();
$this->data = [];
}
/**
* Reloads the data array with the current session data
* Only used internally
*
* @param array $data Currently stored session data
*/
public function reload(array $data): void
{
$this->data = $data;
}
}

View file

@ -0,0 +1,103 @@
<?php
namespace Kirby\Session;
/**
* @package Kirby Session
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
abstract class SessionStore
{
/**
* Creates a new session ID with the given expiry time
*
* Needs to make sure that the session does not already exist
* and needs to reserve it by locking it exclusively.
*
* @param int $expiryTime Timestamp
* @return string Randomly generated session ID (without timestamp)
*/
abstract public function createId(int $expiryTime): string;
/**
* Checks if the given session exists
*
* @param int $expiryTime Timestamp
* @param string $id Session ID
* @return bool true: session exists,
* false: session doesn't exist
*/
abstract public function exists(int $expiryTime, string $id): bool;
/**
* Locks the given session exclusively
*
* Needs to throw an Exception on error.
*
* @param int $expiryTime Timestamp
* @param string $id Session ID
*/
abstract public function lock(int $expiryTime, string $id): void;
/**
* Removes all locks on the given session
*
* Needs to throw an Exception on error.
*
* @param int $expiryTime Timestamp
* @param string $id Session ID
*/
abstract public function unlock(int $expiryTime, string $id): void;
/**
* Returns the stored session data of the given session
*
* Needs to throw an Exception on error.
*
* @param int $expiryTime Timestamp
* @param string $id Session ID
*/
abstract public function get(int $expiryTime, string $id): string;
/**
* Stores data to the given session
*
* Needs to make sure that the session exists.
* Needs to throw an Exception on error.
*
* @param int $expiryTime Timestamp
* @param string $id Session ID
* @param string $data Session data to write
*/
abstract public function set(int $expiryTime, string $id, string $data): void;
/**
* Deletes the given session
*
* Needs to throw an Exception on error.
*
* @param int $expiryTime Timestamp
* @param string $id Session ID
*/
abstract public function destroy(int $expiryTime, string $id): void;
/**
* Deletes all expired sessions
*
* Needs to throw an Exception on error.
*/
abstract public function collectGarbage(): void;
/**
* Securely generates a random session ID
*
* @return string Random hex string with 20 bytes
*/
protected static function generateId(): string
{
return bin2hex(random_bytes(10));
}
}

View file

@ -0,0 +1,274 @@
<?php
namespace Kirby\Session;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Http\Cookie;
use Kirby\Http\Request;
use Kirby\Toolkit\Str;
use Throwable;
/**
* Sessions - Base class for all session fiddling
*
* @package Kirby Session
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class Sessions
{
protected SessionStore $store;
protected string $mode;
protected string|null $cookieDomain;
protected string $cookieName;
protected array $cache = [];
/**
* Creates a new Sessions instance
*
* @param \Kirby\Session\SessionStore|string $store SessionStore object or a path to the storage directory (uses the FileSessionStore)
* @param array $options Optional additional options:
* - `mode`: Default token transmission mode (cookie, header or manual); defaults to `cookie`
* - `cookieDomain`: Domain to set the cookie to (this disables the cookie path restriction); defaults to none (default browser behavior)
* - `cookieName`: Name to use for the session cookie; defaults to `kirby_session`
* - `gcInterval`: How often should the garbage collector be run?; integer or `false` for never; defaults to `100`
*/
public function __construct(
SessionStore|string $store,
array $options = []
) {
$this->store = match (true) {
$store instanceof SessionStore => $store,
default => new FileSessionStore($store),
};
$this->mode = $options['mode'] ?? 'cookie';
$this->cookieDomain = $options['cookieDomain'] ?? null;
$this->cookieName = $options['cookieName'] ?? 'kirby_session';
$gcInterval = $options['gcInterval'] ?? 100;
// validate options
if (in_array($this->mode, ['cookie', 'header', 'manual'], true) === false) {
throw new InvalidArgumentException(
data: [
'method' => 'Sessions::__construct',
'argument' => '$options[\'mode\']'
],
translate: false
);
}
// trigger automatic garbage collection with the given probability
if (is_int($gcInterval) === true && $gcInterval > 0) {
// convert the interval into a probability between 0 and 1
$gcProbability = 1 / $gcInterval;
// generate a random number
$random = mt_rand(1, 10000);
// $random will be below or equal $gcProbability * 10000 with a probability of $gcProbability
if ($random <= $gcProbability * 10000) {
$this->collectGarbage();
}
} elseif ($gcInterval !== false) {
throw new InvalidArgumentException(
data: [
'method' => 'Sessions::__construct',
'argument' => '$options[\'gcInterval\']'
],
translate: false
);
}
}
/**
* Creates a new empty session
*
* @param array $options Optional additional options:
* - `mode`: Token transmission mode (cookie or manual); defaults to default mode of the Sessions instance
* - `startTime`: Time the session starts being valid (date string or timestamp); defaults to `now`
* - `expiryTime`: Time the session expires (date string or timestamp); defaults to `+ 2 hours`
* - `timeout`: Activity timeout in seconds (integer or false for none); defaults to `1800` (half an hour)
* - `renewable`: Should it be possible to extend the expiry date?; defaults to `true`
*/
public function create(array $options = []): Session
{
// fall back to default mode
$options['mode'] ??= $this->mode;
return new Session($this, null, $options);
}
/**
* Returns the specified Session object
*
* @param string $token Session token, either including or without the key
* @param string|null $mode Optional transmission mode override
*/
public function get(string $token, string|null $mode = null): Session
{
return $this->cache[$token] ??= new Session(
$this,
$token,
['mode' => $mode ?? $this->mode]
);
}
/**
* Returns the current session based on the configured token transmission mode:
* - In `cookie` mode: Gets the session from the cookie
* - In `header` mode: Gets the session from the `Authorization` request header
* - In `manual` mode: Fails and throws an Exception
*
* @return \Kirby\Session\Session|null Either the current session or null in case there isn't one
* @throws \Kirby\Exception\Exception
* @throws \Kirby\Exception\LogicException
*/
public function current(): Session|null
{
$token = match ($this->mode) {
'cookie' => $this->tokenFromCookie(),
'header' => $this->tokenFromHeader(),
'manual' => throw new LogicException(
key: 'session.sessions.manualMode',
fallback: 'Cannot automatically get current session in manual mode',
translate: false,
httpCode: 500
),
// unexpected error that shouldn't occur
default => throw new Exception(translate: false) // @codeCoverageIgnore
};
// no token was found, no session
if (is_string($token) === false) {
return null;
}
// token was found, try to get the session
try {
return $this->get($token);
} catch (Throwable) {
return null;
}
}
/**
* Returns the current session using the following detection order without using the configured mode:
* - Tries to get the session from the `Authorization` request header
* - Tries to get the session from the cookie
* - Otherwise returns null
*
* @return \Kirby\Session\Session|null Either the current session or null in case there isn't one
*/
public function currentDetected(): Session|null
{
$header = $this->tokenFromHeader();
$cookie = $this->tokenFromCookie();
// prefer header token over cookie token
$token = $header ?? $cookie;
// no token was found, no session
if (is_string($token) === false) {
return null;
}
// token was found, try to get the session
try {
return $this->get($token, match (true) {
$header !== null => 'header',
$cookie !== null => 'cookie'
});
} catch (Throwable) {
return null;
}
}
/**
* Getter for the session store instance
*/
public function store(): SessionStore
{
return $this->store;
}
/**
* Getter for the cookie domain
*/
public function cookieDomain(): string|null
{
return $this->cookieDomain;
}
/**
* Getter for the cookie name
*/
public function cookieName(): string
{
return $this->cookieName;
}
/**
* Deletes all expired sessions
*
* If the `gcInterval` is configured, this is done automatically
* on init of the Sessions object.
*/
public function collectGarbage(): void
{
$this->store()->collectGarbage();
}
/**
* Updates the instance cache with a newly created
* session or a session with a regenerated token
*
* @internal
* @param \Kirby\Session\Session $session Session instance to push to the cache
*/
public function updateCache(Session $session): void
{
$this->cache[$session->token()] = $session;
}
/**
* Returns the auth token from the cookie
*/
protected function tokenFromCookie(): string|null
{
$value = Cookie::get($this->cookieName());
if (is_string($value) === false) {
return null;
}
return $value;
}
/**
* Returns the auth token from the Authorization header
*/
protected function tokenFromHeader(): string|null
{
$request = new Request();
$headers = $request->headers();
// check if the header exists at all
if ($header = $headers['Authorization'] ?? null) {
// check if the header uses the "Session" scheme
if (Str::startsWith($header, 'Session ', true) !== true) {
return null;
}
// return the part after the scheme
return substr($header, 8);
}
return null;
}
}