diff --git a/admin/post/demo_data_model.php b/admin/post/demo_data_model.php index dd116be30..c1b9ee85f 100644 --- a/admin/post/demo_data_model.php +++ b/admin/post/demo_data_model.php @@ -367,6 +367,17 @@ function demoFirstId($mysqli, $table, $id_column, $archived_column = '') { // Deterministic stand-in for a serial number - same input, same serial, so a // reload after a removal does not produce a whole new set of numbers +/** A 2FA seed that comes out the same on every run - 32 base32 characters from the seed. */ +function demoTotpSeed($seed) { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + $bytes = md5('itflow-demo-totp-' . $seed, true) . md5('itflow-demo-totp-2-' . $seed, true); + $out = ''; + for ($i = 0; $i < 32; $i++) { + $out .= $alphabet[ord($bytes[$i]) % 32]; + } + return $out; +} + function demoSerial($seed, $length = 10) { return strtoupper(substr(md5('itflow-demo-' . $seed), 0, $length)); } @@ -1342,9 +1353,12 @@ function demoBuildClient($mysqli, $profile, $index, $context, &$counts) { 'credential_password' => encryptCredentialEntry(demoSerial($profile['abbreviation'] . $credential[0], 14) . '!aA1'), 'credential_note' => $credential[4], 'credential_favorite' => $credential[6], + // A 2FA seed on the logins that have one, so the codes count down like the real thing + 'credential_otp_secret' => !empty($credential[7]) ? demoTotpSeed($profile['abbreviation'] . $credential[0]) : '', 'credential_client_id' => $client_id, 'credential_created_at' => demoMonthDateTime($months, 8, 16, 10), - 'credential_password_changed_at' => demoMonthDateTime(max(0, min(4, $months)), 8, 16, 10), + // Some changed lately, some due, one well over a year - never before the client was taken on + 'credential_password_changed_at' => demoDateTime(min($credential[8] ?? 30, max(0, $months * 30 - 10)), 16, 10), 'credential_accessed_at' => demoDateTime(($index + 2) % 15, 11, 30), ]); $credential_ids[$credential[0]] = $credential_id; @@ -1480,6 +1494,8 @@ function demoBuildClient($mysqli, $profile, $index, $context, &$counts) { foreach ($assets as $asset) { if (!empty($asset['credential']) && $asset['credential'] === $credential_name) { demoLink($mysqli, 'asset_credentials', 'credential_id', $credential_id, 'asset_id', $asset['id']); + // The credential's own asset - what the asset's Credentials tab and the credential show + mysqli_query($mysqli, "UPDATE credentials SET credential_asset_id = " . intval($asset['id']) . " WHERE credential_id = " . intval($credential_id) . " AND credential_asset_id = 0"); } } } @@ -4500,13 +4516,14 @@ function demoExpandProfile($spec) { // Credentials - names line up with demoAssetCredentialName so the vault // entries attach to the kit they actually open $mail_admin_uri = $mail === 'Google Workspace' ? 'https://admin.google.com' : 'https://admin.microsoft.com'; + // [name, description, uri, username, note, tags, starred, has 2FA, password last changed (days ago)] $credentials = [ - ['Firewall Administrator', 'Edge firewall management interface', 'https://192.168.10.1', 'msp-admin', 'Local administrator on the edge firewall. Rotated at every quarterly review.', ['Firewall', 'Local Admin', 'Rotate Quarterly'], 1], - [$mail . ' Administrator', 'Tenant administration for mail and identity', $mail_admin_uri, 'msp-admin@' . $domain, 'Break glass administrator. MFA enforced - the token lives with the on call phone.', ['Microsoft 365', 'Domain Admin', 'Break Glass'], 1], - ['Hypervisor and Storage Administrator', $foss ? 'Proxmox VE and TrueNAS management' : 'Hypervisor and NAS management', 'https://192.168.20.10:8006', 'msp-admin', 'Root equivalent. Use the individual accounts for day to day work and this only for maintenance.', ['Hypervisor', 'Local Admin', 'Break Glass'], 0], - ['Network Controller', 'UniFi controller for switching and wireless', 'https://192.168.10.12', 'msp-' . strtolower($short), 'Controller runs on the management stack, not on site.', ['Switch', 'Wireless', 'Shared'], 0], - ['Backup Console', $foss ? 'Proxmox Backup Server and Synology Active Backup' : 'Backup platform for servers and endpoints', 'https://backup.example', 'msp-' . strtolower($short), 'Console login for restore requests and job monitoring.', ['Backup Console', 'Service Account'], 0], - ['Registrar and DNS', 'Domain registrar and DNS control panel', 'https://registrar.example', 'billing@' . $domain, 'Registrar account is in the client name with us as technical contact.', ['Registrar and DNS', 'Shared'], 0], + ['Firewall Administrator', 'Edge firewall management interface', 'https://192.168.10.1', 'msp-admin', 'Local administrator on the edge firewall. Rotated at every quarterly review.', ['Firewall', 'Local Admin', 'Rotate Quarterly'], 1, 0, 25], + [$mail . ' Administrator', 'Tenant administration for mail and identity', $mail_admin_uri, 'msp-admin@' . $domain, 'Break glass administrator. MFA enforced - the token lives with the on call phone.', ['Microsoft 365', 'Domain Admin', 'Break Glass'], 1, 1, 120], + ['Hypervisor and Storage Administrator', $foss ? 'Proxmox VE and TrueNAS management' : 'Hypervisor and NAS management', 'https://192.168.20.10:8006', 'msp-admin', 'Root equivalent. Use the individual accounts for day to day work and this only for maintenance.', ['Hypervisor', 'Local Admin', 'Break Glass'], 0, 0, 430], + ['Network Controller', 'UniFi controller for switching and wireless', 'https://192.168.10.12', 'msp-' . strtolower($short), 'Controller runs on the management stack, not on site.', ['Switch', 'Wireless', 'Shared'], 0, 0, 60], + ['Backup Console', $foss ? 'Proxmox Backup Server and Synology Active Backup' : 'Backup platform for servers and endpoints', 'https://backup.example', 'msp-' . strtolower($short), 'Console login for restore requests and job monitoring.', ['Backup Console', 'Service Account'], 0, 1, 210], + ['Registrar and DNS', 'Domain registrar and DNS control panel', 'https://registrar.example', 'billing@' . $domain, 'Registrar account is in the client name with us as technical contact.', ['Registrar and DNS', 'Shared'], 0, 1, 15], ]; // Services - the last two entries say which asset types run it and which diff --git a/agent/ajax.php b/agent/ajax.php index aa53fc16d..fa9921bb2 100644 --- a/agent/ajax.php +++ b/agent/ajax.php @@ -817,6 +817,10 @@ if (isset($_GET['share_generate_link'])) { // Logging logAudit("Share", "Create", "$session_name created shared link for $item_type - $item_name", $client_id, $item_id); + // ...and on the credential itself, so its history says who it went out to + if ($item_type == "Credential") { + logAudit("Credential", "Share", "$session_name shared credential $item_name" . ($item_email ? " with $item_email" : '') . ", expiring in $item_expires_friendly", $client_id, $item_id); + } } @@ -1167,13 +1171,19 @@ if (isset($_GET['get_totp_token_via_id'])) { $credential_id = intval($_GET['credential_id']); $sql = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT credential_name, credential_otp_secret, credential_client_id FROM credentials WHERE credential_id = $credential_id")); + if (!$sql) { + echo json_encode(''); + exit; + } $name = escapeSql($sql['credential_name']); $totp_secret = $sql['credential_otp_secret']; $client_id = intval($sql['credential_client_id']); enforceClientAccess(); - $otp = TokenAuth6238::getTokenCode(strtoupper($totp_secret)); + // Seeds saved before they were tidied on save may still carry spaces, dashes or the whole otpauth:// link + $totp_seed = credentialOtpSeed($totp_secret); + $otp = $totp_seed ? TokenAuth6238::getTokenCode($totp_seed) : ''; echo json_encode($otp); // Logging @@ -1816,6 +1826,10 @@ if (isset($_GET['get_credential_via_id'])) { $credential_id = intval($_GET['credential_id']); $sql = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT credential_name, credential_username, credential_password, credential_client_id FROM credentials WHERE credential_id = $credential_id")); + if (!$sql) { + echo json_encode(['error' => 'That credential no longer exists.']); + exit; + } $name = escapeSql($sql['credential_name']); $client_id = intval($sql['credential_client_id']); @@ -1827,6 +1841,9 @@ if (isset($_GET['get_credential_via_id'])) { ); echo json_encode($response); + // When it was last used - the credentials list sorts by it. Kept off the updated date, which means an edit + mysqli_query($mysqli, "UPDATE credentials SET credential_accessed_at = NOW(), credential_updated_at = credential_updated_at WHERE credential_id = $credential_id"); + // Only log if this user hasn't viewed this credential recently (mirrors TOTP dedup) $check_recent_view = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(log_id) AS recent_view FROM logs WHERE log_type = 'Credential' AND log_action = 'View' AND log_user_id = $session_user_id AND log_entity_id = $credential_id AND log_client_id = $client_id AND log_created_at > (NOW() - INTERVAL 5 MINUTE)")); diff --git a/agent/asset.php b/agent/asset.php index d2b369f2a..72b8e4383 100644 --- a/agent/asset.php +++ b/agent/asset.php @@ -103,6 +103,7 @@ if (mysqli_num_rows($sql) == 0) { $can_edit = lookupUserPermission('module_support') >= 2; $can_delete = lookupUserPermission('module_support') >= 3; $can_credentials = lookupUserPermission('module_credential') >= 1; + $can_credentials_edit = lookupUserPermission('module_credential') >= 2; // The rack it sits in, if any $asset_rack = rackPositionForAsset($asset_id); @@ -211,18 +212,8 @@ if (mysqli_num_rows($sql) == 0) { $document_count = mysqli_num_rows($sql_related_documents); // Credentials - $credential_count = 0; - if ($can_credentials) { - $sql_related_credentials = mysqli_query($mysqli, " - SELECT credential_id, credential_name, credential_description, credential_uri, - credential_username, credential_otp_secret - FROM credentials - WHERE credential_asset_id = $asset_id - AND credential_archived_at IS NULL - ORDER BY credential_name ASC - "); - $credential_count = mysqli_num_rows($sql_related_credentials); - } + $related_credentials = $can_credentials ? credentialRows("credential_asset_id = $asset_id AND credential_client_id = $client_id") : []; + $credential_count = count($related_credentials); // Licenses it holds a seat on, with each one's seats in use and the key it was given $held_software = softwareHeldBy('asset', $asset_id); @@ -337,8 +328,8 @@ if (mysqli_num_rows($sql) == 0) { Recurring Ticket - - + + Credential @@ -365,7 +356,7 @@ if (mysqli_num_rows($sql) == 0) { License - + Credential @@ -734,98 +725,24 @@ if (mysqli_num_rows($sql) == 0) {
Logins for this asset. Passwords and codes are fetched only when you ask for them. - +
-
- -
- - - - - - - - - - - - - - - - - - - - - - - -
NameUsernamePasswordOTPURI
- -
-
- - - - - - - - - - - - Hover... - - - - - - - - - - - - - -
-
+ + false, + 'unlink' => function ($row) use ($asset_id, $can_edit) { + return $can_edit && credentialCan(2) ? "post.php?unlink_credential_from_asset&asset_id=$asset_id&credential_id=" . intval($row['credential_id']) . "&csrf_token=" . $_SESSION['csrf_token'] : ''; + }, + ]) ?>
No credentials for this asset.
@@ -1213,7 +1130,6 @@ if (mysqli_num_rows($sql) == 0) {
- - - -
- - - - - - - - - - - - OTP - - - + + + + + + + + + +
@@ -378,6 +373,10 @@ $money_format = function ($amount) use ($currency_format, $client_currency_code, } elseif ($item['item_type'] === 'File') { $item_tile = fileTypeTile($item['file_ext'], 'sm'); $item_label = "$item_label"; + } elseif ($item['item_type'] === 'Credential' && $item['item_name']) { + // Its tile, opening its quick look - with the password and 2FA there for whoever may see them + $item_tile = credentialTile(['credential_name' => $item['item_name'], 'credential_uri' => $item['credential_uri'], 'credential_description' => $item['credential_description']], 'sm'); + $item_label = $access['credentials'] ? "$item_label" : $item_label; } else { $item_tile = ''; } @@ -547,8 +546,6 @@ $money_format = function ($amount) use ($currency_format, $client_currency_code, - - diff --git a/agent/contact.php b/agent/contact.php index deee03f74..9891f2fdc 100644 --- a/agent/contact.php +++ b/agent/contact.php @@ -72,6 +72,7 @@ $contact_role_line = implode(' · ', array_filter([$contact_title, $contac $can_edit = lookupUserPermission('module_client') >= 2; $can_delete = lookupUserPermission('module_client') >= 3; $can_credentials = lookupUserPermission('module_credential') >= 1; +$can_credentials_edit = lookupUserPermission('module_credential') >= 2; $can_support = lookupUserPermission('module_support') >= 1; $can_support_edit = lookupUserPermission('module_support') >= 2; @@ -93,17 +94,8 @@ $sql_related_assets = mysqli_query($mysqli, "SELECT asset_id, asset_name, asset_ $asset_count = mysqli_num_rows($sql_related_assets); // Credentials - only for agents who may see them -$credential_count = 0; -if ($can_credentials) { - $sql_related_credentials = mysqli_query($mysqli, "SELECT credential_id, credential_name, credential_description, credential_uri, - credential_username, credential_otp_secret - FROM credentials - WHERE credential_contact_id = $contact_id - AND credential_archived_at IS NULL - ORDER BY credential_name ASC" - ); - $credential_count = mysqli_num_rows($sql_related_credentials); -} +$related_credentials = $can_credentials ? credentialRows("credential_contact_id = $contact_id AND credential_client_id = $client_id") : []; +$credential_count = count($related_credentials); // Tickets they raised or are the contact on - open ones first $related_tickets = []; @@ -276,8 +268,8 @@ if (!$can_support) { Asset - - + + Credential @@ -303,7 +295,7 @@ if (!$can_support) { Asset - + Credential @@ -531,75 +523,23 @@ if (!$can_support) {
Their logins. Passwords and codes are fetched only when you ask for them. - +
-
- -
- - - - - - - - - - - - - - - - -
NameUsernamePasswordOTPURI
- -
-
- - - - - Hover... - - - - - - - -
-
+ + function ($row) use ($contact_id, $can_edit) { + return $can_edit && credentialCan(2) ? "post.php?unlink_credential_from_contact&contact_id=$contact_id&credential_id=" . intval($row['credential_id']) . "&csrf_token=" . $_SESSION['csrf_token'] : ''; + }, + ]) ?>
No credentials for .
@@ -897,8 +837,6 @@ if (!$can_support) {
- - - - - - = 2; + +// Client Filter (all clients) +$client = ''; +if (!$client_url && !empty($_GET['client'])) { + $client = intval($_GET['client']); + $client_query = "AND credential_client_id = $client"; +} + +// Location Filter (one client) - credentials for the assets at a site +$location_filter = $client_url ? intval($_GET['location'] ?? 0) : 0; +$location_query = $location_filter ? "AND c.credential_asset_id IN (SELECT asset_id FROM assets WHERE asset_location_id = $location_filter)" : ''; + +// Tags Filter - a credential with any of them if (isset($_GET['tags']) && is_array($_GET['tags']) && !empty($_GET['tags'])) { - // Sanitize each element of the tags array - $sanitizedTags = array_map('intval', $_GET['tags']); - // Convert the sanitized tags into a comma-separated string - $tag_filter = implode(",", $sanitizedTags); - $tag_query = "AND tags.tag_id IN ($tag_filter)"; + $tag_filter = implode(",", array_map('intval', $_GET['tags'])); + $tag_query = "AND c.credential_id IN (SELECT credential_id FROM credential_tags WHERE tag_id IN ($tag_filter))"; } else { $tag_filter = 0; $tag_query = ''; } -if (!$client_url) { - // Client Filter - if (isset($_GET['client']) & !empty($_GET['client'])) { - $client_query = 'AND (credential_client_id = ' . intval($_GET['client']) . ')'; - $client = intval($_GET['client']); - } else { - // Default - any - $client_query = ''; - $client = ''; - } -} +// Which ones - the pills along the top +$rotate_days = CREDENTIAL_ROTATE_DAYS; +$shows = [ + '' => ['label' => 'All', 'where' => ''], + 'starred' => ['label' => 'Starred', 'icon' => 'fa-star', 'where' => 'c.credential_favorite = 1'], + 'otp' => ['label' => 'With 2FA', 'icon' => 'fa-clock', 'where' => "c.credential_otp_secret <> ''"], + 'nootp' => ['label' => 'No 2FA', 'icon' => 'fa-unlock', 'where' => "(c.credential_otp_secret IS NULL OR c.credential_otp_secret = '')"], + 'due' => ['label' => "Not changed in $rotate_days days", 'icon' => 'fa-history', 'where' => "(c.credential_password_changed_at IS NULL OR c.credential_password_changed_at < NOW() - INTERVAL $rotate_days DAY)"], + 'shared' => ['label' => 'Shared', 'icon' => 'fa-share-alt', 'where' => "c.credential_id IN (SELECT item_related_id FROM shared_items WHERE item_type = 'Credential' AND item_active = 1 AND item_expire_at > NOW() AND (COALESCE(item_view_limit, 0) = 0 OR item_views < item_view_limit))"], +]; +$show = isset($shows[$_GET['show'] ?? '']) ? ($_GET['show'] ?? '') : ''; +$show_query = $shows[$show]['where'] ? 'AND ' . $shows[$show]['where'] : ''; -// Location Filter -if ($client_url && isset($_GET['location']) && !empty($_GET['location'])) { - $location_query = 'AND (a.asset_location_id = ' . intval($_GET['location']) . ')'; - $location_query_innerjoin = 'INNER JOIN assets a on a.asset_id = c.credential_asset_id '; - $location_filter = intval($_GET['location']); -} else { - // Default - any - $location_query_innerjoin = ''; - $location_query = ''; - $location_filter = ''; +// Only these sort - filter_header.php will hand through any lowercase word, and +// an unknown column takes the page down with a mysqli exception +$sortable_columns = ['credential_name', 'credential_uri', 'credential_password_changed_at', 'credential_accessed_at', 'client_name', 'credential_created_at']; +if (!in_array($sort, $sortable_columns, true)) { + $sort = 'credential_name'; } +$sort_column = $sort === 'client_name' ? 'client_name' : "c.$sort"; + +$search_query = "AND (c.credential_name LIKE '%$q%' OR c.credential_description LIKE '%$q%' OR c.credential_uri LIKE '%$q%' OR c.credential_uri_2 LIKE '%$q%' + OR c.credential_note LIKE '%$q%' OR client_name LIKE '%$q%' OR asset_name LIKE '%$q%' OR contact_name LIKE '%$q%' + OR c.credential_id IN (SELECT credential_id FROM credential_tags JOIN tags USING (tag_id) WHERE tag_name LIKE '%$q%'))"; +$scope_query = clientScopeSql('credential_client_id'); $sql = mysqli_query( $mysqli, - "SELECT SQL_CALC_FOUND_ROWS c.credential_id AS c_credential_id, c.*, credential_tags.*, tags.*, clients.*, contacts.*, assets.* + "SELECT SQL_CALC_FOUND_ROWS c.credential_id, c.credential_name, c.credential_description, c.credential_uri, c.credential_uri_2, c.credential_username, + c.credential_otp_secret, c.credential_favorite, c.credential_archived_at, c.credential_password_changed_at, c.credential_accessed_at, + c.credential_client_id, client_name, contact_id, contact_name, contact_photo, contact_archived_at, asset_id, asset_name, asset_type, asset_archived_at FROM credentials c - LEFT JOIN credential_tags ON credential_tags.credential_id = c.credential_id - LEFT JOIN tags ON tags.tag_id = credential_tags.tag_id LEFT JOIN clients ON client_id = credential_client_id - LEFT JOIN contacts ON contact_id = credential_contact_id - LEFT JOIN assets ON asset_id = credential_asset_id - $location_query_innerjoin + LEFT JOIN contacts ON contact_id = credential_contact_id AND contact_client_id = credential_client_id + LEFT JOIN assets ON asset_id = credential_asset_id AND asset_client_id = credential_client_id WHERE $archive_query $tag_query - AND (c.credential_name LIKE '%$q%' OR c.credential_description LIKE '%$q%' OR c.credential_uri LIKE '%$q%' OR tag_name LIKE '%$q%' OR client_name LIKE '%$q%') + $search_query $location_query - " . clientScopeSql('credential_client_id') . " + $show_query + $scope_query $client_query - GROUP BY c.credential_id - ORDER BY c.credential_favorite DESC, $sort $order LIMIT $record_from, $record_to" + ORDER BY c.credential_favorite DESC, $sort_column $order, c.credential_name ASC LIMIT $record_from, $record_to" ); $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); +$credentials = []; +while ($row = mysqli_fetch_assoc($sql)) { + $credentials[intval($row['credential_id'])] = $row; +} + +// Tags, links and share links for the whole page at once, not per row +$credentials_tags = tagsForRecords('credential', array_keys($credentials)); +$credentials_links = credentialLinks(array_keys($credentials)); +$credentials_shares = credentialShares(array_keys($credentials)); + +// How many of each, for the pills - everything in scope, before the pill itself +$counts = ['' => 0]; +$count_sql = []; +foreach ($shows as $key => $s) { + $count_sql[] = $key === '' ? 'COUNT(*)' : "COALESCE(SUM({$s['where']}), 0)"; +} +$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT " . implode(', ', $count_sql) . " + FROM credentials c + LEFT JOIN clients ON client_id = credential_client_id + LEFT JOIN contacts ON contact_id = credential_contact_id AND contact_client_id = credential_client_id + LEFT JOIN assets ON asset_id = credential_asset_id AND asset_client_id = credential_client_id + WHERE $archive_query $tag_query $search_query $location_query $scope_query $client_query")); +foreach (array_keys($shows) as $i => $key) { + $counts[$key] = intval($row[$i]); +} + +$filter_url = function ($changes) use ($client_url) { + $params = array_merge($_GET, $changes); + unset($params['page']); + return '?' . http_build_query(array_filter($params, function ($v) { return $v !== '' && $v !== null; })); +}; + ?>

Credentials

- = 2) { ?> -
- - -