Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
eb57634
Adding a session expired header to the NoAccessException error
chippison Dec 18, 2025
799635f
put back old version of Access.php
chippison Dec 19, 2025
22ca0cb
adding a flag to indicate session has expired
chippison Dec 21, 2025
a7ff017
add indication that session is invalid/expired when token in request …
chippison Dec 21, 2025
d379f03
adding a check that login and token auth is null as indication that s…
chippison Dec 21, 2025
ba963d2
only enforcing 401 http code in header when session expired flag is t…
chippison Dec 21, 2025
5c08a49
Making sure we 'await' when saving testEnvironment as well as waiting…
chippison Dec 22, 2025
3ec2253
making widgetloader open a blank page first
chippison Dec 22, 2025
e76a3e0
fixing test
chippison Dec 22, 2025
816b0bf
adding options override
chippison Dec 22, 2025
71c8b20
remove commented out code
chippison Dec 22, 2025
a61f1c6
changing the test so that it now just checks for the session expired …
chippison Dec 23, 2025
f1a04ad
test using a different test fixture
chippison Dec 23, 2025
121b0f1
adding back the test to check the error message is shown
chippison Dec 23, 2025
12a3eaa
Improve detection of session timeouts
sgiehl Jan 15, 2026
cfc87e6
use custom header instead of status code
sgiehl Jan 15, 2026
462cbc2
Adds UI test
sgiehl Jan 15, 2026
d499feb
presere timeout state in cookie
sgiehl Jan 15, 2026
71edc17
making sure the setting the session expired flag to true when expirat…
chippison Jan 15, 2026
f0b9b36
handling the case where anonymous user is allowed to view the site. T…
chippison Jan 15, 2026
9a1fdc4
adding tests; removed unused space
chippison Jan 16, 2026
e649089
updating systems test and integration tests for Core to reflect our c…
chippison Jan 16, 2026
ed0b24a
updating UI tests
chippison Jan 16, 2026
b9ca187
fixed missed test after testing
chippison Jan 16, 2026
07714c8
apply review feedback
sgiehl Jan 19, 2026
87a734b
Adds UI test
sgiehl Jan 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions core/Access.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Piwik\Access\CapabilitiesProvider;
use Piwik\API\Request;
use Piwik\Access\RolesProvider;
use Piwik\Http\BadRequestException;
use Piwik\Request\AuthenticationToken;
use Piwik\Container\StaticContainer;
use Piwik\Plugins\SitesManager\API as SitesManagerApi;
Expand Down Expand Up @@ -78,6 +79,11 @@ class Access
*/
private $auth = null;

/**
* @var bool
*/
private $sessionExpired = false;

/**
* Gets the singleton instance. Creates it if necessary.
*
Expand Down Expand Up @@ -627,7 +633,7 @@ public function checkUserHasCapability($idSites, $capability)
/**
* @param int|array|string $idSites
* @return array
* @throws \Piwik\NoAccessException
* @throws BadRequestException
*/
protected function getIdSites($idSites)
{
Expand All @@ -638,7 +644,7 @@ protected function getIdSites($idSites)
$idSites = Site::getIdSitesFromIdSitesString($idSites);

if (empty($idSites)) {
$this->throwNoAccessException("The parameter 'idSite=' is missing from the request.");
throw new BadRequestException("The parameter 'idSite=' is missing from the request.");
}

return $idSites;
Expand Down Expand Up @@ -745,21 +751,24 @@ private function throwNoAccessException($message)
{
if (Piwik::isUserIsAnonymous() && !Request::isRootRequestApiRequest()) {
$message = Piwik::translate('General_YouMustBeLoggedIn');

// Try to detect whether user was previously logged in so that we can display a different message
$referrer = Url::getReferrer();
$matomoUrl = SettingsPiwik::getPiwikUrl();
if (
$referrer && $matomoUrl && Url::isValidHost(Url::getHostFromUrl($referrer)) &&
strpos($referrer, $matomoUrl) === 0
) {
if ($this->sessionExpired) {
$message = Piwik::translate('General_YourSessionHasExpired');
}
}

throw new NoAccessException($message);
}

public function setSessionExpired(bool $sessionExpired): void
{
$this->sessionExpired = $sessionExpired;
}

public function wasSessionExpired(): bool
{
return $this->sessionExpired;
}

/**
* Returns true if the current user is logged in or not.
*
Expand Down
30 changes: 29 additions & 1 deletion core/FrontController.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ class FrontController extends Singleton
public const DEFAULT_MODULE = 'CoreHome';
public const DEFAULT_LOGIN = 'anonymous';
public const DEFAULT_TOKEN_AUTH = 'anonymous';
private const SESSION_TIMEOUT_COOKIE_NAME = 'matomo_session_timed_out';

// public for tests
public static $requestId = null;
Expand Down Expand Up @@ -425,6 +426,9 @@ public function init()
$sessionAuth = $this->makeSessionAuthenticator();
if ($sessionAuth) {
$loggedIn = Access::getInstance()->reloadAccess($sessionAuth);
if (!$loggedIn && $sessionAuth->wasSessionExpired()) {
Access::getInstance()->setSessionExpired(true);
}
}

// ... if session auth fails try normal auth (which will login the anonymous user)
Expand All @@ -449,7 +453,8 @@ public function init()
$this->makeAuthenticator($sessionAuth); // Piwik\Auth must be set to the correct Login plugin
}


$this->consumeSessionTimeoutCookie();
$this->sendSessionTimedOutHeaderIfNeeded();

// Force the auth to use the token_auth if specified, so that embed dashboard
// and all other non widgetized controller methods works fine
Expand Down Expand Up @@ -806,6 +811,21 @@ private static function setRequestIdHeader()
Common::sendHeader("X-Matomo-Request-Id: $requestId");
}

private function consumeSessionTimeoutCookie(): void
{
$cookie = new Cookie(self::SESSION_TIMEOUT_COOKIE_NAME);

if (!$cookie->isCookieFound()) {
return;
}

$cookie->delete();

if (Piwik::isUserIsAnonymous()) {
Access::getInstance()->setSessionExpired(true);
}
}

private function isSupportedBrowserCheckNeeded()
{
if (defined('PIWIK_ENABLE_DISPATCH') && !PIWIK_ENABLE_DISPATCH) {
Expand Down Expand Up @@ -845,4 +865,12 @@ private function isSupportedBrowserCheckNeeded()

return false;
}

private function sendSessionTimedOutHeaderIfNeeded()
{
if (!Access::getInstance()->wasSessionExpired()) {
return;
}
Common::sendHeader('X-Matomo-Session-Timed-Out: 1');
}
}
14 changes: 14 additions & 0 deletions core/Session/SessionAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ class SessionAuth implements Auth

private $tokenAuth;

/**
* @var bool
*/
private $sessionExpired = false;

public function __construct(?UsersModel $userModel = null, $shouldDestroySession = true)
{
$this->userModel = $userModel ?: new UsersModel();
Expand Down Expand Up @@ -97,6 +102,7 @@ public function setPasswordHash(

public function authenticate()
{
$this->sessionExpired = false;
$sessionFingerprint = new SessionFingerprint();
$userModel = $this->userModel;

Expand Down Expand Up @@ -243,9 +249,17 @@ private function isExpiredSession(SessionFingerprint $sessionFingerprint)
}

$isExpired = Date::now()->getTimestampUTC() > $expirationTime;
if ($isExpired) {
$this->sessionExpired = true;
}
return $isExpired;
}

public function wasSessionExpired(): bool
{
return $this->sessionExpired;
}

private function checkIfSessionFailedToRead()
{
if (Session\SaveHandler\DbTable::$wasSessionToLargeToRead) {
Expand Down
78 changes: 78 additions & 0 deletions plugins/CoreHome/tests/UI/AjaxHelperSessionTimeout_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*!
* Matomo - free/libre analytics platform
*
* AjaxHelper session timeout UI test.
*
* @link https://matomo.org
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/

describe('AjaxHelperSessionTimeout', function () {
this.fixture = "Piwik\\Tests\\Fixtures\\OneVisitorTwoVisits";

const reportUrl = '?module=CoreHome&action=index&idSite=1&period=day&date=yesterday';

async function loadReportPage() {
await page.goto(reportUrl);
await page.waitForNetworkIdle();
await page.waitForFunction(() => window.ajaxHelper && window.piwikHelper);
}

const cases = [
{
name: 'should refresh when a request indicates the session has timed out',
headerValue: '1',
expectedRefresh: true,
},
{
name: 'should not refresh when the session timeout header is missing',
headerValue: null,
expectedRefresh: false,
},
];

cases.forEach(({ name, headerValue, expectedRefresh }) => {
it(name, async function () {
await loadReportPage();

const refreshCalled = await page.evaluate((value) => {
document.cookie = 'matomo_session_timed_out=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
window._ajaxSessionTimedOutRefresh = false;
const originalRefresh = window.piwikHelper.refreshAfter;
const originalAjax = window.$.ajax;

window.piwikHelper.refreshAfter = (timeout) => {
window._ajaxSessionTimedOutRefresh = timeout === 0;
};

const mockXhr = {
status: 401,
statusText: 'error',
getResponseHeader: (name) => (name === 'X-Matomo-Session-Timed-Out' ? value : null),
then() {
return this;
},
fail(callback) {
this._fail = callback;
return this;
},
};

window.$.ajax = () => mockXhr;

const helper = new window.ajaxHelper();
helper.send();
if (typeof mockXhr._fail === 'function') {
mockXhr._fail(mockXhr);
}

window.$.ajax = originalAjax;
window.piwikHelper.refreshAfter = originalRefresh;

return window._ajaxSessionTimedOutRefresh;
}, headerValue);

expect(refreshCalled).to.equal(expectedRefresh);
});
});
});
51 changes: 0 additions & 51 deletions plugins/CoreHome/tests/UI/WidgetLoader_spec.js

This file was deleted.

Loading
Loading