Files
itflow/functions/credential.php
johnnyq fecd1cabff Credentials makeover: rebuilt credentials list with kind icons, username copy, password show/copy and live 2FA code buttons fetched on demand, where it opens, what it is for (asset, contact, vendors, licenses, services), password age pills and last used, filters for Starred, With 2FA, No 2FA, Not changed in 90 days and Shared with counts, search across notes, second link, tags, asset and contact, open straight to a credential with ?open=ID; new credential quick look with login, age, last viewed by, links with unlink, notes, open share links with turn off, and history; tabbed new/edit form with password generator (passphrase or random), strength meter, 2FA secret that accepts spaces or an otpauth:// link and shows the current code to check, copy buttons on both links, prefill from asset, contact or vendor; new Change password window that dates and logs the change; new Share window from anywhere (contact or email, 1 hour to 1 month, gone after opened once by default, link copied); same login controls on asset and contact pages and quick looks, vendor Logins tab, license page and quick look, service quick look, client overview favourites and search; credential pickers show icons and say when a credential moves from another asset or contact; client overview Passwords due for a change attention item and Active share links limited to live links; rotation report with selectable span, per client counts and oldest first; more export columns; demo data gets 2FA secrets, varied password ages and logins attached to their assets. Fixes passwords being blanked when the vault session had expired, other clients' assets, contacts and tags accepted on save, bulk star using the wrong permission, credential links to assets and contacts not checking the client, the edit or link permission, or which asset or contact an unlink was for, credentials left pointing at deleted assets and contacts, share links left open after a credential was deleted, import carrying values between rows and accepting any file type, 2FA secrets saved with spaces or lowercase giving no code, the service quick look listing credentials without credential access and repeating assets, an asset login with only a username being lost, and the API not dating new passwords. Adds functions/credential.php and js/credentials.js; removes credential_model.php and the old credential show password/OTP scripts. No DB change.
2026-10-14 23:33:29 -04:00

516 lines
26 KiB
PHP

<?php
/*
* Credentials - a client's logins: the firewall admin, the Microsoft 365
* break-glass account, the registrar, the Wi-Fi key.
*
* Usernames and passwords are encrypted with the vault key that opens with
* the agent's session (functions/security.php); nothing here changes that.
* Passwords and 2FA codes are only ever fetched when asked for, through
* agent/ajax.php (get_credential_via_id, get_totp_token_via_id), which check
* the module and the client and log the view - the HTML these helpers draw
* carries the credential id, never the secret.
*
* A credential belongs to one asset and/or one contact (columns on the
* credential), and is linked to vendors and licenses through link tables
* (functions/links.php) and to services (service_credentials).
*/
/** How long a password can go unchanged before it is called stale - the same 90 days the rotation report uses. */
const CREDENTIAL_ROTATE_DAYS = 90;
/** Changed within this many days is fresh; between this and a year, due; over a year, overdue. */
const CREDENTIAL_OVERDUE_DAYS = 365;
/** May this agent do this with credentials? 1 is to see them (and reveal, copy and share), 2 to change them, 3 to delete them. */
function credentialCan($level) {
return lookupUserPermission('module_credential') >= $level;
}
/** One credential by id with its client, contact and asset - raw, or null. */
function credentialRow($credential_id) {
global $mysqli;
$credential_id = intval($credential_id);
if (!$credential_id) {
return null;
}
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT credentials.*, client_name,
contact_id, contact_name, contact_title, contact_photo, contact_archived_at,
asset_id, asset_name, asset_type, asset_archived_at
FROM credentials
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 credential_id = $credential_id
LIMIT 1"));
return $row ?: null;
}
/**
* What kind of login it is, from where it opens and what it is called - an
* icon and a tone for its tile: an admin account, Wi-Fi, a remote desktop,
* a shell, a database, a mailbox, a web console, or a plain key.
*/
function credentialKind($row) {
$uri = strtolower(trim((string) ($row['credential_uri'] ?? '')));
$text = strtolower(($row['credential_name'] ?? '') . ' ' . ($row['credential_description'] ?? ''));
$kinds = [
['/wi-?fi|wireless|ssid|psk/', '', 'fa-wifi', 'wireless'],
['/vpn/', '', 'fa-shield-alt', 'firewall'],
['/\b(admin|administrator|root|break ?glass|domain admin)\b/', '', 'fa-user-shield', 'firewall'],
['/database|\bsql\b|mysql|postgres/', '', 'fa-database', 'virtual'],
['/', '#^(rdp|vnc|rustdesk|anydesk|splashtop|connectwise)://#', 'fa-desktop', 'workstation'],
['/', '#^(ssh|telnet|sftp)://#', 'fa-terminal', 'server'],
['/', '#^(smb|file|dav|webdav|ftp|ftps)://#', 'fa-folder-open', 'peripheral'],
['/\b(mail|email|mailbox|365|exchange|workspace)\b/', '', 'fa-envelope', 'phone'],
['/', '#^https?://#', 'fa-globe', 'network'],
];
foreach ($kinds as [$name_match, $uri_match, $icon, $tone]) {
if ($uri_match !== '' && preg_match($uri_match, $uri)) {
return ['icon' => $icon, 'tone' => $tone];
}
if ($uri_match === '' && $name_match !== '/' && preg_match($name_match, $text)) {
return ['icon' => $icon, 'tone' => $tone];
}
}
return ['icon' => 'fa-key', 'tone' => 'other'];
}
/** The credential's tile - its kind's icon on its tone. $size '' | 'sm' | 'lg'. */
function credentialTile($row, $size = '') {
$kind = credentialKind($row);
$size_class = in_array($size, ['sm', 'lg'], true) ? " itflow-asset-icon-$size" : '';
return '<span class="itflow-asset-icon' . $size_class . ' itflow-asset-tone-' . $kind['tone'] . '" aria-hidden="true"><i class="fas fa-fw ' . $kind['icon'] . '"></i></span>';
}
/**
* How old the password is: 'fresh' (changed in the last 90 days), 'due'
* (90 days to a year) or 'overdue' (over a year), with the days and a line
* to show. Null when it has never been recorded.
*/
function credentialAge($changed_at) {
$changed_at = trim((string) $changed_at);
if ($changed_at === '' || strpos($changed_at, '0000-00-00') === 0) {
return null;
}
$changed = strtotime($changed_at);
if (!$changed) {
return null;
}
$days = max(0, intval(floor((time() - $changed) / 86400)));
if ($days >= CREDENTIAL_OVERDUE_DAYS) {
$state = 'overdue';
$tone = 'danger';
} elseif ($days >= CREDENTIAL_ROTATE_DAYS) {
$state = 'due';
$tone = 'warning';
} else {
$state = 'fresh';
$tone = 'success';
}
$text = $days === 0 ? 'Changed today' : ($days === 1 ? 'Changed yesterday' : ($days < 60 ? "Changed $days days ago" : 'Changed ' . timeAgo(date('Y-m-d', $changed))));
return ['state' => $state, 'tone' => $tone, 'days' => $days, 'text' => $text, 'date' => date('Y-m-d', $changed)];
}
/** The age as a soft pill - green, amber when due for rotation, red when overdue. '' when unknown. */
function credentialAgePill($age, $short = false) {
if (!$age) {
return '';
}
$tone = $age['tone'];
$label = $short ? ($age['days'] < 60 ? $age['days'] . 'd' : ($age['days'] < 730 ? round($age['days'] / 30) . 'mo' : round($age['days'] / 365) . 'y')) : $age['text'];
$title = $age['text'] . ($age['state'] === 'fresh' ? '' : ' - due for rotation');
return '<span class="badge rounded-pill bg-' . $tone . '-subtle text-' . $tone . '-emphasis border border-' . $tone . '-subtle fw-normal" title="' . escapeHtml($title) . '">'
. '<i class="fas fa-' . ($age['state'] === 'fresh' ? 'check' : 'history') . ' me-1"></i>' . escapeHtml($label) . '</span>';
}
/**
* A 2FA seed as it is stored - the base32 secret, uppercase, without spaces
* or dashes - from what was pasted: the secret as an app shows it ("abcd efgh
* ...") or a whole otpauth:// link from the QR code. '' for nothing, false
* when it is not a seed.
*/
function credentialOtpSeed($input) {
$input = trim((string) $input);
if ($input === '') {
return '';
}
if (stripos($input, 'otpauth://') === 0) {
$query = [];
parse_str((string) parse_url($input, PHP_URL_QUERY), $query);
$input = (string) ($query['secret'] ?? '');
}
$seed = strtoupper(preg_replace('/[\s\-]+/', '', $input));
$seed = rtrim($seed, '=');
if ($seed === '' || !preg_match('/^[A-Z2-7]+$/', $seed) || strlen($seed) < 16) {
return false;
}
return $seed;
}
/**
* The credential fields a New / Edit form posts, as a SQL SET list - the
* username and password encrypted, the 2FA seed tidied, only the client's
* own contact and asset. Null with $error set when it cannot be saved: no
* name, a field too long, a 2FA seed that is not one, or a vault that did
* not open with this session (encrypting would otherwise write nothing over
* the password).
*/
function credentialFormSet($post, $client_id, &$error = '') {
$error = '';
if (trim((string) ($post['name'] ?? '')) === '') {
$error = 'A credential needs a name.';
return null;
}
if ($too_long = checkCredentialLengths($post)) {
$error = "The $too_long is too long to store.";
return null;
}
$otp = credentialOtpSeed($post['otp_secret'] ?? '');
if ($otp === false) {
$error = "That 2FA secret is not one - paste the key the site shows under \"can't scan the code\", or the otpauth:// link.";
return null;
}
$username = encryptCredentialEntry(trim((string) ($post['username'] ?? '')));
$password = encryptCredentialEntry(trim((string) ($post['password'] ?? '')));
if ($username === false || $password === false) {
$error = 'Your vault session has expired, so the login could not be encrypted - sign out and back in, then try again.';
return null;
}
$text = function ($field, $length) use ($post) {
return "'" . escapeSql(mb_substr(trim((string) ($post[$field] ?? '')), 0, $length)) . "'";
};
$fields = [
'credential_name' => $text('name', 200),
'credential_description' => $text('description', 500),
'credential_uri' => $text('uri', 500),
'credential_uri_2' => $text('uri_2', 500),
'credential_username' => "'" . escapeSql($username) . "'",
'credential_password' => "'" . escapeSql($password) . "'",
'credential_otp_secret' => "'" . escapeSql($otp) . "'",
'credential_note' => "'" . escapeSql(trim((string) ($post['note'] ?? ''))) . "'",
'credential_favorite' => intval($post['favorite'] ?? 0) ? 1 : 0,
'credential_contact_id' => assetClientOwnedId('contact', $post['contact'] ?? 0, $client_id),
'credential_asset_id' => assetClientOwnedId('asset', $post['asset'] ?? 0, $client_id),
];
$set = [];
foreach ($fields as $column => $value) {
$set[] = "$column = $value";
}
return implode(', ', $set);
}
/** Replace a credential's tags with these - credential tags (type 4) only. */
function credentialSetTags($credential_id, $tag_ids) {
global $mysqli;
$credential_id = intval($credential_id);
mysqli_query($mysqli, "DELETE FROM credential_tags WHERE credential_id = $credential_id");
$tag_ids = array_values(array_unique(array_filter(array_map('intval', (array) $tag_ids))));
if (!$tag_ids) {
return;
}
mysqli_query($mysqli, "INSERT IGNORE INTO credential_tags (credential_id, tag_id)
SELECT $credential_id, tag_id FROM tags WHERE tag_type = 4 AND tag_id IN (" . implode(',', $tag_ids) . ")");
}
/** The username as stored, decrypted - '' when there is none or the vault is closed. */
function credentialUsername($row) {
$ciphertext = (string) ($row['credential_username'] ?? '');
if ($ciphertext === '') {
return '';
}
$plain = decryptCredentialEntry($ciphertext);
return $plain === false ? '' : (string) $plain;
}
/** The username with its copy button, or a dash. Raw in. */
function credentialUsernameHtml($username, $empty = '-') {
$username = escapeHtml($username);
if ($username === '') {
return '<span class="text-body-secondary">' . $empty . '</span>';
}
return '<span class="itflow-credential-user text-truncate">' . $username . '</span><button type="button" class="itflow-copy clipboardjs" title="Copy username" data-clipboard-text="' . $username . '"><i class="far fa-copy"></i></button>';
}
/**
* The password as dots, with an eye that fetches and shows it for a little
* while and a copy button that fetches and copies it - both logged as a view
* by agent/ajax.php. Nothing secret is in the page until asked for.
*/
function credentialPasswordHtml($credential_id) {
$credential_id = intval($credential_id);
// Copying is the point here, so its copy button shows all the time (.itflow-copy-always)
return '<span class="itflow-credential-pw itflow-copy-always" data-credential-id="' . $credential_id . '">'
. '<span class="itflow-credential-pw-mask" aria-hidden="true">&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;</span>'
. '<span class="itflow-credential-pw-value font-monospace" hidden></span>'
. '<button type="button" class="itflow-credential-reveal" title="Show password" aria-label="Show password" aria-pressed="false"><i class="fas fa-eye"></i></button>'
. '<button type="button" class="itflow-copy itflow-credential-copy" title="Copy password"><i class="far fa-copy"></i></button>'
. '</span>';
}
/**
* The current 2FA code, fetched when clicked, with how long it has left - it
* follows on to the next code while it is on show, and a second click
* copies it. '' when the credential has no 2FA.
*/
function credentialOtpHtml($credential_id, $has_otp) {
if (!$has_otp) {
return '';
}
$credential_id = intval($credential_id);
return '<button type="button" class="itflow-credential-otp" data-credential-otp="' . $credential_id . '" title="Show the 2FA code">'
. '<i class="far fa-clock me-1"></i><span class="itflow-credential-otp-code">2FA</span><span class="itflow-credential-otp-time"></span></button>';
}
/** Where the login is used - the link, opening in a new tab, and a copy button. '' without one. Raw in. */
function credentialUriHtml($uri, $max = 34) {
$uri = trim((string) $uri);
if ($uri === '') {
return '';
}
$href = escapeUrl($uri);
$label = escapeHtml(truncate(preg_replace('#^https?://#i', '', $uri), $max));
return '<a href="' . $href . '" target="_blank" rel="noopener noreferrer" title="' . escapeHtml($uri) . '">' . $label . '<i class="fas fa-external-link-alt ms-1 small"></i></a>'
. '<button type="button" class="itflow-copy clipboardjs" title="Copy link" data-clipboard-text="' . escapeHtml($uri) . '"><i class="far fa-copy"></i></button>';
}
/** A credential in a picker - its kind's icon and a quieter line under it (functions/format.php selectOptionData). */
function selectOptionCredential($row, $description = '') {
return selectOptionData(['icon' => 'fas ' . credentialKind($row)['icon'], 'description' => $description, 'keywords' => $row['credential_uri'] ?? '']);
}
/**
* A credential wherever a page mentions one: its tile and its name, opening
* its quick look. 'class' goes on the link; 'archived' strikes it through.
*/
function credentialLink($row, $options = []) {
$credential_id = intval($row['credential_id'] ?? $row['id'] ?? 0);
$name = escapeHtml($row['credential_name'] ?? $row['name'] ?? '');
$archived = !empty($options['archived']);
$class = trim('text-truncate ' . ($options['class'] ?? '') . ($archived ? ' text-decoration-line-through text-body-secondary' : ''));
return '<span class="itflow-contact-ref">' . credentialTile($row, 'sm')
. "<a class=\"ajax-modal $class\" href=\"#\" data-modal-url=\"modals/credential/credential.php?id=$credential_id\">$name</a></span>";
}
/**
* Credentials to list somewhere - an asset's, a contact's, a vendor's or a
* license's - each with its client, contact and asset like credentialRow(),
* starred first then by name, keyed by id. $where is SQL the caller built
* from intval'd ids. Archived ones are left out unless asked for.
*/
function credentialRows($where, $include_archived = false) {
global $mysqli;
$sql = mysqli_query($mysqli, "SELECT credentials.*, client_name,
contact_id, contact_name, contact_title, contact_photo, contact_archived_at,
asset_id, asset_name, asset_type, asset_archived_at
FROM credentials
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 ($where)" . ($include_archived ? '' : ' AND credential_archived_at IS NULL') . "
ORDER BY credential_favorite DESC, credential_name ASC
LIMIT 500");
$rows = [];
while ($sql && $row = mysqli_fetch_assoc($sql)) {
$rows[intval($row['credential_id'])] = $row;
}
return $rows;
}
/** The asset and contact a credential belongs to, each as a small link - HTML pieces, maybe none. */
function credentialOwnersHtml($row) {
$pieces = [];
if (!empty($row['asset_id'])) {
$pieces[] = '<span class="itflow-contact-ref"><span class="itflow-asset-icon itflow-asset-icon-sm itflow-vendor-tile-xs itflow-asset-tone-' . assetTypeTone($row['asset_type']) . '" aria-hidden="true"><i class="fas fa-fw fa-' . getAssetIcon($row['asset_type']) . '"></i></span>'
. '<a class="ajax-modal text-truncate" href="#" data-modal-size="lg" data-modal-url="modals/asset/asset.php?id=' . intval($row['asset_id']) . '">' . escapeHtml($row['asset_name']) . '</a></span>';
}
if (!empty($row['contact_id'])) {
$pieces[] = contactLink(intval($row['credential_client_id']), $row['contact_id'], $row['contact_name'], $row['contact_photo'], ['archived' => !empty($row['contact_archived_at'])]);
}
return $pieces;
}
/**
* Credentials as a table, the same wherever they are listed outside the
* credentials page - an asset's or contact's tab, a vendor's logins, a
* license's: the credential (opening its quick look), the login with the
* password and 2FA code on asking, how old the password is, and the actions.
* Options: 'for' adds the asset and contact it belongs to under its name
* (on a vendor's or license's list, where that differs); 'unlink' is a function
* of the row giving the URL that unlinks it from this page's record ('' for
* none).
*/
function credentialTableHtml(array $rows, array $options = []) {
$can_edit = credentialCan(2);
$can_share = lookupUserPermission('module_support') >= 2;
$show_for = !empty($options['for']);
$unlink = $options['unlink'] ?? null;
$csrf = $_SESSION['csrf_token'] ?? '';
$html = '<div class="table-responsive"><table class="table table-hover align-middle mb-0 itflow-credential-table">'
. '<thead class="text-nowrap table-light"><tr><th>Credential</th><th>Login</th><th>Changed</th><th></th></tr></thead><tbody>';
foreach ($rows as $row) {
$id = intval($row['credential_id']);
$name = escapeHtml($row['credential_name']);
$description = escapeHtml($row['credential_description']);
$archived = !empty($row['credential_archived_at']);
$quick = "modals/credential/credential.php?id=$id";
$has_otp = trim((string) $row['credential_otp_secret']) !== '';
$html .= '<tr><td class="itflow-credential-name-cell"><div class="d-flex align-items-center gap-2">' . credentialTile($row, 'sm') . '<div class="min-w-0">'
. '<a class="fw-semibold text-body ajax-modal" href="#" data-modal-url="' . $quick . '">' . $name . '</a>'
. (intval($row['credential_favorite']) ? '<i class="fas fa-star text-warning small ms-1" title="Starred"></i>' : '')
. ($description !== '' ? '<div class="small text-body-secondary text-truncate itflow-asset-desc" title="' . $description . '">' . $description . '</div>' : '');
$owners = $show_for ? credentialOwnersHtml($row) : [];
if ($owners) {
$html .= '<div class="small d-flex flex-wrap column-gap-3 row-gap-1 mt-1 itflow-credential-owners">' . implode('', $owners) . '</div>';
}
$html .= '</div></div></td>';
$html .= '<td><div class="d-flex align-items-center small"><i class="fas fa-fw fa-user text-body-secondary me-1"></i>' . credentialUsernameHtml(credentialUsername($row)) . '</div>'
. '<div class="d-flex align-items-center flex-wrap gap-2 mt-1 small">' . credentialPasswordHtml($id) . credentialOtpHtml($id, $has_otp) . '</div>';
foreach (array_filter([$row['credential_uri'] ?? '', $row['credential_uri_2'] ?? '']) as $uri) {
$html .= '<div class="small text-nowrap mt-1">' . credentialUriHtml($uri, 34) . '</div>';
}
$html .= '</td>';
$html .= '<td class="text-nowrap">' . (credentialAgePill(credentialAge($row['credential_password_changed_at']), true) ?: '<span class="text-body-secondary">-</span>') . '</td>';
$actions = '<a class="dropdown-item ajax-modal" href="#" data-modal-url="' . $quick . '"><i class="fas fa-fw fa-eye text-secondary me-2"></i>Quick look</a>';
if ($can_edit && !$archived) {
$actions .= '<a class="dropdown-item ajax-modal" href="#" data-modal-size="lg" data-modal-url="modals/credential/credential_edit.php?id=' . $id . '"><i class="fas fa-fw fa-edit text-secondary me-2"></i>Edit</a>'
. '<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/credential/credential_rotate.php?id=' . $id . '"><i class="fas fa-fw fa-sync-alt text-secondary me-2"></i>Change password</a>';
}
if ($can_share && !$archived) {
$actions .= '<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/credential/credential_share.php?id=' . $id . '"><i class="fas fa-fw fa-share-alt text-secondary me-2"></i>Share</a>';
}
$unlink_url = $unlink ? (string) $unlink($row) : '';
if ($unlink_url !== '' || ($can_edit && !$archived)) {
$actions .= '<div class="dropdown-divider"></div>';
}
if ($unlink_url !== '') {
$actions .= '<a class="dropdown-item" href="' . escapeHtml($unlink_url) . '"><i class="fas fa-fw fa-unlink text-secondary me-2"></i>Unlink</a>';
}
if ($can_edit && !$archived) {
$actions .= '<a class="dropdown-item text-danger confirm-link" href="post.php?archive_credential=' . $id . '&amp;csrf_token=' . escapeHtml($csrf) . '"><i class="fas fa-fw fa-archive me-2"></i>Archive</a>';
}
$html .= '<td class="text-end"><div class="dropdown dropstart"><button class="btn btn-sm btn-light" title="More actions" type="button" data-bs-toggle="dropdown"><i class="fas fa-fw fa-ellipsis-v"></i></button>'
. '<div class="dropdown-menu">' . $actions . '</div></div></td></tr>';
}
return $html . '</tbody></table></div>';
}
/**
* Credentials as a short list for the quick looks (an asset's, a contact's):
* each one's tile and name opening its own quick look, the login with the
* password and 2FA code on asking, and how old the password is. Rows from
* credentialRows().
*/
function credentialListHtml(array $rows) {
$html = '<ul class="list-unstyled mb-0 itflow-links">';
foreach ($rows as $row) {
$link_row = $row + ['id' => $row['credential_id'], 'name' => $row['credential_name']];
$html .= '<li class="itflow-link-row">' . linkRowHtml('credential', $link_row, $row['credential_client_id'])
. credentialAgePill(credentialAge($row['credential_password_changed_at']), true) . '</li>';
}
return $html . '</ul>';
}
/**
* What each credential is linked to beyond its own asset and contact -
* vendors, licenses and services - for the whole page at once:
* [credential_id => [['type', 'id', 'name'], ...]].
*/
function credentialLinks(array $credential_ids) {
global $mysqli;
$credential_ids = array_values(array_unique(array_filter(array_map('intval', $credential_ids))));
$links = array_fill_keys($credential_ids, []);
if (!$credential_ids) {
return $links;
}
$in = implode(',', $credential_ids);
$sources = [
'vendor' => "SELECT l.credential_id, v.vendor_id, v.vendor_name FROM vendor_credentials l JOIN vendors v ON v.vendor_id = l.vendor_id AND v.vendor_archived_at IS NULL WHERE l.credential_id IN ($in) ORDER BY v.vendor_name",
'software' => "SELECT l.credential_id, s.software_id, s.software_name FROM software_credentials l JOIN software s ON s.software_id = l.software_id AND s.software_archived_at IS NULL WHERE l.credential_id IN ($in) ORDER BY s.software_name",
'service' => "SELECT l.credential_id, s.service_id, s.service_name FROM service_credentials l JOIN services s ON s.service_id = l.service_id WHERE l.credential_id IN ($in) ORDER BY s.service_name",
];
foreach ($sources as $type => $query) {
$sql = mysqli_query($mysqli, $query);
while ($row = mysqli_fetch_row($sql)) {
$links[intval($row[0])][] = [$type, intval($row[1]), $row[2]];
}
}
return $links;
}
/** The share links still open for each credential, for the whole page at once: [credential_id => [share rows]]. */
function credentialShares(array $credential_ids) {
global $mysqli;
$credential_ids = array_values(array_unique(array_filter(array_map('intval', $credential_ids))));
$shares = array_fill_keys($credential_ids, []);
if (!$credential_ids) {
return $shares;
}
$sql = mysqli_query($mysqli, "SELECT item_id, item_related_id, item_recipient, item_note, item_views, item_view_limit, item_created_at, item_expire_at
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)
AND item_related_id IN (" . implode(',', $credential_ids) . ")
ORDER BY item_expire_at ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$shares[intval($row['item_related_id'])][] = $row;
}
return $shares;
}
/**
* Who did what with a credential - its audit log entries (views, 2FA code
* views, edits, rotations, links, shares), newest first.
*/
function credentialHistory($credential_id, $limit = 50) {
global $mysqli;
$credential_id = intval($credential_id);
$limit = max(1, intval($limit));
$sql = mysqli_query($mysqli, "SELECT log_action, log_description, log_created_at, user_name FROM logs
LEFT JOIN users ON user_id = log_user_id
WHERE log_type = 'Credential' AND log_entity_id = $credential_id
ORDER BY log_id DESC LIMIT $limit");
$rows = [];
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
return $rows;
}
/** The last time someone looked at the password or the 2FA code, and who - or null. */
function credentialLastViewed($credential_id) {
global $mysqli;
$credential_id = intval($credential_id);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT log_created_at, user_name FROM logs
LEFT JOIN users ON user_id = log_user_id
WHERE log_type = 'Credential' AND log_entity_id = $credential_id AND log_action IN ('View', 'View TOTP')
ORDER BY log_id DESC LIMIT 1"));
return $row ?: null;
}
/**
* An asset or contact is going for good - its credentials stay, for no asset
* or no contact, rather than pointing at an id that no longer exists.
*/
function credentialsForget($kind, $id) {
global $mysqli;
$column = ['asset' => 'credential_asset_id', 'contact' => 'credential_contact_id'][$kind] ?? null;
$id = intval($id);
if (!$column || !$id) {
return;
}
mysqli_query($mysqli, "UPDATE credentials SET $column = 0 WHERE $column = $id");
}
/** Deleting a credential for good - its open share links go with it. Links and tags cascade. */
function credentialDeleteForGood($credential_id) {
global $mysqli;
$credential_id = intval($credential_id);
mysqli_query($mysqli, "DELETE FROM shared_items WHERE item_type = 'Credential' AND item_related_id = $credential_id");
mysqli_query($mysqli, "DELETE FROM credentials WHERE credential_id = $credential_id");
}