2022-06-17 17:51:59 +02:00
|
|
|
<?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 Throwable;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @package Kirby Session
|
|
|
|
* @author Lukas Bestle <lukas@getkirby.com>
|
|
|
|
* @link https://getkirby.com
|
|
|
|
* @copyright Bastian Allgeier
|
|
|
|
* @license https://opensource.org/licenses/MIT
|
|
|
|
*/
|
|
|
|
class Session
|
|
|
|
{
|
2022-08-31 15:02:43 +02:00
|
|
|
// parent data
|
|
|
|
protected $sessions;
|
|
|
|
protected $mode;
|
|
|
|
|
|
|
|
// parts of the token
|
|
|
|
protected $tokenExpiry;
|
|
|
|
protected $tokenId;
|
|
|
|
protected $tokenKey;
|
|
|
|
|
|
|
|
// persistent data
|
|
|
|
protected $startTime;
|
|
|
|
protected $expiryTime;
|
|
|
|
protected $duration;
|
|
|
|
protected $timeout;
|
|
|
|
protected $lastActivity;
|
|
|
|
protected $renewable;
|
|
|
|
protected $data;
|
|
|
|
protected $newSession;
|
|
|
|
|
|
|
|
// temporary state flags
|
|
|
|
protected $updatingLastActivity = false;
|
|
|
|
protected $destroyed = false;
|
|
|
|
protected $writeMode = false;
|
|
|
|
protected $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(Sessions $sessions, $token, array $options)
|
|
|
|
{
|
|
|
|
$this->sessions = $sessions;
|
|
|
|
$this->mode = $options['mode'] ?? 'cookie';
|
|
|
|
|
|
|
|
if (is_string($token)) {
|
|
|
|
// 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();
|
|
|
|
}
|
|
|
|
} elseif ($token === null) {
|
|
|
|
// new session
|
|
|
|
|
|
|
|
// 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
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
if (!is_int($this->timeout) && $this->timeout !== false) {
|
|
|
|
throw new InvalidArgumentException([
|
|
|
|
'data' => ['method' => 'Session::__construct', 'argument' => '$options[\'timeout\']'],
|
|
|
|
'translate' => false
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
if (!is_bool($this->renewable)) {
|
|
|
|
throw new InvalidArgumentException([
|
|
|
|
'data' => ['method' => 'Session::__construct', 'argument' => '$options[\'renewable\']'],
|
|
|
|
'translate' => false
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
|
|
|
|
// set activity time if a timeout was requested
|
|
|
|
if (is_int($this->timeout)) {
|
|
|
|
$this->lastActivity = time();
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
throw new InvalidArgumentException([
|
|
|
|
'data' => ['method' => 'Session::__construct', 'argument' => '$token'],
|
|
|
|
'translate' => false
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
|
|
|
|
// ensure that all changes are committed on script termination
|
|
|
|
register_shutdown_function([$this, 'commit']);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Gets the session token or null if the session doesn't have a token yet
|
|
|
|
*
|
|
|
|
* @return string|null
|
|
|
|
*/
|
|
|
|
public function token()
|
|
|
|
{
|
|
|
|
if ($this->tokenExpiry !== null) {
|
|
|
|
if (is_string($this->tokenKey)) {
|
|
|
|
return $this->tokenExpiry . '.' . $this->tokenId . '.' . $this->tokenKey;
|
|
|
|
}
|
2022-12-19 14:56:05 +01:00
|
|
|
|
|
|
|
return $this->tokenExpiry . '.' . $this->tokenId;
|
2022-08-31 15:02:43 +02:00
|
|
|
}
|
2022-12-19 14:56:05 +01:00
|
|
|
|
|
|
|
return null;
|
2022-08-31 15:02:43 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Gets or sets the transmission mode
|
|
|
|
* Setting only works for new sessions that haven't been transmitted yet
|
|
|
|
*
|
|
|
|
* @param string $mode Optional new transmission mode
|
|
|
|
* @return string Transmission mode
|
|
|
|
*/
|
|
|
|
public function mode(string $mode = null)
|
|
|
|
{
|
|
|
|
if (is_string($mode)) {
|
|
|
|
// 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 $expiryTime Optional new expiry timestamp or time string to set
|
|
|
|
* @return int Timestamp
|
|
|
|
*/
|
|
|
|
public function expiryTime($expiryTime = null): int
|
|
|
|
{
|
|
|
|
if (is_string($expiryTime) || is_int($expiryTime)) {
|
|
|
|
// 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();
|
|
|
|
} elseif ($expiryTime !== null) {
|
|
|
|
throw new InvalidArgumentException([
|
|
|
|
'data' => ['method' => 'Session::expiryTime', 'argument' => '$expiryTime'],
|
|
|
|
'translate' => false
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
|
|
|
|
return $this->expiryTime;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Gets or sets the session duration
|
|
|
|
* Setting the duration also updates the expiry time and regenerates the session token
|
|
|
|
*
|
|
|
|
* @param int $duration Optional new duration in seconds to set
|
|
|
|
* @return int Number of seconds
|
|
|
|
*/
|
|
|
|
public function duration(int $duration = null): int
|
|
|
|
{
|
|
|
|
if (is_int($duration)) {
|
|
|
|
// 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 $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($timeout = null)
|
|
|
|
{
|
|
|
|
if (is_int($timeout) || $timeout === false) {
|
|
|
|
// verify that the timeout is at least 1 second
|
|
|
|
if (is_int($timeout) && $timeout < 1) {
|
|
|
|
throw new InvalidArgumentException([
|
|
|
|
'data' => ['method' => 'Session::timeout', 'argument' => '$timeout'],
|
|
|
|
'translate' => false
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
|
|
|
|
$this->prepareForWriting();
|
|
|
|
$this->timeout = $timeout;
|
|
|
|
|
|
|
|
if (is_int($timeout)) {
|
|
|
|
$this->lastActivity = time();
|
|
|
|
} else {
|
|
|
|
$this->lastActivity = null;
|
|
|
|
}
|
|
|
|
} elseif ($timeout !== null) {
|
|
|
|
throw new InvalidArgumentException([
|
|
|
|
'data' => ['method' => 'Session::timeout', 'argument' => '$timeout'],
|
|
|
|
'translate' => false
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
|
|
|
|
return $this->timeout;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Gets or sets the renewable flag
|
|
|
|
* Automatically renews the session if renewing gets enabled
|
|
|
|
*
|
|
|
|
* @param bool $renewable Optional new renewable flag to set
|
|
|
|
* @return bool
|
|
|
|
*/
|
|
|
|
public function renewable(bool $renewable = null): bool
|
|
|
|
{
|
|
|
|
if (is_bool($renewable)) {
|
|
|
|
$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
|
|
|
|
* @return mixed
|
|
|
|
*/
|
|
|
|
public function __call(string $name, array $arguments)
|
|
|
|
{
|
|
|
|
// validate that we can handle the called method
|
|
|
|
if (!in_array($name, ['set', 'increment', 'decrement', 'get', 'pull', 'remove', 'clear'])) {
|
|
|
|
throw new BadMethodCallException([
|
|
|
|
'data' => ['method' => 'Session::' . $name],
|
|
|
|
'translate' => false
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
|
|
|
|
return $this->data()->$name(...$arguments);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Writes all changes to the session to the session store
|
|
|
|
*
|
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
public function commit()
|
|
|
|
{
|
|
|
|
// 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
|
|
|
|
*/
|
2023-04-14 16:34:06 +02:00
|
|
|
if (
|
|
|
|
$this->writeMode !== true ||
|
|
|
|
$this->tokenExpiry === null ||
|
|
|
|
$this->destroyed === true
|
|
|
|
) {
|
2022-08-31 15:02:43 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// collect all data
|
|
|
|
if ($this->newSession) {
|
|
|
|
// 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
|
|
|
|
];
|
|
|
|
} 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
|
|
|
|
*
|
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
public function destroy()
|
|
|
|
{
|
|
|
|
// 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
|
|
|
|
*
|
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
public function renew()
|
|
|
|
{
|
|
|
|
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
|
|
|
|
*
|
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
public function regenerateToken()
|
|
|
|
{
|
|
|
|
// 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;
|
|
|
|
$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') {
|
|
|
|
Cookie::set($this->sessions->cookieName(), $this->token(), [
|
|
|
|
'lifetime' => $this->tokenExpiry,
|
|
|
|
'path' => Url::index(['host' => null, 'trailingSlash' => true]),
|
|
|
|
'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
|
|
|
|
*
|
|
|
|
* @return bool
|
|
|
|
*/
|
|
|
|
public function needsRetransmission(): bool
|
|
|
|
{
|
|
|
|
return $this->needsRetransmission;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Ensures that all pending changes are written to disk before the object is destructed
|
|
|
|
*/
|
|
|
|
public function __destruct()
|
|
|
|
{
|
|
|
|
$this->commit();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Initially generates the token for new sessions
|
|
|
|
* Used internally
|
|
|
|
*
|
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
public function ensureToken()
|
|
|
|
{
|
|
|
|
if ($this->tokenExpiry === null) {
|
|
|
|
$this->regenerateToken();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Puts the session into write mode by acquiring a lock and reloading the data
|
|
|
|
* Used internally
|
|
|
|
*
|
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
public function prepareForWriting()
|
|
|
|
{
|
|
|
|
// 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
|
|
|
|
*/
|
2023-04-14 16:34:06 +02:00
|
|
|
if (
|
|
|
|
$this->tokenExpiry === null ||
|
|
|
|
$this->destroyed === true ||
|
|
|
|
$this->writeMode === true
|
|
|
|
) {
|
2022-08-31 15:02:43 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// don't allow writing for read-only sessions
|
|
|
|
// (only the case for moved sessions)
|
|
|
|
/**
|
|
|
|
* @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;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
protected function parseToken(string $token, bool $withoutKey = false)
|
|
|
|
{
|
|
|
|
// 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 $now Timestamp to use as a base for the calculation of relative dates
|
|
|
|
* @return int Timestamp value
|
|
|
|
*/
|
|
|
|
protected static function timeToTimestamp($time, int $now = null): int
|
|
|
|
{
|
|
|
|
// default to current time as $now
|
|
|
|
if (!is_int($now)) {
|
|
|
|
$now = time();
|
|
|
|
}
|
|
|
|
|
|
|
|
// convert date strings to a timestamp first
|
|
|
|
if (is_string($time)) {
|
|
|
|
$time = strtotime($time, $now);
|
|
|
|
}
|
|
|
|
|
|
|
|
// now make sure that we have a valid timestamp
|
|
|
|
if (is_int($time)) {
|
|
|
|
return $time;
|
|
|
|
}
|
2022-12-19 14:56:05 +01:00
|
|
|
|
|
|
|
throw new InvalidArgumentException([
|
|
|
|
'data' => [
|
|
|
|
'method' => 'Session::timeToTimestamp',
|
|
|
|
'argument' => '$time'
|
|
|
|
],
|
|
|
|
'translate' => false
|
|
|
|
]);
|
2022-08-31 15:02:43 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Loads the session data from the session store
|
|
|
|
*
|
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
protected function init()
|
|
|
|
{
|
|
|
|
// 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
|
|
|
|
try {
|
|
|
|
$data = unserialize($data);
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
throw new LogicException([
|
|
|
|
'key' => 'session.invalid',
|
|
|
|
'data' => ['token' => $this->token()],
|
|
|
|
'fallback' => 'Session "' . $this->token() . '" is invalid',
|
|
|
|
'translate' => false,
|
|
|
|
'httpCode' => 500,
|
|
|
|
'previous' => $e
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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'])) {
|
|
|
|
$this->parseToken($data['newSession'], true);
|
|
|
|
$this->init();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// verify timeout
|
|
|
|
if (is_int($data['timeout'])) {
|
|
|
|
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
|
2022-12-19 14:56:05 +01:00
|
|
|
if ($this->data instanceof SessionData) {
|
2022-08-31 15:02:43 +02:00
|
|
|
$this->data()->reload($data['data']);
|
|
|
|
} else {
|
|
|
|
$this->data = new SessionData($this, $data['data']);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Regenerate session token, but only if there is already one
|
|
|
|
*
|
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
protected function regenerateTokenIfNotNew()
|
|
|
|
{
|
|
|
|
if ($this->tokenExpiry !== null) {
|
|
|
|
$this->regenerateToken();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Automatically renews the session if possible and necessary
|
|
|
|
*
|
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
protected function autoRenew()
|
|
|
|
{
|
|
|
|
// 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
|
|
|
|
*
|
|
|
|
* @return bool
|
|
|
|
*/
|
|
|
|
protected function needsRenewal(): bool
|
|
|
|
{
|
|
|
|
return $this->renewable() === true && $this->expiryTime() - time() < $this->duration() / 2;
|
|
|
|
}
|
2022-06-17 17:51:59 +02:00
|
|
|
}
|