Skip to content

Commit dc8ffc7

Browse files
authored
force admin to make a choice about inventory auth (#23571)
1 parent e3bb9b6 commit dc8ffc7

5 files changed

Lines changed: 208 additions & 61 deletions

File tree

src/Glpi/Agent/Communication/AbstractRequest.php

Lines changed: 49 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
use League\OAuth2\Server\Exception\OAuthServerException;
5050
use RuntimeException;
5151
use Safe\Exceptions\SimplexmlException;
52+
use Safe\Exceptions\UrlException;
5253
use Toolbox;
5354
use UnexpectedValueException;
5455

@@ -228,55 +229,62 @@ public function handleRequest(mixed $data): bool
228229
$guess_mode = ($base_mode === null);
229230
$this->setMode(self::JSON_MODE);
230231

231-
$auth_required = false;
232232
if (!$this->isLocal()) {
233233
$auth_required = Config::getConfigurationValue('inventory', 'auth_required');
234-
}
235-
if ($auth_required === Conf::CLIENT_CREDENTIALS) {
236-
$request = new Request('POST', $_SERVER['REQUEST_URI'], $this->headers->getHeaders());
237-
try {
238-
$client = Server::validateAccessToken($request);
239-
if (!in_array('inventory', $client['scopes'], true)) {
240-
$this->addError('Access denied. Agent must authenticate using client credentials and have the "inventory" OAuth scope', 401);
241-
return false;
242-
}
243-
} catch (OAuth2KeyException $e) {
244-
ErrorHandler::logCaughtException($e);
245-
$this->addError($e->getMessage());
246-
return false;
247-
} catch (OAuthServerException) {
248-
$this->addError('Authorization header required to send an inventory', 401);
249-
return false;
250-
}
251-
}
252234

253-
if ($auth_required === Conf::BASIC_AUTH) {
254-
$authorization_header = $this->headers->getHeader('Authorization');
255-
if (is_null($authorization_header)) {
256-
$this->headers->setHeader("www-authenticate", 'Basic realm="basic"');
257-
$this->addError('Authorization header required to send an inventory', 401);
258-
return false;
259-
} else {
260-
$allowed = false;
261-
// if Authorization start with 'Basic'
262-
if (preg_match('/^Basic\s+(.*)$/i', $authorization_header, $matches)) {
263-
$inventory_login = Config::getConfigurationValue('inventory', 'basic_auth_login');
264-
$inventory_password = (new GLPIKey())
265-
->decrypt(Config::getConfigurationValue('inventory', 'basic_auth_password'));
266-
$agent_credential = base64_decode($matches[1]);
267-
[$agent_login, $agent_password] = explode(':', $agent_credential, 2);
268-
if (
269-
$inventory_login == $agent_login
270-
&& $inventory_password == $agent_password
271-
) {
272-
$allowed = true;
235+
if ($auth_required === Conf::CLIENT_CREDENTIALS) {
236+
$request = new Request('POST', $_SERVER['REQUEST_URI'], $this->headers->getHeaders());
237+
try {
238+
$client = Server::validateAccessToken($request);
239+
if (!in_array('inventory', $client['scopes'], true)) {
240+
$this->addError('Access denied. Agent must authenticate using client credentials and have the "inventory" OAuth scope', 401);
241+
return false;
273242
}
243+
} catch (OAuth2KeyException $e) {
244+
ErrorHandler::logCaughtException($e);
245+
$this->addError($e->getMessage());
246+
return false;
247+
} catch (OAuthServerException) {
248+
$this->addError('Authorization header required to send an inventory', 401);
249+
return false;
274250
}
275-
if (!$allowed) {
251+
} elseif ($auth_required === Conf::BASIC_AUTH) {
252+
$authorization_header = $this->headers->getHeader('Authorization');
253+
if (is_null($authorization_header)) {
276254
$this->headers->setHeader("www-authenticate", 'Basic realm="basic"');
277-
$this->addError('Access denied. Wrong login or password for basic authentication.', 401);
255+
$this->addError('Authorization header required to send an inventory', 401);
278256
return false;
257+
} else {
258+
$allowed = false;
259+
// if Authorization start with 'Basic'
260+
if (preg_match('/^Basic\s+(.*)$/i', $authorization_header, $matches)) {
261+
$inventory_login = Config::getConfigurationValue('inventory', 'basic_auth_login');
262+
$inventory_password = (new GLPIKey())
263+
->decrypt(Config::getConfigurationValue('inventory', 'basic_auth_password'));
264+
try {
265+
$agent_credential = base64_decode($matches[1], true);
266+
if (str_contains($agent_credential, ':')) {
267+
[$agent_login, $agent_password] = explode(':', $agent_credential, 2);
268+
if (
269+
$inventory_login == $agent_login
270+
&& $inventory_password == $agent_password
271+
) {
272+
$allowed = true;
273+
}
274+
}
275+
} catch (UrlException) {
276+
// malformed base64 — leave $allowed as false
277+
}
278+
}
279+
if (!$allowed) {
280+
$this->headers->setHeader("www-authenticate", 'Basic realm="basic"');
281+
$this->addError('Access denied. Wrong login or password for basic authentication.', 401);
282+
return false;
283+
}
279284
}
285+
} elseif ($auth_required !== Conf::NO_AUTH) {
286+
$this->addError('Server configuration error: invalid inventory authentication setting. Please configure the Inventory → Authorization header in GLPI.', 503);
287+
return false;
280288
}
281289
}
282290

src/Glpi/Inventory/Conf.php

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
use Monitor;
7373
use NetworkPort;
7474
use NetworkPortType;
75+
use OAuthClient;
7576
use Peripheral;
7677
use Plugin;
7778
use Printer;
@@ -140,6 +141,8 @@ class Conf extends CommonGLPI
140141

141142
public const STALE_AGENT_ACTION_TRASHBIN = 2;
142143

144+
public const NO_AUTH = 'none';
145+
143146
public const CLIENT_CREDENTIALS = 'client_credentials';
144147

145148
public const BASIC_AUTH = 'basic_auth';
@@ -396,18 +399,50 @@ public function showConfigForm()
396399
echo "</tr>";
397400
echo "<tr class='tab_bg_1'>";
398401
echo "<td>";
399-
echo "<i class='ti ti-cloud-lock me-2'></i>";
400-
echo "<label for='auth'>" . __s('Authorization header') . "</label>";
402+
$auth_rand = mt_rand();
403+
echo "<i class='ti ti-shield me-2'></i>";
404+
echo "<label for='dropdown_auth_required{$auth_rand}'>" . __s('Authorization header') . "</label>";
405+
echo "<span class='required'>*</span>";
401406
echo "</td>";
402-
echo "<td>";
407+
echo "<td colspan='3'>";
403408
Dropdown::showFromArray('auth_required', [
404-
'none' => __('None'),
409+
'' => Dropdown::EMPTY_VALUE,
405410
self::CLIENT_CREDENTIALS => __s('OAuth - Client credentials'),
406411
self::BASIC_AUTH => __s('Basic Authentication'),
412+
self::NO_AUTH => __('None (not recommended)'),
407413
], [
408-
'value' => $config['auth_required'] ?? 'none',
414+
'value' => $config['auth_required'] ?? '',
415+
'required' => true,
416+
'rand' => $auth_rand,
409417
]);
418+
echo "<div id='oauth_client_hint_row' class='mt-1'>";
419+
echo "<div class='alert alert-info d-inline-flex align-items-center mb-0 py-2' role='note'>";
420+
echo "<i class='ti ti-info-circle flex-shrink-0 me-1'></i>";
421+
echo "<span>";
422+
echo __s('Using OAuth Client Credentials requires a registered OAuth client with the "inventory" scope.');
423+
echo '<br>';
424+
echo sprintf(
425+
__s('The generated client ID and secret must then be set in the GLPI Agent configuration using the %s and %s parameters.'),
426+
'<code>oauth-client-id</code>',
427+
'<code>oauth-client-secret</code>'
428+
);
429+
echo ' <a href="' . htmlescape(OAuthClient::getFormURL()) . '">';
430+
echo __s('Add an OAuth client');
431+
echo ' <i class="ti ti-external-link ms-1"></i>';
432+
echo '</a>';
433+
echo "</span>";
434+
echo "</div>";
435+
echo "</div>";
436+
echo "<div id='no_auth_warning_row' class='mt-1'>";
437+
echo "<div class='alert alert-warning d-inline-flex align-items-center mb-0 py-2' role='alert'>";
438+
echo "<i class='ti ti-alert-triangle flex-shrink-0 me-1'></i>";
439+
echo "<span>";
440+
echo __s('Not using any authentication on inventory is not recommended and poses a security risk. Any agent will be able to send inventory data without verification.');
441+
echo "</span>";
442+
echo "</div>";
443+
echo "</div>";
410444
echo "</td></tr>";
445+
411446
echo "<tr class='tab_bg_1' id='basic_auth_login_row'>";
412447
echo "<td>";
413448
echo "<i class='ti ti-abc me-2'></i>";
@@ -437,13 +472,14 @@ public function showConfigForm()
437472
echo "</tr>";
438473
echo Html::scriptBlock("
439474
function toggleDisplayLoginInputs(select) {
440-
let displayedInputs = false;
441475
const selectedValue = $(select).val();
442-
if (selectedValue == '" . self::BASIC_AUTH . "') {
443-
displayedInputs = true;
444-
}
445-
$('#basic_auth_login_row').toggle(displayedInputs);
446-
$('#basic_auth_password_row').toggle(displayedInputs);
476+
const isBasicAuth = selectedValue == '" . self::BASIC_AUTH . "';
477+
const isOAuth = selectedValue == '" . self::CLIENT_CREDENTIALS . "';
478+
const isNoAuth = selectedValue == '" . self::NO_AUTH . "';
479+
$('#basic_auth_login_row').toggle(isBasicAuth);
480+
$('#basic_auth_password_row').toggle(isBasicAuth);
481+
$('#oauth_client_hint_row').toggle(isOAuth);
482+
$('#no_auth_warning_row').toggle(isNoAuth);
447483
}
448484
449485
const selectAuthHeader = $(`select[name='auth_required']`);
@@ -1146,6 +1182,24 @@ public function saveConf(array $values)
11461182
$values['stale_agents_status_condition'] = ['all'];
11471183
}
11481184

1185+
$enabled_inventory = (int) ($values['enabled_inventory'] ?? $defaults['enabled_inventory']) === 1;
1186+
if ($enabled_inventory) {
1187+
$allowed_auth_required = [
1188+
self::CLIENT_CREDENTIALS,
1189+
self::BASIC_AUTH,
1190+
self::NO_AUTH,
1191+
];
1192+
$auth_required = $values['auth_required'] ?? null;
1193+
if (!is_string($auth_required) || !in_array($auth_required, $allowed_auth_required, true)) {
1194+
Session::addMessageAfterRedirect(
1195+
__s('Inventory is enabled. Please select a valid authorization header method.'),
1196+
false,
1197+
ERROR
1198+
);
1199+
return false;
1200+
}
1201+
}
1202+
11491203
if (isset($values['auth_required']) && $values['auth_required'] === Conf::BASIC_AUTH) {
11501204
if (
11511205
!empty($values['basic_auth_password'])
@@ -1306,7 +1360,7 @@ public static function getDefaults(): array
13061360
'stale_agents_status' => 0,
13071361
'stale_agents_status_condition' => exportArrayToDB(['all']),
13081362
'import_env' => 0,
1309-
'auth_required' => 'none',
1363+
'auth_required' => '',
13101364
'basic_auth_login' => '',
13111365
'basic_auth_password' => '',
13121366
];

tests/functional/Glpi/Inventory/ConfTest.php

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,13 @@
3434

3535
namespace tests\units\Glpi\Inventory;
3636

37+
use Config;
3738
use Glpi\Inventory\Conf;
38-
use Glpi\Tests\GLPITestCase;
39+
use Glpi\Tests\DbTestCase;
3940
use PHPUnit\Framework\Attributes\DataProvider;
4041
use Psr\Log\LogLevel;
4142

42-
class ConfTest extends GLPITestCase
43+
class ConfTest extends DbTestCase
4344
{
4445
public function testKnownInventoryExtensions()
4546
{
@@ -84,6 +85,7 @@ public static function confProvider(): array
8485
{
8586
$provider = [];
8687
$defaults = Conf::getDefaults();
88+
$defaults['auth_required'] = Conf::NO_AUTH;
8789
foreach ($defaults as $key => $value) {
8890
$provider[] = [
8991
'key' => $key,
@@ -107,4 +109,34 @@ public function testErrorGetter()
107109
$this->hasPhpLogRecordThatContains('Property doesNotExists does not exists!', LogLevel::WARNING);
108110
$this->hasSessionMessages(WARNING, ['Property doesNotExists does not exists!']);
109111
}
112+
113+
public static function invalidAuthRequiredProvider(): iterable
114+
{
115+
yield 'empty string' => [''];
116+
yield 'null value' => [null];
117+
yield 'unexpected value' => ['unexpected_value'];
118+
}
119+
120+
#[DataProvider('invalidAuthRequiredProvider')]
121+
public function testSaveConfRejectsInvalidAuthRequiredWhenInventoryIsEnabled(mixed $auth_required): void
122+
{
123+
$this->login();
124+
125+
Config::setConfigurationValues('inventory', [
126+
'enabled_inventory' => 1,
127+
'auth_required' => Conf::NO_AUTH,
128+
]);
129+
130+
$conf = new Conf();
131+
$result = $conf->saveConf([
132+
'enabled_inventory' => 1,
133+
'auth_required' => $auth_required,
134+
]);
135+
136+
$this->assertFalse($result);
137+
$this->hasSessionMessages(ERROR, [
138+
'Inventory is enabled. Please select a valid authorization header method.',
139+
]);
140+
$this->assertSame(Conf::NO_AUTH, Config::getConfigurationValue('inventory', 'auth_required'));
141+
}
110142
}

tests/src/autoload/functions.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
use Glpi\Form\Section;
4141
use Glpi\Helpdesk\HelpdeskTranslation;
4242
use Glpi\Helpdesk\Tile\GlpiPageTile;
43+
use Glpi\Inventory\Conf;
4344
use Glpi\Socket;
4445

4546
function loadDataset()
@@ -953,6 +954,8 @@ function loadDataset()
953954
Search::$search = [];
954955
Config::setConfigurationValues('phpunit', ['dataset' => $data['_version']]);
955956
}
957+
958+
Config::setConfigurationValues('inventory', ['auth_required' => Conf::NO_AUTH]);
956959
$DB->commit();
957960

958961
$_SESSION = $session_bak; // Unset force session variables

0 commit comments

Comments
 (0)