diff --git a/client/includes/check_login.php b/client/includes/check_login.php index 9d38973f2..719e8122d 100644 --- a/client/includes/check_login.php +++ b/client/includes/check_login.php @@ -42,7 +42,12 @@ if ($portal_view_as) { } // Load user session vars -$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_archived_at, user_auth_method, user_avatar, user_status, user_token, user_type FROM users WHERE users.user_id = $session_user_id")); +$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_archived_at, user_auth_method, user_avatar, user_password, user_status, user_token, user_type FROM users WHERE users.user_id = $session_user_id")); + +$session_stamp_current = userSessionStamp($row['user_password'] ?? ''); +if ($row) { + unset($row['user_password']); +} $session_avatar = $row['user_avatar'] ?? ''; $session_auth_method = (string) ($row['user_auth_method'] ?? ''); @@ -82,6 +87,21 @@ if (!$portal_view_as) { session_destroy(); redirect("/login.php"); } + + // Check the password hasn't changed since this session signed in - by the contact + // elsewhere, an agent, or a reset link (userSessionStamp()) + if (!hash_equals($session_stamp_current, (string) ($_SESSION['user_session_stamp'] ?? ''))) { + session_unset(); + session_destroy(); + redirect("/login.php"); + } + +} elseif (!hash_equals((string) $portal_view_as['agent_session_stamp'], (string) ($_SESSION['user_session_stamp'] ?? ''))) { + + // View as contact runs on the agent's session, so their password changing ends it too + session_unset(); + session_destroy(); + redirect("/login.php"); } // Two-step sign-in required for password accounts (Admin > Client Portal) and diff --git a/client/login_microsoft.php b/client/login_microsoft.php index 6728cd09c..0f5865f58 100644 --- a/client/login_microsoft.php +++ b/client/login_microsoft.php @@ -126,7 +126,7 @@ if (isset($_GET['code']) || isset($_GET['error'])) { $upn = mysqli_real_escape_string($mysqli, $msgraph_response["userPrincipalName"]); - $sql = mysqli_query($mysqli, "SELECT contact_client_id, contact_id, user_auth_method, user_email, user_id FROM users + $sql = mysqli_query($mysqli, "SELECT contact_client_id, contact_id, user_auth_method, user_email, user_id, user_password FROM users LEFT JOIN contacts ON user_id = contact_user_id LEFT JOIN clients ON contact_client_id = client_id WHERE user_email = '$upn' @@ -156,6 +156,8 @@ if (isset($_GET['code']) || isset($_GET['error'])) { $_SESSION['contact_id'] = $contact_id; $_SESSION['csrf_token'] = randomString(32); $_SESSION['login_method'] = "azure"; + // A later password change ends this session (userSessionStamp()) + $_SESSION['user_session_stamp'] = userSessionStamp($row['user_password'] ?? ''); unset($_SESSION['portal_view_as']); // an agent's View as contact in this browser ends here // Logging diff --git a/client/post.php b/client/post.php index d3c37616d..b487e89ef 100644 --- a/client/post.php +++ b/client/post.php @@ -562,9 +562,11 @@ if (isset($_POST['portal_password_change'])) { redirect('profile.php#security'); } - $hash = escapeSql(password_hash($new_password, PASSWORD_DEFAULT)); - mysqli_query($mysqli, "UPDATE users SET user_password = '$hash' WHERE user_id = $session_user_id AND user_type = 2"); + $new_hash = password_hash($new_password, PASSWORD_DEFAULT); + mysqli_query($mysqli, "UPDATE users SET user_password = '" . escapeSql($new_hash) . "' WHERE user_id = $session_user_id AND user_type = 2"); portalPasswordLinkClear($session_user_id); + // This browser stays signed in, with a new session id - every other session of theirs ends + userSessionRestamp($session_user_id, $new_hash); logAudit("Contact", "Edit", "Client contact $session_contact_name changed their password in the client portal", $session_client_id, $session_contact_id); portalMailAccountChange(stripslashes($session_contact_email), stripslashes($session_contact_name), 'password'); diff --git a/functions/portal.php b/functions/portal.php index e7e4b8760..8b93b8dea 100644 --- a/functions/portal.php +++ b/functions/portal.php @@ -343,7 +343,7 @@ function portalViewAsAccess($agent_user_id, $contact_id) { if (intval($config_client_portal_enable) !== 1 || !$agent_user_id || !$contact_id) { return null; } - $agent = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_name, user_type, user_status, user_archived_at, user_role_id, role_is_admin + $agent = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_name, user_password, user_type, user_status, user_archived_at, user_role_id, role_is_admin FROM users LEFT JOIN user_roles ON role_id = user_role_id WHERE user_id = $agent_user_id LIMIT 1")); if (!$agent || intval($agent['user_type']) !== 1 || intval($agent['user_status']) !== 1 || $agent['user_archived_at'] !== null) { return null; @@ -391,6 +391,7 @@ function portalViewAsAccess($agent_user_id, $contact_id) { 'user_id' => intval($contact['contact_user_id']), 'agent_user_id' => $agent_user_id, 'agent_name' => (string) $agent['user_name'], + 'agent_session_stamp' => userSessionStamp($agent['user_password'] ?? ''), // client/includes/check_login.php 'billing' => $level('module_sales') >= 1 && $level('module_financial') >= 1, ]; } diff --git a/functions/user.php b/functions/user.php index e17c799fa..64dbe7a03 100644 --- a/functions/user.php +++ b/functions/user.php @@ -202,9 +202,34 @@ function userPasswordProblem($password) { */ function userSetPassword($user_id, $password) { global $mysqli; - $hash = escapeSql(password_hash((string) $password, PASSWORD_DEFAULT)); + $hash = password_hash((string) $password, PASSWORD_DEFAULT); $ciphertext = escapeSql(encryptUserSpecificKey((string) $password)); - mysqli_query($mysqli, "UPDATE users SET user_password = '$hash', user_specific_encryption_ciphertext = '$ciphertext' WHERE user_id = " . intval($user_id)); + mysqli_query($mysqli, "UPDATE users SET user_password = '" . escapeSql($hash) . "', user_specific_encryption_ciphertext = '$ciphertext' WHERE user_id = " . intval($user_id)); + userSessionRestamp($user_id, $hash); +} + +/** + * What a session keeps of the password it was signed in with - a digest of the hash, + * never the hash. A new password makes every session signed in before it stop + * matching, and the next page each one loads signs it out: includes/load_user_session.php + * for agents, client/includes/check_login.php for the portal. That covers every way a + * password is set - by its owner, by an admin, the incident-response reset, a portal + * reset link - with nothing to call, the same way disabling a user already ends their + * sessions. + */ +function userSessionStamp($password_hash) { + return hash('sha256', 'itflow-session|' . (string) $password_hash); +} + +/** + * Keep this browser signed in when the password just changed is its own user's - a new + * session id, and the stamp of the new hash. Every other session of theirs still ends. + */ +function userSessionRestamp($user_id, $password_hash) { + if (session_status() === PHP_SESSION_ACTIVE && intval($_SESSION['user_id'] ?? 0) === intval($user_id) && !headers_sent()) { + session_regenerate_id(true); + $_SESSION['user_session_stamp'] = userSessionStamp($password_hash); + } } /** Turn an agent's MFA off - and forget the browsers it trusted, which have to sign in afresh. */ diff --git a/includes/load_user_session.php b/includes/load_user_session.php index 569cebdba..dfb8cdd44 100644 --- a/includes/load_user_session.php +++ b/includes/load_user_session.php @@ -8,7 +8,7 @@ $sql = mysqli_query( $mysqli, "SELECT role_is_admin, role_name, user_archived_at, user_avatar, user_config_force_mfa, user_config_records_per_page, user_config_theme_dark, user_email, user_name, user_role_id, - user_status, user_token, user_type FROM users + user_password, user_status, user_token, user_type FROM users LEFT JOIN user_settings ON users.user_id = user_settings.user_id LEFT JOIN user_roles ON user_role_id = role_id WHERE users.user_id = $session_user_id" @@ -16,6 +16,11 @@ $sql = mysqli_query( $row = mysqli_fetch_assoc($sql); +$session_stamp_current = userSessionStamp($row['user_password'] ?? ''); +if ($row) { + unset($row['user_password']); +} + $session_name = escapeSql($row['user_name']); $session_email = $row['user_email']; $session_avatar = $row['user_avatar']; @@ -51,6 +56,14 @@ if ($session_user_archived_at !== null) { redirect("/login.php"); } +// Check the password hasn't changed since this session signed in - by them elsewhere, +// an admin, or the incident-response reset (userSessionStamp()) +if (!hash_equals($session_stamp_current, (string) ($_SESSION['user_session_stamp'] ?? ''))) { + session_unset(); + session_destroy(); + redirect("/login.php"); +} + // Load user client permissions (allow + deny lists) $user_client_access_sql = "SELECT client_id, permission_type FROM user_client_permissions WHERE user_id = $session_user_id"; $user_client_access_result = mysqli_query($mysqli, $user_client_access_sql); diff --git a/login.php b/login.php index 4f65eb91e..048ad7f14 100644 --- a/login.php +++ b/login.php @@ -471,6 +471,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['login']) || isset($_ $_SESSION['user_id'] = $user_id; $_SESSION['csrf_token'] = randomString(32); $_SESSION['logged'] = true; + // A later password change ends this session (userSessionStamp()) + $_SESSION['user_session_stamp'] = userSessionStamp($selectedRow['user_password'] ?? ''); if ($force_mfa == 1 && $token == NULL) { $config_start_page = "user/mfa_enforcement.php"; @@ -710,6 +712,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['login']) || isset($_ $_SESSION['user_type'] = 2; $_SESSION['contact_id'] = $contact_id; $_SESSION['login_method'] = "local"; + // A later password change ends this session (userSessionStamp()) + $_SESSION['user_session_stamp'] = userSessionStamp($selectedRow['user_password'] ?? ''); // Keep consistent with agent flow (helps any shared session checks) $_SESSION['logged'] = true;