Files
cacti/user_log.php
TheWitness a52368125e feat(auth): rename User Domains to Login Providers, add SAML2 and OpenID Connect (#8066)
* feat(auth): rename User Domains to Login Providers

Collapses user_domains/user_domains_ldap into a single login_providers table with a JSON parameters column, migrated in place during the 1.3.0 upgrade. Renames the admin page, menu, breadcrumbs, and help links to match.

* feat(auth): rework login providers as Cacti\Auth classes

Introduces LoginProviderInterface with credential (LDAP/AD) and redirect (SAML2/OpenID) flavors, replacing the procedural domains_login_process()/domains_ldap_*() functions. Adds blank-field group membership gating shared by every provider type.

* feat(auth): add SAML2 and OpenID Connect login providers

Adds onelogin/php-saml and firebase/php-jwt, a new cacti_http() SSRF-hardened outbound request helper for every OpenID discovery/token/JWKS call, and login_sso.php to handle the redirect-based SSO flow. Login page now shows a button per enabled SAML2/OpenID provider, and per-provider auth cookie opt-out is enforced on both the password and SSO login paths.

* chore(i18n): regenerate cacti.pot

* feat(auth): add Local and Basic auth as Cacti\Auth providers

LocalAuthLoginProvider (CredentialLoginProviderInterface) and BasicAuthLoginProvider (new PreAuthenticatedLoginProviderInterface, since Basic Auth has no password for Cacti to check) are thin adapters over the existing local_auth_login_process()/basic_auth_login_process() functions, which are left untouched to avoid disturbing their regression-tested lockout/timing/rehash behavior.

* feat(auth): move local password verification into LocalAuthLoginProvider

secpass_login_process() and local_auth_login_process() are retired; their lockout, constant-time verification, forced-password-change, and rehash-on-login logic now live end to end in the class (verifyCredential/enforcePasswordPolicy/rehashIfNeeded). secpass_check_pass() stays a shared lib/auth.php function since password change/reset/user_admin also call it outside any login flow. Local auth still has no login_providers row: isEnabled() is always true and it cannot be disabled or deleted.

* feat(auth): move Login Provider CRUD into Cacti\Auth classes

AbstractLoginProvider gains static deleteById()/enableById()/disableById()/makeDefaultById()/persist(); each concrete provider owns its own collectParameters(), dispatched via LoginProviderFactory. login_providers.php's form_save()/form_actions() call these instead of doing raw SQL, and the retired provider_remove()/provider_disable()/provider_enable()/provider_default()/provider_collect_parameters() procedural functions are gone. Local/Basic auth are unaffected: they never extend AbstractLoginProvider, so they structurally cannot expose delete/disable.

* fix(auth): address Copilot review findings on SSO/SAML/OIDC security

Fixes callback/ACS/SLS/metadata URLs that used the raw provider id instead of the login realm (broke every SSO login), adds InResponseTo correlation for SAML and sub/azp validation for OIDC, requires HTTPS for OIDC discovery/endpoints, pins cacti_http() connections against DNS rebinding via CURLOPT_RESOLVE, exempts login_sso.php from the authenticated-page redirect guard and gates it on auth_method, wraps provider dispatch in a exception boundary, adds a dedicated SAML SLO path instead of routing it through login completion, makes remember-me an explicit opt-in for SSO, fixes disabled/deleted providers not revoking existing auth cookies, restricts the default-realm action to LDAP/AD, and fixes the 1.3.0 migration silently enabling group enforcement for installs that had it off. Also updates the E2E seed script for the login_providers schema.

* chore(auth): refresh CI security baselines and document no-op validation

Regenerate the sink inventory baseline for the new/renamed lib/Auth/* and login_providers.php files, and add '// no-validation:' comments on the intentionally-unconstrained admin/free-text/secret fields (LDAP DNs/filters, SAML certs/keys, OIDC client credentials, provider display fields) flagged by the validator constraint ratchet.

Refreshes the validator baseline to drop the deleted user_domains.php entries.

* refactor(ldap): remove dead DB-lookup path from Ldap::__construct

The domain_id>0 branch duplicated the JSON-parameter-to-property mapping already owned by LdapLoginProvider::buildLdap(), and nothing constructs Ldap with a real id anymore (all call sites pass 0). Drop the parameter and the branch so Ldap stays a pure connection/protocol class with no DB coupling.

Also fixes a latent bug: group_require is a typed bool property, but LdapLoginProvider::authenticate() assigned it an int (1/0) from a strict_types=1 file, which throws a TypeError on every LDAP/AD login attempt. Assign a real bool instead.

* fix(auth): fix LDAP login, upgrade ordering, and hardening guard failures

LdapLoginProvider compared LdapError::GetErrorDetails()'s int error_num against the string '0' with strict equality, so it never matched and every LDAP/AD login was rejected regardless of valid credentials. Compare against int 0 instead.

install/upgrades/1_3_0.php called login_providers_convert_1_3_0() (which reads and then drops user_domains/user_domains_ldap) before the pre-existing legacy ALTER TABLE statements that add the debug/network_timeout/bind_timeout columns those reads depend on, so upgrades failed once the tables were already gone. Moved the call to run after those ALTERs.

OpenIdLoginProvider::complete() read state/code from raw \ instead of gnrv(), tripping the hardening pattern guard; switched to the request helper.

* style(auth): apply php-cs-fixer formatting and normalize line endings to LF

Runs php-cs-fixer across the new lib/Auth/* classes, login_providers.php, login_sso.php, and the other touched files, fixing indentation/alignment/docblock style to match project convention. Also normalizes several files that had picked up CRLF line endings back to the LF the project's .php-cs-fixer.php config (setLineEnding) and the rest of the tracked tree actually use.

* fix(auth): address second Copilot review round (9 high/medium findings)

- Make login_providers_convert_1_3_0() resumable: CREATE TABLE IF NOT EXISTS and INSERT ... ON DUPLICATE KEY UPDATE so an interrupted upgrade can be re-run instead of leaving a half-migrated, unrecoverable table.

- SamlLoginProvider::complete() now fails closed when no saved AuthnRequest ID exists instead of passing null to processResponse(), which skipped InResponseTo validation and accepted unsolicited IdP-initiated assertions.

- LoginResult gains an isCredentialFailure flag; only a genuine wrong-password LDAP bind (LdapError::Failure) counts toward the lockout counter now, not connection errors or group-membership denials.

- Fixed button_label/debug being read from the JSON parameters column in OpenIdLoginProvider/SamlLoginProvider/LdapLoginProvider when they are actually top-level login_providers columns; AbstractLoginProvider now hydrates and exposes both.

- Removed SamlLoginProvider's wantMessagesSigned, which silently required the outer SAML message (not just the assertion) to be signed, rejecting IdPs that only sign the assertion as the UI promises.

- Added getTemplateUserId() to CredentialLoginProviderInterface and implemented it on LocalAuthLoginProvider so the contract (not just incidental AbstractLoginProvider inheritance) guarantees the method lib/auth.php calls.

- cacti_http()'s CURLOPT_RESOLVE entries now bracket IPv6 literals correctly, and safe-IP validation strips parse_url()'s brackets before checking, fixing IPv6 target pinning.

- login_providers.php's type dropdown now swaps the Full Name attribute default (cn/displayName) on an actual user-driven type change, not on page load, so a new Active Directory provider does not silently save the LDAP default.

- Extracted auth_apply_group_login_opts() and call it from both auth_login.php and login_sso.php, so SSO logins honor a user's group-derived login_opts the same way password logins already do.

* fix(auth): fix PHPStan level 8 errors and pest harness stub drift

Test harness fix: the LoginProvidersLoginRealmValidationTest stub LoginResult class was missing the isCredentialFailure property added to the real class, causing 'Undefined property' warnings that broke the harness's JSON output. Added the property and set it on the simulated bad-password failure.

PHPStan (48+ errors, all newly introduced by this branch's rewrite, never actually verified until now): narrowed db_fetch_row_prepared()/db_fetch_assoc()'s array|bool (including bare true, which survives a '?: []' short-circuit - fixed with explicit is_array() checks instead) at every call site across auth_login.php, login_sso.php, lib/auth.php, lib/Auth/LocalAuthLoginProvider.php, lib/Auth/LoginProviderFactory.php, and install/upgrades/1_3_0.php.

Fixed OpenIdLoginProvider::discover()'s return shape to match its declared docblock instead of returning the raw decoded JSON. Fixed lib/functions.php's cacti_http() to narrow curl_exec()'s string|bool result with is_string() (not ===false, which still leaves 'true' as a residual type) and guard / as non-empty for curl_setopt_array()'s stubs.

Fixed a real bug in SamlLoginProvider::processLogout(): processSLO(true) was passing 'true' as \ (the first parameter) instead of \ (the fifth), so it always redirected/exited internally with the default \=false, making Cacti's own logout.php never regain control. Now calls processSLO(keepLocalSession: true, stay: true) and is typed ': never' to match; login_sso.php's now-genuinely-unreachable break statements after processLogout()/initiate() are removed.

Fixed login_providers.php passing a expression (not a variable) to inject_form_variables()'s by-reference parameter.

Verified: PHPStan analyse clean (0 errors), full Pest Unit/Security suite passes (128 tests), hardening guard/sink inventory/validator ratchet all still clean.

* fix(auth): fix ambiguous column and array+bool crash in user_log filter

The user filter dropdown query selected a bare 'id' column after joining user_auth and login_providers, which is ambiguous now that login_providers also has an id column (unlike the old user_domains.domain_id) - MySQL rejected the query outright. Qualified as ua.id.

Also narrow db_fetch_assoc()'s array|bool result before the \ + \ + \ merge, which was throwing a TypeError whenever the query failed instead of surfacing the real SQL error.

* fix(auth): address third Copilot review round (5 findings + tests)

- AbstractLoginProvider::persist() now disables the template account for every provider type; SAML2/OpenID also copy it via user_copy() in login_sso_complete() and were left directly usable as a local login.

- cacti_http_resolve_safe_ips() now detects IPv4-mapped IPv6 literals (::ffff:0:0/96) via inet_pton() and validates the embedded IPv4 address, closing an SSRF bypass via e.g. ::ffff:169.254.169.254.

- login_providers_convert_1_3_0() now tracks db_install_execute()'s status for every row and only drops the legacy user_domains/user_domains_ldap tables once every row (and the final row count) is confirmed migrated.

- Ldap::Getcn() now looks up ldap_get_entries()'s always-lowercased attribute keys case-insensitively while still storing the result under the originally-requested key, fixing AD's displayName (and any other mixed-case configured attribute) always resolving to a blank full name.

- SamlLoginProvider::processLogout() now redirects to the LogoutResponse URL processSLO() returns for a LogoutRequest (stay:true), instead of discarding it and always redirecting to Cacti's own logout page - federated logout was never actually acknowledged to the IdP.

- Added tests/Unit/Security/Auth/OpenIdLoginProviderTest.php (13 tests) covering verifyIdToken() signature/issuer/audience/azp/nonce validation, discover()'s HTTPS enforcement, and complete()'s callback-state and UserInfo-subject-matching checks, against a locally-generated RSA keypair/JWKS.

- Regenerated the sink inventory baseline for the new header_redirect call in SamlLoginProvider::processLogout().

Verified: PHPStan clean, full Pest Security/Auth suite passes (830 tests including the 13 new ones), php-cs-fixer/hardening-guard/sink-inventory/validator-ratchet all clean.
2026-09-24 17:51:18 -04:00

355 lines
10 KiB
PHP

<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
| This code is designed, written, and maintained by the Cacti Group. See |
| about.php and/or the AUTHORS file for specific developer information. |
+-------------------------------------------------------------------------+
| http://www.cacti.net/ |
+-------------------------------------------------------------------------+
*/
require('./include/auth.php');
// set default action
set_default_action();
switch (grv('action')) {
case 'purge_execute':
clear_user_log();
raise_message('purge_user_log', __('User Log Purged.'), MESSAGE_LEVEL_INFO);
header('location: user_log.php');
break;
case 'purge':
top_header();
purge_user_log();
bottom_footer();
break;
default:
top_header();
view_user_log();
bottom_footer();
break;
}
function view_user_log() : void {
global $auth_realms, $item_rows;
draw_user_log_filter(true);
if (grv('rows') == '-1') {
$rows = read_config_option('num_rows_table');
} else {
$rows = grv('rows');
}
$sql_where = '';
$sql_params = [];
// filter by username
if (grv('user_id') == '-2') {
$sql_where = 'WHERE ul.user_id NOT IN (SELECT DISTINCT id FROM user_auth)';
} elseif (grv('user_id') != '-1') {
$sql_where = 'WHERE ul.user_id = ?';
$sql_params[] = grv('user_id');
}
// filter by result
if (grv('result') != '-1') {
$sql_where .= ($sql_where != '' ? ' AND ' : 'WHERE ') . ' ul.result = ?';
$sql_params[] = grv('result');
}
// filter by search string
if (grv('filter') != '') {
$sql_where .= ($sql_where != '' ? ' AND ' : 'WHERE ') . ' (
ul.username LIKE ? OR ul.time LIKE ? OR ua.full_name LIKE ? OR ul.ip LIKE ?)';
$sql_params[] = '%' . grv('filter') . '%';
$sql_params[] = '%' . grv('filter') . '%';
$sql_params[] = '%' . grv('filter') . '%';
$sql_params[] = '%' . grv('filter') . '%';
}
$total_rows = db_fetch_cell_prepared("SELECT COUNT(*)
FROM user_auth AS ua
RIGHT JOIN user_log AS ul
ON ua.username = ul.username
$sql_where",
$sql_params);
$user_log_sql = "SELECT ul.username, ua.full_name, ua.realm,
ul.time, ul.result, ul.ip
FROM user_auth AS ua
RIGHT JOIN user_log AS ul
ON ua.username = ul.username
AND ua.id = ul.user_id
$sql_where
ORDER BY " . sanitize_sql_column(grv('sort_column'), 'time') . ' ' . (strtoupper(grv('sort_direction')) === 'DESC' ? 'DESC' : 'ASC') . '
LIMIT ' . ($rows * (grv('page') - 1)) . ',' . $rows;
$user_log = db_fetch_assoc_prepared($user_log_sql, $sql_params);
$display_text = [
'username' => [__('User'), 'ASC'],
'full_name' => [__('Full Name'), 'ASC'],
'realm' => [__('Authentication Realm'), 'ASC'],
'time' => [__('Date'), 'DESC'],
'result' => [__('Result'), 'DESC'],
'ip' => [__('IP Address'), 'DESC']
];
$nav = html_nav_bar('user_log.php?user_id=' . grv('user_id') . '&filter=' . grv('filter'), MAX_DISPLAY_PAGES, grv('page'), $rows, $total_rows, 6, __('Login Attempts'), 'page', 'main');
print $nav;
html_start_box('', '100%', false, 3, 'center', '');
html_header_sort($display_text, grv('sort_column'), grv('sort_direction'), 1, 'user_log.php');
$i = 0;
if (cacti_sizeof($user_log)) {
foreach ($user_log as $item) {
form_alternate_row('line' . $i, true);
form_selectable_cell(filter_value($item['username'], grv('filter')), $i, '', 'nowrap');
if (isset($item['full_name'])) {
form_selectable_cell(filter_value($item['full_name'], grv('filter')), $i);
} else {
form_selectable_cell(__('(User Removed)'), $i);
}
if (isset($auth_realms[$item['realm']]['name'])) {
form_selectable_cell(filter_value($auth_realms[$item['realm']]['name'], grv('filter')), $i);
} else {
form_selectable_cell(__('N/A'), $i);
}
form_selectable_cell(filter_value($item['time'], grv('filter')), $i);
form_selectable_cell(($item['result'] == 0 ? __('Failed') : ($item['result'] == 1 ? __('Success - Password') : ($item['result'] == 3 ? __('Success - Password Change') : __('Success - Token')))), $i);
form_selectable_cell(filter_value($item['ip'], grv('filter')), $i);
$i++;
}
}
html_end_box();
if (cacti_sizeof($user_log)) {
print $nav;
}
}
function clear_user_log() : void {
$users = db_fetch_assoc('SELECT DISTINCT id, username FROM user_auth');
if (cacti_sizeof($users)) {
// remove active users
foreach ($users as $user) {
// Check how many rows for the current user with a valid token
foreach ([1, 2] as $result) {
$total_rows = db_fetch_cell_prepared('SELECT COUNT(username)
FROM user_log
WHERE username = ?
AND user_id = ?
AND result = ?',
[$user['username'], $user['id'], $result]);
if ($total_rows > 1) {
db_execute_prepared('DELETE
FROM user_log
WHERE username = ?
AND user_id = ?
AND result = ?
ORDER BY time LIMIT ' . ($total_rows - 1),
[$user['username'], $user['id'], $result]);
}
}
db_execute_prepared('DELETE
FROM user_log
WHERE username = ?
AND user_id = ?
AND result = 0',
[$user['username'], $user['id']]);
}
// delete inactive users
db_execute('DELETE
FROM user_log
WHERE user_id NOT IN (SELECT id FROM user_auth)
OR username NOT IN (SELECT username FROM user_auth)');
}
}
function purge_user_log() : void {
form_start('user_log.php');
html_start_box(__('Purge User Log'), '60%', false, 3, 'center', '');
print "<tr>
<td class='textArea'>
<p>" . __('Click \'Continue\' to purge the User Log.<br><br><br>Note: If logging is set to both Cacti and Syslog, the log information will remain in Syslog.') . "</p>
</td>
</tr>
<tr class='saveRow'>
<td colspan='2' class='right'>
<button type='button' class='ui-button ui-corner-all ui-widget' id='cancel'>" . __esc('Cancel') . "</button>
<button type='button' class='ui-button ui-corner-all ui-widget' id='pc' name='purge_continue' title='" . __esc('Purge Log') . "'>" . __esc('Continue') . "</button>
<script type='text/javascript'>
$(function() {
$('#pc').click(function() {
strURL = location.pathname+'?action=purge_execute';
loadUrl({url:strURL})
});
$('#cancel').click(function() {
strURL = location.pathname;
loadUrl({url:strURL})
});
});
</script>
</td>
</tr>";
html_end_box();
}
function create_user_log_filter() : array {
global $item_rows;
$all = ['-1' => __('All')];
$deleted = ['-2' => __('Deleted/Invalid')];
$users = db_fetch_assoc('SELECT DISTINCT ua.id,
IF(ud.name != "",
CONCAT(ua.username, " (", ud.name, ")"),
IF(ua.realm = 0,
CONCAT(ua.username, " (' . __esc('Local Auth') . ')"),
CONCAT(ua.username, " (' . __esc('Basic Auth') . ')")
)
) AS name
FROM user_auth AS ua
LEFT JOIN login_providers AS ud
ON ua.realm = ud.id+1000
ORDER BY username, realm');
$users = is_array($users) ? $users : [];
if (cacti_sizeof($users)) {
$users = array_rekey($users, 'id', 'name');
}
$users = $all + $deleted + $users;
$results = [
'-1' => __('Any'),
'1' => __('Success - Password'),
'2' => __('Success - Token'),
'3' => __('Success - Password Change'),
'0' => __('Failed')
];
return [
'rows' => [
[
'filter' => [
'method' => 'textbox',
'friendly_name' => __('Search'),
'filter' => FILTER_DEFAULT,
'placeholder' => __('Enter a search term'),
'size' => '30',
'default' => '',
'pageset' => true,
'max_length' => '120',
'value' => ''
],
'user_id' => [
'method' => 'drop_array',
'friendly_name' => __('User'),
'filter' => FILTER_VALIDATE_INT,
'default' => '-1',
'pageset' => true,
'array' => $users,
'value' => '-1'
],
'result' => [
'method' => 'drop_array',
'friendly_name' => __('Result'),
'filter' => FILTER_VALIDATE_INT,
'default' => '-1',
'pageset' => true,
'array' => $results,
'value' => '-1'
],
'rows' => [
'method' => 'drop_array',
'friendly_name' => __('Attempts'),
'filter' => FILTER_VALIDATE_INT,
'default' => '-1',
'pageset' => true,
'array' => $item_rows,
'value' => '-1'
]
]
],
'buttons' => [
'go' => [
'method' => 'submit',
'display' => __('Go'),
'title' => __('Apply filter to table'),
],
'clear' => [
'method' => 'button',
'display' => __('Clear'),
'title' => __('Reset filter to default values'),
],
'purge' => [
'method' => 'button',
'display' => __('Purge'),
'action' => 'default',
'title' => __('Purge User log of all but the last login attempt'),
]
],
'sort' => [
'sort_column' => 'time',
'sort_direction' => 'DESC'
]
];
}
function draw_user_log_filter(bool $render = false) : void {
$filters = create_user_log_filter();
// create the page filter
$pageFilter = new CactiTableFilter(__('User Login History'), 'user_log.php', 'form_userlog', 'sess_userlog');
$pageFilter->rows_label = __('Attempts');
$pageFilter->set_filter_array($filters);
if ($render) {
$pageFilter->render();
} else {
$pageFilter->sanitize();
}
}