diff --git a/admin/database_updates/2.4.8.php b/admin/database_updates/2.4.8.php new file mode 100644 index 00000000..ede7c0a5 --- /dev/null +++ b/admin/database_updates/2.4.8.php @@ -0,0 +1,16 @@ + (NOW() - INTERVAL 10 MINUTE)" )); $failed_login_count = intval($row['failed_login_count']); @@ -337,6 +337,26 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['login']) || isset($_ $current_code = intval($_POST['current_code']); } + // Per-account limit on second factor attempts. The IP lockout at the + // top of this file can be spread across hosts, and anything reaching + // this point has already cleared the password, so the second factor + // needs a limit tied to the account rather than the source address. + // Only a request that passed the password check can add to this + // counter, so it cannot be used to lock another user out. + $mfa_locked = false; + if (!empty($current_code)) { + $row_mfa = mysqli_fetch_assoc(mysqli_query( + $mysqli, + "SELECT COUNT(log_id) AS failed_mfa_count + FROM logs + WHERE log_user_id = $user_id + AND log_type = 'Login' + AND log_action = 'MFA Failed' + AND log_created_at > (NOW() - INTERVAL 10 MINUTE)" + )); + $mfa_locked = intval($row_mfa['failed_mfa_count']) >= 5; + } + $mfa_is_complete = false; $extended_log = ''; @@ -362,7 +382,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['login']) || isset($_ } // Validate MFA code - if (!empty($current_code) && TokenAuth6238::verify($token, $current_code)) { + if (!$mfa_locked && !empty($current_code) && TokenAuth6238::verify($token, $current_code, 1)) { $mfa_is_complete = true; $extended_log = 'with MFA'; } @@ -494,7 +514,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['login']) || isset($_ 'agent_user_id' => $user_id, 'agent_master_key' => $agent_master_key, // may be null 'token' => $pending_mfa_token, - 'created' => time() + // Keep the original issue time on a retry so the pending + // window actually expires, instead of being pushed forward + // by every wrong code. + 'created' => ($is_mfa_step && !empty($pending_mfa['created'])) + ? intval($pending_mfa['created']) + : time() ]; // Now that we've transferred what we need, it's safe to clear the dual-role pending session. @@ -512,7 +537,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['login']) || isset($_ "; - if ($current_code !== 0) { + if ($mfa_locked) { + + // No log row and no email while locked out. Both are + // per-request writes the attacker controls, and logging here + // would keep pushing the 10 minute window forward so the + // lockout could never clear. The MFA Failed rows that + // triggered it are already the audit trail. + header("HTTP/1.1 429 Too Many Requests"); + + $response = " +