* Settings > Security (config_login_alert_failures, 0 for never). login.php, * client/login_microsoft.php and api/v1/validate_api_key.php write it; Admin > * Sign-ins, the audit log and Your account > Activity read it. */ /** How long a run of failures is counted over, and how many from one IP address login.php turns away after. */ const SIGN_IN_WINDOW_MINUTES = 10; const SIGN_IN_LOCKOUT = 15; /** The kinds of audit log entry that are signing in, and the actions that are a failed one. */ const SIGN_IN_TYPES = ['Login', 'Client Login']; const SIGN_IN_FAILED = ['Failed', 'MFA Failed']; /** The choices for when admins are told - failures in SIGN_IN_WINDOW_MINUTES => label. */ function signInAlertOptions() { return [0 => 'Never', 5 => 'After 5 failed sign-ins', 10 => 'After 10 failed sign-ins', 20 => 'After 20 failed sign-ins', 50 => 'After 50 failed sign-ins']; } /** How many failed sign-ins in a row tell the admins - 0 for never. Read once a request; 0 before the setting exists. */ function signInAlertLimit() { global $mysqli; static $limit = null; if ($limit === null) { try { $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT config_login_alert_failures FROM settings WHERE company_id = 1")); $limit = max(0, intval($row[0] ?? 0)); } catch (Throwable $e) { $limit = 0; } } return $limit; } /* * Why a password didn't get someone in as $email (raw) - the account it * belongs to, if any: ['user_id', 'client_id', 'name', 'type' (Login for an * agent or no account, Client Login for a portal contact), 'reason']. An * agent's account comes before a portal one with the same email. */ function signInFailureReason($email) { global $mysqli; $email_sql = escapeSql(trim((string) $email)); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT users.user_id, user_name, user_type, user_status, user_archived_at, contact_client_id, client_archived_at FROM users LEFT JOIN contacts ON contact_user_id = users.user_id LEFT JOIN clients ON client_id = contact_client_id WHERE user_email = '$email_sql' ORDER BY user_type ASC, users.user_id ASC LIMIT 1")); if (!$row) { return ['user_id' => 0, 'client_id' => 0, 'name' => '', 'type' => 'Login', 'reason' => 'no account uses that email']; } $agent = intval($row['user_type']) === 1; if ($row['user_archived_at'] !== null) { $reason = 'the account is archived'; } elseif (intval($row['user_status']) === 2) { $reason = "they haven't set a password yet"; } elseif (intval($row['user_status']) !== 1) { $reason = $agent ? 'the account is disabled' : 'their portal access is turned off'; } elseif (!$agent && $row['client_archived_at'] !== null) { $reason = 'their client is archived'; } else { $reason = 'wrong password'; } return [ 'user_id' => intval($row['user_id']), 'client_id' => $agent ? 0 : intval($row['contact_client_id']), 'name' => (string) $row['user_name'], 'type' => $agent ? 'Login' : 'Client Login', 'reason' => $reason, ]; } /* * A sign in event in the audit log, as $user_id - whose account it was, even * when nobody signed in - from this request's IP address and browser. * $description is SQL-safe. */ function signInLog($type, $action, $description, $user_id = 0, $client_id = 0) { global $session_user_id; $was = $session_user_id ?? 0; $session_user_id = intval($user_id); logAudit($type, $action, $description, $client_id, $user_id); $session_user_id = $was; } /* A failed sign in, logged with why, then watched (signInWatch()). */ function signInLogFailure($type, $action, $description, $user_id = 0, $client_id = 0) { signInLog($type, $action, $description, $user_id, $client_id); signInWatch($user_id); } /* * After a failed sign in: when this IP address, or the account, has just * reached the admins' number of failures in SIGN_IN_WINDOW_MINUTES, tell them * (signInAlertAdmins()). Only on reaching it, so one run is one alert. */ function signInWatch($user_id = 0) { global $mysqli, $session_ip; $limit = signInAlertLimit(); if ($limit <= 0) { return; } $ip = escapeSql((string) $session_ip); $window = "log_type IN ('" . implode("', '", SIGN_IN_TYPES) . "') AND log_action IN ('" . implode("', '", SIGN_IN_FAILED) . "') AND log_created_at > NOW() - INTERVAL " . SIGN_IN_WINDOW_MINUTES . " MINUTE"; if ($ip !== '' && intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM logs WHERE $window AND log_ip = '$ip'"))[0]) === $limit) { $accounts = signInAccountsTried("$window AND log_ip = '$ip'"); signInAlertAdmins( "$limit failed sign-ins from $session_ip", "$limit failed sign-ins in " . SIGN_IN_WINDOW_MINUTES . " minutes from $session_ip, " . $accounts . '.', ['IP address' => (string) $session_ip, 'Accounts tried' => ucfirst($accounts)], '/admin/sign_ins.php?view=ips&q=' . rawurlencode((string) $session_ip) ); } $user_id = intval($user_id); if ($user_id && intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM logs WHERE $window AND log_user_id = $user_id"))[0]) === $limit) { $user = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_name, user_email FROM users WHERE user_id = $user_id")); $ips = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(DISTINCT log_ip) FROM logs WHERE $window AND log_user_id = $user_id"))[0]); $name = (string) ($user['user_name'] ?? 'an account'); signInAlertAdmins( "$limit failed sign-ins for $name", "$limit failed sign-ins in " . SIGN_IN_WINDOW_MINUTES . " minutes for $name's account" . ($user ? ' (' . $user['user_email'] . ')' : '') . ", from " . ($ips === 1 ? 'one IP address' : "$ips IP addresses") . '.', ['Account' => $name . ($user ? ' - ' . $user['user_email'] : ''), 'IP addresses' => (string) $ips], '/admin/audit_logs.php?view=signins&user=' . $user_id ); } } /* The accounts a run of failures tried, in words - "trying Tess Tech, Johnny Test and 2 addresses with no account". Raw. */ function signInAccountsTried($where) { global $mysqli; $names = []; $sql = mysqli_query($mysqli, "SELECT DISTINCT user_name FROM logs JOIN users ON user_id = log_user_id WHERE $where AND log_user_id > 0 ORDER BY user_name LIMIT 6"); while ($row = mysqli_fetch_row($sql)) { $names[] = $row[0]; } $unknown = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(DISTINCT log_description) FROM logs WHERE $where AND log_user_id = 0"))[0]); if ($unknown) { $names[] = $unknown === 1 ? 'an email with no account' : "$unknown emails with no account"; } if (!$names) { return 'trying no account in particular'; } $last = array_pop($names); return 'trying ' . ($names ? implode(', ', $names) . ' and ' : '') . $last; } /* The active admins - [user_id => ['name', 'email']]. */ function signInAdmins() { global $mysqli; $admins = []; $sql = mysqli_query($mysqli, "SELECT users.user_id, user_name, user_email FROM users JOIN user_roles ON role_id = user_role_id AND role_is_admin = 1 WHERE user_type = 1 AND user_status = 1 AND user_archived_at IS NULL"); while ($row = mysqli_fetch_assoc($sql)) { $admins[intval($row['user_id'])] = ['name' => $row['user_name'], 'email' => $row['user_email']]; } return $admins; } /* * Tell the admins about a run of failures - a notification each, an email * each, and an entry in the audit log (Login Alert, from the address it is * about). Raw values in; $url is where to look. */ function signInAlertAdmins($title, $text, array $summary, $url) { global $mysqli; $type = escapeSql('Failed Sign-ins'); $notice = escapeSql(mb_substr($text, 0, 1000)); $action = escapeSql($url); foreach (signInAdmins() as $admin_id => $admin) { mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = '$type', notification = '$notice', notification_action = '$action', notification_user_id = $admin_id"); signInMailAlert($admin['name'], $admin['email'], $title, $text, $summary, $url); } signInLog('Login', 'Alert', escapeSql("Admins were told: $text")); } /* The email to an admin about a run of failed sign-ins. Raw values in. */ function signInMailAlert($name, $email, $title, $text, array $summary, $url) { global $config_base_url; $app = userMailAppName(); emailQueue([['email' => $email, 'name' => $name, 'footnote' => "You get this as an $app administrator. Admin > Settings > Security says when."]], "$app: $title", [ 'kind' => 'Security', 'tone' => 'danger', 'preview' => $text, 'title' => 'Failed sign-ins', 'intro' => [$text], 'summary' => ['When' => date('D j M Y, g:i A')] + $summary, 'callout' => "$app turns an IP address away after " . SIGN_IN_LOCKOUT . " failed sign-ins in " . SIGN_IN_WINDOW_MINUTES . " minutes. If an account is being guessed at, make sure it has a strong password and multi-factor authentication.", 'button' => ['label' => 'See the sign-ins', 'url' => "https://$config_base_url$url"], ]); } /* * The successful sign ins among $rows (audit log rows with log_id, log_type, * log_action, log_user_id, log_ip) that were the first from their IP address * for that account - [log_id => true]. Measured against the log ITFlow still * keeps. */ function signInNewIpIds(array $rows) { global $mysqli; $ids = []; foreach ($rows as $row) { if (in_array($row['log_type'], SIGN_IN_TYPES, true) && $row['log_action'] === 'Success' && intval($row['log_user_id']) > 0 && trim((string) $row['log_ip']) !== '') { $ids[] = intval($row['log_id']); } } if (!$ids) { return []; } $new = []; $sql = mysqli_query($mysqli, "SELECT l.log_id FROM logs l WHERE l.log_id IN (" . implode(',', $ids) . ") AND NOT EXISTS (SELECT 1 FROM logs p WHERE p.log_user_id = l.log_user_id AND p.log_type = l.log_type AND p.log_action = 'Success' AND p.log_ip = l.log_ip AND p.log_id < l.log_id)"); while ($row = mysqli_fetch_row($sql)) { $new[intval($row[0])] = true; } return $new; } /** The New IP badge on a sign in that was the first from its address. */ function signInNewIpBadge() { return 'New IP'; }