Fix client portal review findings: PIN wipe, log indexing, statement currency (db 2.7.8)

Setting a PIN containing < or > silently cleared it: the length check ran before escapeSql(), whose strip_tags() then emptied the value, and the UPDATE stored the blank while flashing success. Length is now checked after sanitising.

Password and PIN changes require the current password. SSO contacts are exempt - no local password to check, and the IdP already did it.

New index on logs(log_user_id, log_client_id) for the portal profile and activity pages, which were scanning the whole table twice per profile view. admin/audit_logs.php's date filter rewritten as a half-open range so KEY log_created_at is usable - DATE(log_created_at) BETWEEN made it non-sargable.

Portal statement page and PDF now render in the client's currency, matching the guest view and the emailed statement.

Quick Send asks for confirmation; confirm-link extended to submit buttons.

Portal audit entries logged an empty name - client/post.php used , which only exists agent-side.
This commit is contained in:
johnnyq
2026-08-28 16:38:42 -04:00
parent 820103460f
commit 156c51224d
12 changed files with 213 additions and 36 deletions

View File

@@ -72,6 +72,36 @@ function enforceContactCan($capability) {
}
}
/*
* Confirms the person at the keyboard is the account holder, before a change
* that would let someone who hijacked a session take the account over or
* defeat phone verification.
*
* Returns true for SSO contacts without checking anything: there is no local
* password to compare against, and the identity provider has already done this
* work. Gating them on a password they do not have would just lock them out.
*/
function portalReauthenticate($current_password) {
global $mysqli, $session_user_id;
if (($_SESSION['login_method'] ?? 'local') !== 'local') {
return true;
}
if (empty($current_password)) {
return false;
}
$sql = mysqli_query($mysqli, "SELECT user_password FROM users WHERE user_id = $session_user_id LIMIT 1");
$row = mysqli_fetch_assoc($sql);
if (!$row || empty($row['user_password'])) {
return false;
}
return password_verify($current_password, $row['user_password']);
}
/*
* A timestamp a person can read, in the company's configured date and time
* format rather than the raw DATETIME the database hands back.

View File

@@ -232,21 +232,27 @@ if (isset($_POST['set_contact_pin'])) {
* section, so every signed-in contact manages their own - same as the
* password change above.
*/
$pin = trim($_POST['pin'] ?? '');
if ($pin === '') {
flashAlert("Enter a PIN, or leave the page to keep the current one", 'error');
redirect('profile.php');
}
if (strlen($pin) < 4) {
flashAlert("Your PIN needs to be at least 4 characters", 'error');
// The PIN is what we read back to verify a caller, so changing it is
// re-authenticated for password logins. SSO contacts are exempt: they have
// no local password to check, and the identity provider already did this.
if (!portalReauthenticate($_POST['current_password'] ?? '')) {
flashAlert("That password was not right - your PIN has not been changed", 'error');
redirect('profile.php');
}
// contact_pin is varchar(255) - trim to fit rather than let an over-long
// value error out under strict mode
$pin = escapeSql(substr($pin, 0, 255));
// value error out under strict mode.
//
// escapeSql() runs strip_tags() before escaping, so the length has to be
// re-checked AFTER it: a PIN of "<1234>" passed a check on the raw input,
// came out of strip_tags() as an empty string, and the UPDATE below then
// silently WIPED the contact's PIN while flashing "Phone PIN updated".
$pin = escapeSql(substr(trim($_POST['pin'] ?? ''), 0, 255));
if (strlen($pin) < 4) {
flashAlert("Your PIN needs to be at least 4 characters, and cannot contain < or >", 'error');
redirect('profile.php');
}
mysqli_query($mysqli, "UPDATE contacts SET contact_pin = '$pin' WHERE contact_id = $session_contact_id AND contact_client_id = $session_client_id");
@@ -460,10 +466,17 @@ if (isset($_GET['export_statement_pdf'])) {
*/
$client_id = $session_client_id;
$sql = mysqli_query($mysqli, "SELECT client_name FROM clients WHERE client_id = $client_id LIMIT 1");
$sql = mysqli_query($mysqli, "SELECT client_currency_code, client_name FROM clients WHERE client_id = $client_id LIMIT 1");
$row = mysqli_fetch_assoc($sql);
$client_name = escapeHtml($row['client_name']);
// Match client/statement.php, the guest invoice view and the emailed
// statement - all four render in the client's own currency
$statement_currency_code = escapeHtml($row['client_currency_code']);
if (empty($statement_currency_code)) {
$statement_currency_code = $session_company_currency;
}
$sql = mysqli_query($mysqli, "SELECT company_address, company_city, company_country, company_logo, company_name,
company_phone, company_phone_country_code, company_state, company_website, company_zip
FROM companies WHERE company_id = 1");
@@ -581,15 +594,15 @@ if (isset($_GET['export_statement_pdf'])) {
<td style="font-size:9pt;">' . $invoice_scope . '</td>
<td style="font-size:9pt;">' . $invoice_date . '</td>
<td style="font-size:9pt;"' . $due_style . '>' . $invoice_due . '</td>
<td style="font-size:9pt;" align="right">' . numfmt_format_currency($currency_format, $invoice_amount, $session_company_currency) . '</td>
<td style="font-size:9pt;" align="right">' . numfmt_format_currency($currency_format, $amount_paid, $session_company_currency) . '</td>
<td style="font-size:9pt;" align="right">' . numfmt_format_currency($currency_format, $invoice_balance, $session_company_currency) . '</td>
<td style="font-size:9pt;" align="right">' . numfmt_format_currency($currency_format, $invoice_amount, $statement_currency_code) . '</td>
<td style="font-size:9pt;" align="right">' . numfmt_format_currency($currency_format, $amount_paid, $statement_currency_code) . '</td>
<td style="font-size:9pt;" align="right">' . numfmt_format_currency($currency_format, $invoice_balance, $statement_currency_code) . '</td>
</tr>';
}
$html .= '<tr>
<td colspan="6" align="right" style="font-weight:bold;">Total Balance Due</td>
<td align="right" style="font-weight:bold;">' . numfmt_format_currency($currency_format, $statement_total, $session_company_currency) . '</td>
<td align="right" style="font-weight:bold;">' . numfmt_format_currency($currency_format, $statement_total, $statement_currency_code) . '</td>
</tr>
</table>';
@@ -621,6 +634,13 @@ if (isset($_POST['edit_profile'])) {
$new_password = $_POST['new_password'];
// Without this a hijacked session could set a new password without knowing
// the old one, locking the real contact out of their own portal.
if (!empty($new_password) && !portalReauthenticate($_POST['current_password'] ?? '')) {
flashAlert("That password was not right - your password has not been changed", 'error');
redirect('profile.php');
}
if (!empty($new_password)) {
$password_hash = password_hash($new_password, PASSWORD_DEFAULT);
mysqli_query($mysqli, "UPDATE users SET user_password = '$password_hash' WHERE user_id = $session_user_id");
@@ -913,7 +933,7 @@ if (isset($_GET['add_payment_by_provider'])) {
// Notify/log
appNotify("Invoice Paid", "Invoice $invoice_prefix$invoice_number automatically paid", "/agent/invoice.php?invoice_id=$invoice_id", $client_id);
logAudit("Invoice", "Payment", "$session_name initiated Stripe payment amount of " . numfmt_format_currency($currency_format, $invoice_amount, $invoice_currency_code) . " added to invoice $invoice_prefix$invoice_number - $pi_id $extended_log_desc", $client_id, $invoice_id);
logAudit("Invoice", "Payment", "$session_contact_name initiated Stripe payment amount of " . numfmt_format_currency($currency_format, $invoice_amount, $invoice_currency_code) . " added to invoice $invoice_prefix$invoice_number - $pi_id $extended_log_desc", $client_id, $invoice_id);
triggerCustomAction('invoice_pay', $invoice_id);
flashAlert("The amount " . numfmt_format_currency($currency_format, $invoice_amount, $invoice_currency_code) . " paid Invoice $invoice_prefix$invoice_number");
@@ -1374,14 +1394,14 @@ if (isset($_POST['set_recurring_payment'])) {
// Get Payment ID for reference
$recurring_payment_id = mysqli_insert_id($mysqli);
logAudit("Recurring Invoice", "Auto Payment", "$session_name created Auto Pay for Recurring Invoice $recurring_invoice_prefix$recurring_invoice_number in the amount of " . numfmt_format_currency($currency_format, $recurring_invoice_amount, $recurring_invoice_currency_code), $session_client_id, $recurring_invoice_id);
logAudit("Recurring Invoice", "Auto Payment", "$session_contact_name created Auto Pay for Recurring Invoice $recurring_invoice_prefix$recurring_invoice_number in the amount of " . numfmt_format_currency($currency_format, $recurring_invoice_amount, $recurring_invoice_currency_code), $session_client_id, $recurring_invoice_id);
flashAlert("Automatic Payment $saved_payment_description enabled for Recurring Invoice $recurring_invoice_prefix$recurring_invoice_number");
} else {
// Delete
mysqli_query($mysqli, "DELETE FROM recurring_payments WHERE recurring_payment_recurring_invoice_id = $recurring_invoice_id");
logAudit("Recurring Invoice", "Auto Payment", "$session_name removed Auto Pay for Recurring Invoice $recurring_invoice_prefix$recurring_invoice_number in the amount of " . numfmt_format_currency($currency_format, $recurring_invoice_amount, $recurring_invoice_currency_code), $session_client_id, $recurring_invoice_id);
logAudit("Recurring Invoice", "Auto Payment", "$session_contact_name removed Auto Pay for Recurring Invoice $recurring_invoice_prefix$recurring_invoice_number in the amount of " . numfmt_format_currency($currency_format, $recurring_invoice_amount, $recurring_invoice_currency_code), $session_client_id, $recurring_invoice_id);
flashAlert("Automatic Payment Disabled for Recurring Invoice $recurring_invoice_prefix$recurring_invoice_number");
}

View File

@@ -439,6 +439,17 @@ $sql_actions = mysqli_query(
will remember but that is not easy to guess.
</p>
<?php if ($login_method === 'local') { ?>
<div class="mb-3">
<label for="pinCurrentPassword">Your current password</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-lock"></i></span>
<input type="password" class="form-control" id="pinCurrentPassword" name="current_password"
autocomplete="current-password" required>
</div>
</div>
<?php } ?>
<div class="mb-3">
<label for="contactPin"><?= empty($contact_pin) ? 'New PIN' : 'Replace with' ?></label>
<div class="input-group">
@@ -476,6 +487,15 @@ $sql_actions = mysqli_query(
</div>
<div class="modal-body">
<div class="mb-3">
<label for="currentPassword">Your current password</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-lock"></i></span>
<input type="password" class="form-control" id="currentPassword" name="current_password"
autocomplete="current-password" required>
</div>
</div>
<div class="mb-3">
<label for="newPassword">New password</label>
<div class="input-group">

View File

@@ -17,6 +17,18 @@ require_once "includes/inc_all.php";
enforceContactCan('accounting');
/*
* Amounts render in the client's own currency, matching the guest invoice view
* and the statement an agent emails. This page and its PDF used to render in
* the company currency, so the same invoice carried a different symbol
* depending on where you read it.
*/
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT client_currency_code FROM clients WHERE client_id = $session_client_id LIMIT 1"));
$statement_currency_code = escapeHtml($row['client_currency_code']);
if (empty($statement_currency_code)) {
$statement_currency_code = $session_company_currency;
}
/*
* Payments are summed in a derived table rather than joined directly, or an
* invoice with two payments against it would be counted twice.
@@ -119,9 +131,9 @@ $statement_total = 0;
<td><?= $invoice_scope_display ?></td>
<td><?= $invoice_date ?></td>
<td class="<?= $overdue_color ?>"><?= $invoice_due ?></td>
<td class="text-end"><?= numfmt_format_currency($currency_format, $invoice_amount, $session_company_currency) ?></td>
<td class="text-end"><?= numfmt_format_currency($currency_format, $amount_paid, $session_company_currency) ?></td>
<td class="text-end fw-bold"><?= numfmt_format_currency($currency_format, $invoice_balance, $session_company_currency) ?></td>
<td class="text-end"><?= numfmt_format_currency($currency_format, $invoice_amount, $statement_currency_code) ?></td>
<td class="text-end"><?= numfmt_format_currency($currency_format, $amount_paid, $statement_currency_code) ?></td>
<td class="text-end fw-bold"><?= numfmt_format_currency($currency_format, $invoice_balance, $statement_currency_code) ?></td>
</tr>
<?php
@@ -134,7 +146,7 @@ $statement_total = 0;
<tfoot>
<tr>
<th colspan="6" class="text-end">Total Balance Due</th>
<th class="text-end"><?= numfmt_format_currency($currency_format, $statement_total, $session_company_currency) ?></th>
<th class="text-end"><?= numfmt_format_currency($currency_format, $statement_total, $statement_currency_code) ?></th>
</tr>
</tfoot>
</table>