Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 12 additions & 17 deletions Auth/Base.php
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,17 @@ protected function getUserForLogin()
if (!empty($this->login)) {
$user = $this->usersModel->getUser($this->login);

if (!empty($user) && !$this->isSameLogin($this->login, $user['login'])) {
if (!empty($user) && !UserIdentity::isSameLogin($this->login, $user['login'])) {
$this->logger->warning(
"Auth\\Base::{func}: refusing to authenticate '{assertedLogin}': it resolves to the "
. "existing Matomo user '{storedLogin}', which is a different login.",
array(
'func' => __FUNCTION__,
'assertedLogin' => $this->login,
'storedLogin' => $user['login'],
)
);

throw new Exception(sprintf(
"Refusing to authenticate: asserted login '%s' resolved to the different existing user '%s'.",
$this->login,
Expand All @@ -307,21 +317,6 @@ protected function getUserForLogin()
return $this->userForLogin;
}

/**
* Returns whether the asserted login and the login of the user row it resolved to identify the same user.
*
* Implementations that verify no credential against the returned row have to override this and require an
* exact match.
*
* @param string $assertedLogin
* @param string $storedLogin
* @return bool
*/
protected function isSameLogin(string $assertedLogin, string $storedLogin): bool
{
return UserIdentity::isSameLogin($assertedLogin, $storedLogin);
}

protected function tryFallbackAuth($onlySuperUsers = true, ?Auth $auth = null)
{
if (empty($auth)) {
Expand Down Expand Up @@ -387,7 +382,7 @@ protected function makeSuccessLogin($userInfo)

protected function makeAuthFailure()
{
return new AuthResult(AuthResult::FAILURE, $this->login, null);
return new AuthResult(AuthResult::FAILURE, $this->login, '');
}

protected function authenticateByLdap()
Expand Down
37 changes: 20 additions & 17 deletions Auth/WebServerAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
use Piwik\Plugins\LoginLdap\Ldap\Exceptions\ConnectionException;
use Piwik\Plugins\LoginLdap\LdapInterop\UserSynchronizer;
use Piwik\Plugins\LoginLdap\Model\LdapUsers;
use Piwik\Plugins\LoginLdap\UserIdentity;
use Piwik\Plugins\UsersManager\API as UsersManagerAPI;
use Piwik\Plugins\UsersManager\Model as UserModel;
use Piwik\Session;
Expand Down Expand Up @@ -153,8 +152,15 @@ private static function getAlreadyAuthenticatedLogin()
}

/**
* Returns the login the web server authenticated for this request, normalized the way
* {@link self::authenticate()} normalizes it, or null when the web server authenticated nobody.
* Returns the login the web server authenticated for this request, or null when it authenticated nobody.
*
* The single answer to "who does the web server say this is", so that authentication, the session guard
* and {@link self::isCurrentRequestWebServerAuthenticated()} cannot disagree about it.
*
* The value is returned as the web server gave it. Trimming it here would let "ironman " authenticate as
* "ironman", which is a row the login column's collation returns for it and which the login comparison
* exists to refuse. Trimming decides only whether anybody was asserted at all, so that a REMOTE_USER of
* " ", or one that strips to nothing such as "SHIELD\\", names nobody rather than the empty login.
*
* @return string|null
*/
Expand All @@ -167,29 +173,26 @@ public static function getAssertedLogin(): ?string
}

if (Config::getStripDomainFromWebAuth()) {
return preg_replace('/(.*?\\\\)|(@.*)/', '', $webServerAuthUser);
$webServerAuthUser = preg_replace('/(.*?\\\\)|(@.*)/', '', $webServerAuthUser);
}

return $webServerAuthUser;
}

public static function isCurrentRequestWebServerAuthenticated(): bool
{
$auth = StaticContainer::get('Piwik\Auth');
return $auth instanceof WebServerAuth && !empty($_SERVER['REMOTE_USER']);
return trim($webServerAuthUser) === '' ? null : $webServerAuthUser;
}

/**
* No password, password hash or token auth is verified against the row the asserted login resolves to, so
* unlike the other auth implementations this one requires the stored login to match it exactly.
* Returns whether the web server authenticated somebody for this request.
*
* Callers use this to skip Matomo's own password confirmation, so it has to agree with
* {@link self::getAssertedLogin()}: an assertion naming nobody authenticates nobody, and must not skip
* anything.
*
* @param string $assertedLogin
* @param string $storedLogin
* @return bool
*/
protected function isSameLogin(string $assertedLogin, string $storedLogin): bool
public static function isCurrentRequestWebServerAuthenticated(): bool
{
return UserIdentity::isSameLoginExact($assertedLogin, $storedLogin);
$auth = StaticContainer::get('Piwik\Auth');

return $auth instanceof WebServerAuth && self::getAssertedLogin() !== null;
}

private function synchronizeLoggedInUser()
Expand Down
17 changes: 10 additions & 7 deletions Auth/WebServerSessionAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
use Piwik\Log\LoggerInterface;
use Piwik\Plugins\LoginLdap\Config;
use Piwik\Plugins\LoginLdap\UserIdentity;
use Piwik\Session;
use Piwik\Session\SessionAuth;
use Piwik\Session\SessionFingerprint;

Expand Down Expand Up @@ -72,7 +71,7 @@ public function authenticate()

$this->endSession();

return new AuthResult(AuthResult::FAILURE, null, null);
return new AuthResult(AuthResult::FAILURE, '', '');
}

/**
Expand All @@ -92,14 +91,18 @@ private function getSessionUserToEndFor(): ?string
$assertedLogin = WebServerAuth::getAssertedLogin();

// an absent REMOTE_USER is not a mismatch: setups that require web server authentication on only some
// paths would otherwise log people out at random
// paths would otherwise log people out at random. getAssertedLogin() also reports one that names
// nobody as absent, so this and WebServerAuth agree on every value.
//
// A REMOTE_USER that only differs from the session's user by surrounding whitespace is a mismatch and
// ends the session, as it would be for any other login WebServerAuth refuses to authenticate.
if ($assertedLogin === null) {
return null;
}

$sessionUser = (new SessionFingerprint())->getUser();

if (empty($sessionUser) || UserIdentity::isSameLoginExact($assertedLogin, $sessionUser)) {
if (empty($sessionUser) || UserIdentity::isSameLogin($assertedLogin, $sessionUser)) {
return null;
}

Expand All @@ -114,9 +117,9 @@ private function getSessionUserToEndFor(): ?string
*/
private function endSession(): void
{
if (Session::isSessionStarted()) {
$_SESSION = array();
}
// not gated on Session::isSessionStarted(): Session::start() returns before setting that flag when a
// session is already active, so the flag can be false while $_SESSION holds the previous user's data
$_SESSION = array();

$this->destroyCurrentSession(new SessionFingerprint());
}
Expand Down
3 changes: 1 addition & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# LoginLdap Changelog

#### LoginLdap 5.2.7- 2026-09-14
#### LoginLdap 5.2.7 - 2026-09-14
- Fixed the LDAP instance identifier comparison, so an access value naming a different Matomo instance is no longer applied to this one
- Fixed the login comparison so that logins the database collation treats as equal, but which are different users, are no longer accepted
- Added an exact login match requirement when authenticating through the web server
- Added termination of a Matomo session when the web server starts authenticating a different user

#### LoginLdap 5.2.6 - 2026-08-24
Expand Down
9 changes: 4 additions & 5 deletions LoginLdap.php
Original file line number Diff line number Diff line change
Expand Up @@ -275,11 +275,10 @@ public function onApiRequestDispatch(&$parameters, $pluginName, $methodName)
return;
}

// Only a web-server-authenticated request bypasses the password. When REMOTE_USER is
// absent, WebServerAuth delegates to its password-validating fallback, so token
// creation stays safe and must remain allowed.
$auth = StaticContainer::get('Piwik\Auth');
if ($auth instanceof WebServerAuth && !empty($_SERVER['REMOTE_USER'])) {
// Only a web-server-authenticated request bypasses the password. When the web server authenticated
// nobody, WebServerAuth delegates to its password-validating fallback, so token creation stays safe
// and must remain allowed.
if (WebServerAuth::isCurrentRequestWebServerAuthenticated()) {
throw new Exception(Piwik::translate('LoginLdap_CreateAppSpecificTokenAuthBlocked'));
}
}
Expand Down
15 changes: 0 additions & 15 deletions UserIdentity.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,6 @@ public static function isSameLogin(string $assertedLogin, string $storedLogin):
return self::asciiLower($assertedLogin) === self::asciiLower($storedLogin);
}

/**
* Returns true if the asserted login and the stored login are byte for byte identical.
*
* Used where the login is the only thing binding a request to an account, so that not even ASCII case
* differences are tolerated.
*
* @param string $assertedLogin The login the web server asserted.
* @param string $storedLogin The login of the user row, or of the session, it was matched against.
* @return bool
*/
public static function isSameLoginExact(string $assertedLogin, string $storedLogin): bool
{
return $assertedLogin === $storedLogin;
}

/**
* Lowercases the ASCII letters in $value and leaves every other byte untouched.
*
Expand Down
30 changes: 29 additions & 1 deletion tests/Integration/WebServerAuthTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,39 @@ public function test_WebServerAuth_Fails_IfAssertedLoginResolvesToDifferentExist
public function getLoginsResolvingToTheSuperUser()
{
return array(
'ascii case difference' => array(ucfirst(self::TEST_SUPERUSER_LOGIN)),
'accented character' => array(substr(self::TEST_SUPERUSER_LOGIN, 0, -1) . "\xc3\xa1"),
);
}

public function test_WebServerAuth_Works_IfAssertedLoginDiffersFromTheStoredOneOnlyByAsciiCase()
{
Config::getInstance()->LoginLdap['use_webserver_auth'] = 1;

$_SERVER['REMOTE_USER'] = strtoupper(self::TEST_SUPERUSER_LOGIN);

$ldapAuth = WebServerAuth::makeConfigured();
$authResult = $ldapAuth->authenticate();

$this->assertEquals(AuthResult::SUCCESS_SUPERUSER_AUTH_CODE, $authResult->getCode());
$this->assertEquals(self::TEST_SUPERUSER_LOGIN, $authResult->getIdentity());
}

public function test_WebServerAuth_Works_OnEveryRequest_IfTheUserIsProvisionedFromADifferentlyCasedLogin()
{
Config::getInstance()->LoginLdap['use_webserver_auth'] = 1;

// the Matomo user does not exist yet, so it is created from the LDAP entry's own casing. Every later
// request asserts the login the web server has, which must keep resolving to that user.
$_SERVER['REMOTE_USER'] = strtoupper(self::TEST_LOGIN);

foreach (array('first', 'second') as $request) {
$authResult = WebServerAuth::makeConfigured()->authenticate();

$this->assertEquals(AuthResult::SUCCESS, $authResult->getCode(), "failed on the {$request} request");
$this->assertEquals(self::TEST_LOGIN, $authResult->getIdentity());
}
}

public function test_WebServerAuth_Fails_IfUserIsNotPartOfRequiredGroup()
{
Config::getInstance()->LoginLdap['use_webserver_auth'] = 1;
Expand Down
118 changes: 118 additions & 0 deletions tests/Unit/WebServerAuthAssertionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

/**
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*
*/

namespace Piwik\Plugins\LoginLdap\tests\Unit;

use PHPUnit\Framework\TestCase;
use Piwik\Config;
use Piwik\Container\StaticContainer;
use Piwik\Log\LoggerInterface;
use Piwik\Plugins\LoginLdap\Auth\WebServerAuth;

/**
* @group LoginLdap
* @group LoginLdap_Unit
* @group LoginLdap_WebServerAuthAssertionTest
*/
class WebServerAuthAssertionTest extends TestCase
{
/**
* @var array
*/
private $configBackup;

/**
* @var mixed
*/
private $authBackup;

public function setUp(): void
{
parent::setUp();

$this->configBackup = Config::getInstance()->LoginLdap;
$this->authBackup = StaticContainer::get('Piwik\Auth');

Config::getInstance()->LoginLdap = array(
'use_webserver_auth' => 1,
'strip_domain_from_web_auth' => 1,
);

StaticContainer::getContainer()->set('Piwik\Auth', new WebServerAuth($this->createMock(LoggerInterface::class)));
}

public function tearDown(): void
{
unset($_SERVER['REMOTE_USER']);
StaticContainer::getContainer()->set('Piwik\Auth', $this->authBackup);
Config::getInstance()->LoginLdap = $this->configBackup;

parent::tearDown();
}

/**
* @dataProvider getAssertionsNamingNobody
*/
public function test_getAssertedLogin_ReturnsNull_IfTheAssertionNamesNobody($remoteUser)
{
$_SERVER['REMOTE_USER'] = $remoteUser;

$this->assertNull(WebServerAuth::getAssertedLogin());
}

/**
* Callers skip Matomo's password confirmation on the strength of this, so an assertion that authenticates
* nobody must not report the request as web server authenticated.
*
* @dataProvider getAssertionsNamingNobody
*/
public function test_isCurrentRequestWebServerAuthenticated_IsFalse_IfTheAssertionNamesNobody($remoteUser)
{
$_SERVER['REMOTE_USER'] = $remoteUser;

$this->assertFalse(WebServerAuth::isCurrentRequestWebServerAuthenticated());
}

public function getAssertionsNamingNobody()
{
return array(
'absent' => array(null),
'empty' => array(''),
'whitespace only' => array(' '),
'domain only' => array('SHIELD\\'),
'at sign only' => array('@shield.org'),
);
}

/**
* @dataProvider getAssertionsNamingSomebody
*/
public function test_getAssertedLogin_ReturnsTheLoginUntrimmed($expected, $remoteUser)
{
$_SERVER['REMOTE_USER'] = $remoteUser;

$this->assertSame($expected, WebServerAuth::getAssertedLogin());
$this->assertTrue(WebServerAuth::isCurrentRequestWebServerAuthenticated());
}

public function getAssertionsNamingSomebody()
{
return array(
'plain' => array('ironman', 'ironman'),
'domain stripped' => array('ironman', 'SHIELD\\ironman'),
'suffix stripped' => array('ironman', 'ironman@shield.org'),

// surrounding whitespace is part of the asserted identity, not noise to be cleaned up: the login
// column's collation returns 'ironman' for 'ironman ', and trimming would authenticate that user
'trailing space kept' => array('ironman ', 'ironman '),
'leading space kept' => array(' ironman', ' ironman'),
);
}
}
Loading
Loading