<?php
/**
* @file classes/session/SessionManager.php
*
* Copyright (c) 2014-2021 Simon Fraser University
* Copyright (c) 2000-2021 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class SessionManager
*
* @ingroup session
*
* @brief Implements PHP methods for a custom session storage handler (see http://php.net/session).
*/
namespace PKP\session;
use APP\core\Application;
use Carbon\Carbon;
use PKP\config\Config;
use PKP\core\PKPRequest;
use PKP\core\Registry;
use PKP\db\DAORegistry;
use SessionHandlerInterface;
class SessionManager implements SessionHandlerInterface
{
/** The DAO for accessing Session objects */
private SessionDao $sessionDao;
/** The Session associated with the current request */
private ?Session $userSession = null;
private PKPRequest $request;
/**
* Constructor.
* Initialize session configuration and set PHP session handlers.
* Attempts to rejoin a user's session if it exists, or create a new session otherwise.
*
*/
private function __construct()
{
$this->sessionDao = DAORegistry::getDAO('SessionDAO');
$this->request = Application::get()->getRequest();
$this->configure();
$this->start();
// If there's a session assigned to the session ID
if ($this->userSession) {
// Validate and refresh it
if ($this->isValid($this->userSession)) {
return $this->refresh();
}
// When invalid, regenerates the session ID without destroying the failed session (it might belong to another user)
session_regenerate_id();
}
$this->createSession();
}
/**
* Return an instance of the session manager.
*
*/
public static function getManager(): static
{
return Registry::get('sessionManager') ?? Registry::get('sessionManager', true, new static());
}
/**
* Invalidate given user's all sessions or except for the given session id
*
* @param int $userId The target user id for whom to invalidate sessions
*
*/
public function invalidateSessions(int $userId, string $excludableSessionId = null): bool
{
$this->getSessionDao()->deleteUserSessions($userId, $excludableSessionId);
return true;
}
/**
* Get the Session DAO instance associated with the current request
*
*/
public function getSessionDao(): SessionDao
{
return $this->sessionDao;
}
/**
* Get the session associated with the current request.
*/
public function getUserSession(): Session
{
return $this->userSession;
}
/**
* Open a session.
* Does nothing; only here to satisfy PHP session handler requirements.
*/
public function open(string $path, string $name): bool
{
return true;
}
/**
* Close a session.
* Does nothing; only here to satisfy PHP session handler requirements.
*/
public function close(): bool
{
return true;
}
/**
* Read session data from database.
*/
public function read(string $sessionId): string
{
$this->userSession ??= $this->sessionDao->getSession($sessionId);
return $this->userSession?->getSessionData() ?? '';
}
/**
* Save session data to database.
*/
public function write(string $sessionId, string $data): bool
{
if ($this->userSession) {
$this->userSession->setSessionData($data);
$this->sessionDao->updateObject($this->userSession);
}
return true;
}
/**
* Destroy (delete) a session.
*/
public function destroy(string $sessionId): bool
{
$this->sessionDao->deleteById($sessionId);
return true;
}
/**
* Garbage collect unused session data.
*
* @todo Use $lifetime instead of assuming 24 hours?
*
* @param int $lifetime the number of seconds after which data will be seen as "garbage" and cleaned up
*/
public function gc(int $lifetime): int|false
{
$sessionLifetimeInDays = max(0, Config::getVar('general', 'session_lifetime'));
$lastUsedRemember = $sessionLifetimeInDays ? Carbon::now()->subDays($sessionLifetimeInDays)->getTimestamp() : 0;
$this->sessionDao->deleteByLastUsed(Carbon::now()->subDay()->getTimestamp(), $lastUsedRemember);
return true;
}
/**
* Regenerate the session ID for the current user session.
* This is useful to guard against the "session fixation" form of hijacking
* by changing the user's session ID after they have logged in (in case the
* original session ID had been pre-populated).
*/
public function regenerateSessionId(): bool
{
// Indirectly calls $this->destroy() with the old session ID
if (!session_regenerate_id(true)) {
return false;
}
$this->userSession->setId(session_id());
$this->sessionDao->insertObject($this->userSession);
return true;
}
/**
* Change the lifetime of the current session cookie.
*
*/
public function updateSessionLifetime(int $expireTime = 0): bool
{
$options = session_get_cookie_params();
unset($options['lifetime']);
$options['expires'] = $expireTime;
return setcookie(session_name(), session_id(), $options);
}
/**
* Retrieves whether the session initialization is disabled
*/
public static function isDisabled(): bool
{
return defined('SESSION_DISABLE_INIT');
}
/**
* Prevents the session initialization
*
* @todo Drop the constant definition once it's safe
*/
public static function disable(): void
{
// Constant kept for backwards compatibility with applications <= 3.3.0
if (!defined('SESSION_DISABLE_INIT')) {
define('SESSION_DISABLE_INIT', true);
}
}
/**
* Retrieves whether the user has a session ID
*/
public static function hasSession(): bool
{
// If the session isn't disabled and a cookie is present or a session was started in the current request
return !static::isDisabled() && (isset($_COOKIE[Config::getVar('general', 'session_cookie_name')]) || !!session_id());
}
private function configure(): void
{
$domain = $this->request->getServerHost(includePort: false);
// Configure PHP session parameters
ini_set('session.name', Config::getVar('general', 'session_cookie_name'));
ini_set('session.cookie_lifetime', 0);
ini_set('session.cookie_path', Config::getVar('general', 'session_cookie_path', $this->request->getBasePath() . '/'));
ini_set('session.cookie_domain', $domain);
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_samesite', Config::getVar('general', 'same_site', 'Lax'));
ini_set('session.cookie_secure', Config::getVar('security', 'force_ssl'));
ini_set('session.use_trans_sid', 0);
ini_set('session.serialize_handler', 'php');
ini_set('session.use_cookies', 1);
ini_set('session.gc_probability', 1);
ini_set('session.gc_maxlifetime', 60 * 60);
ini_set('session.cache_limiter', 'none');
session_set_save_handler($this, true);
}
/**
* Starts the session
*
* In case there are many session cookies, it attempts to find the best one and clear the remaining, considering the issues below:
* Empty domains: Old applications (e.g. OJS 2.x) didn't store the domain, therefore users revising the application after a migration might be affected, we'll drop it.
* Subdomains mixed with parent domains: Users visiting "journal.sfu.ca", then "www.journal.sfu.ca" might end up with N+1 session cookies, this is problematic, we'll try to drop the excess.
* Domain cookies belonging to different paths or expired sessions: They will be kept (until the user clears his cookies) and probably trigger the extra checks.
*/
private function start(): void
{
$sessionIds = collect($this->getSessionIds());
// Standard flow with a single session ID
if ($sessionIds->count() < 2) {
session_start();
return;
}
$requestDomain = $this->request->getServerHost(includePort: false);
/** @var \Illuminate\Support\Collection<int,Session> */
$sessions = $sessionIds
// Attempts to map the ID to an active session
->map(fn (string $sessionId) => $this->sessionDao->getSession($sessionId))
// Only sessions with valid domains (empty domains are also accepted)
->filter(fn (?Session $session) => $session && str_ends_with(strtolower($requestDomain), strtolower($session->getDomain())));
/** @var ?Session */
$bestSession = $sessions->reduce(function (?Session $best, Session $current): ?Session {
// Skip invalid sessions
if (!$this->isValid($current)) {
return $best;
}
// Give priority to logged in sessions
if ($current->getUserId() && !$best?->getUserId()) {
return $current;
}
// Give priority to the session which was used most recently
return $current->getSecondsLastUsed() > (int) $best?->getSecondsLastUsed() ? $current : $best;
});
/** @var \Illuminate\Support\Collection<int,string> */
$domains = $sessions->map(fn (Session $session) => $session->getDomain() ?: $requestDomain)->unique();
// Prefers the parent domain (smaller length) to define the session, fallbacks to the request domain
$bestDomain = $domains->reduce(fn (?string $best, string $current) => $best && strlen($best) <= strlen($current) ? $best : $current) ?: $requestDomain;
// Ensures the session domain isn't empty
$bestSession?->setDomain($bestDomain);
// Updates the domain setting while the session is closed
ini_set('session.cookie_domain', $bestDomain);
// Seed the session with the proper ID
session_id($bestSession?->getId() ?? session_create_id());
session_start();
// The session cookies must be dropped **after** the session is started, otherwise PHP will not send the headers to clear them
$this->clearDiscardedSessions($domains->toArray(), $bestDomain);
// Ensures the domain is updated (data will be saved once the session gets closed)
$this->userSession?->setDomain($bestDomain);
$this->updateSessionLifetime();
}
/**
* Clears discarded session cookies
*
* @param string[] $domains
*/
private function clearDiscardedSessions(array $domains, string $bestDomain): void
{
// Includes non-specified/empty domain (cleanup deprecated domainless cookie from OJS 2.x)
$domains[] = '';
$requestDomain = $this->request->getServerHost(includePort: false);
// Includes the request domain if it's not the domain used by the session
if ($requestDomain !== $bestDomain) {
$domains[] = $requestDomain;
}
// Drops only the cookies (the session data will be cleared by the garbage collector, if we attempt to drop them here we may affect other users)
foreach (array_unique($domains) as $domain) {
setcookie(session_name(), '', ['domain' => $domain, 'path' => ini_get('session.cookie_path')]);
}
}
/**
* Retrieves whether the given session is valid
*/
private function isValid(Session $session): bool
{
// Same IP address (if IP validation is enabled)
return (!Config::getVar('security', 'session_check_ip') || $session->getIpAddress() === $this->request->getRemoteAddr())
// Same user agent
&& $session->getUserAgent() === substr($this->request->getUserAgent(), 0, 255)
// Compatible domain
&& (!$session->getDomain() || str_ends_with(strtolower($this->request->getServerHost(includePort: false)), strtolower($session->getDomain())));
}
/**
* Refreshes the session expiration
*/
private function refresh(): void
{
// Update existing session's timestamp; will be saved when write is called
$this->userSession->setSecondsLastUsed(time());
if (!$this->userSession->getRemember()) {
return;
}
// Update session timestamp for remembered sessions so it doesn't expire in the middle of a browser session
$lifetime = max(0, Config::getVar('general', 'session_lifetime'));
$this->userSession->setRemember((bool) $lifetime);
$this->updateSessionLifetime($lifetime ? Carbon::now()->addDays($lifetime)->getTimestamp() : 0);
}
/**
* Creates a new session
*/
private function createSession(): void
{
$now = time();
$this->userSession = $this->sessionDao->newDataObject();
$this->userSession->setId(session_id());
$this->userSession->setIpAddress($this->request->getRemoteAddr());
$this->userSession->setUserAgent($this->request->getUserAgent());
$this->userSession->setSecondsCreated($now);
$this->userSession->setSecondsLastUsed($now);
$this->userSession->setDomain(ini_get('session.cookie_domain'));
$this->userSession->setSessionData('');
$this->sessionDao->insertObject($this->userSession);
}
/**
* Retrieve session IDs sent by the browser
*
* @return string[]
*/
private function getSessionIds(): array
{
$ids = [];
foreach (explode('; ', $_SERVER['HTTP_COOKIE'] ?? '') as $cookie) {
$nameValue = explode('=', $cookie, 2);
$value = trim(urldecode($nameValue[1] ?? ''));
if ($nameValue[0] === session_name() && strlen($value)) {
$ids[$value] = 0;
}
}
return array_keys($ids);
}
}
if (!PKP_STRICT_MODE) {
class_alias('\PKP\session\SessionManager', '\SessionManager');
}
|