400 lines
14 KiB
PHP
400 lines
14 KiB
PHP
<?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');
|
|
}
|