/// <returns></returns> public async Task UpdateOutcomeIfIpShouldBeBlockedAsync( LoginAttempt loginAttempt, IpHistory ip, List<RemoteHost> serversResponsibleForCachingTheAccount, CancellationToken cancellationToken) { // Always allow a login if there's a valid device cookie associate with this account // FUTURE -- we probably want to do something at the account level to track targetted attacks // against individual accounts and lock them out if (loginAttempt.DeviceCookieHadPriorSuccessfulLoginForThisAccount) return; // Choose a block threshold based on whether the provided password was popular or not. // (If the actual password isn't popular, the loginAttempt will be blocked either way.) double blockThreshold = loginAttempt.PasswordsPopularityAmongFailedGuesses >= _options.ThresholdAtWhichAccountsPasswordIsDeemedPopular ? _options.BlockThresholdPopularPassword : _options.BlockThresholdUnpopularPassword; // As we account for successes, we'll want to make sure we never give credit for more than one success // per account. This set tracks the accounts we've already given credit for HashSet<string> accountsUsedForSuccessCredit = new HashSet<string>(); // Start the scoring at zero, with a higher score indicating a greater chance this is a brute-force // attack. (We'll conclude it's a brute force attack if the score goes over the BlockThreshold.) double bruteLikelihoodScore = 0; // This algoirthm estimates the likelihood that the IP is engaged in a brute force attack and should be // blocked by examining login failures from the IP from most-recent to least-recent, adjusting (increasing) the // BruteLikelihoodScore to account for each failure based on its type (e.g., we penalize known // typos less than other login attempts that use popular password guesses). // // Successful logins reduce our estimated likelihood that the IP address. // We also account for successful logins in reverse chronological order, but do so _lazily_: // we only only examine the minimum number of successes needed to take the likelihood score below // the block threshold. We do so because we want there to be a cost to an account of having its // successes used to prevent an IP from being blocked, otherwise attackers could use a few fake // accounts to intersperse lots of login successes between every failure and never be detected. // These counters track how many successes we have stepped through in search of login successes // that can be used to offset login failures when accounting for the likelihood the IP is attacking int successesWithoutCreditsIndex = 0; int successesWithCreditsIndex = 0; List<LoginAttempt> copyOfRecentLoginFailures; List<LoginAttempt> copyOfRecentLoginSuccessesAtMostOnePerAccount; lock (ip.RecentLoginFailures) { copyOfRecentLoginFailures = ip.RecentLoginFailures.MostRecentToOldest.ToList(); copyOfRecentLoginSuccessesAtMostOnePerAccount = ip.RecentLoginSuccessesAtMostOnePerAccount.MostRecentToOldest.ToList(); } // We step through failures in reverse chronological order (from the 0th element of the sequence on up) for (int failureIndex = 0; failureIndex < copyOfRecentLoginFailures.Count && bruteLikelihoodScore <= blockThreshold; failureIndex++) { // Get the failure at the index in the sequence. LoginAttempt failure = copyOfRecentLoginFailures[failureIndex]; // Stop tracking failures that are too old in order to forgive IPs that have tranferred to benign owner if ((DateTimeOffset.Now - failure.TimeOfAttempt) > _options.ExpireFailuresAfter) break; // Increase the brute-force likelihood score based on the type of failure. // (Failures that indicate a greater chance of being a brute-force attacker, such as those // using popular passwords, warrant higher scores.) switch (failure.Outcome) { case AuthenticationOutcome.CredentialsInvalidNoSuchAccount: bruteLikelihoodScore += _options.PenaltyForInvalidAccount * PopularityPenaltyMultiplier(failure.PasswordsPopularityAmongFailedGuesses); break; case AuthenticationOutcome.CredentialsInvalidIncorrectPasswordTypoLikely: bruteLikelihoodScore += _options.PenaltyForInvalidPasswordPerLoginTypo; break; case AuthenticationOutcome.CredentialsInvalidIncorrectPassword: case AuthenticationOutcome.CredentialsInvalidIncorrectPasswordTypoUnlikely: bruteLikelihoodScore += _options.PenaltyForInvalidPasswordPerLoginRarePassword * PopularityPenaltyMultiplier(failure.PasswordsPopularityAmongFailedGuesses); break; case AuthenticationOutcome.CredentialsInvalidRepeatedIncorrectPassword: // We ignore repeats of incorrect passwords we've already accounted for // No penalty break; } if (bruteLikelihoodScore > blockThreshold) { // The most recent failure took us above the threshold at which we would make the decision to block // this login. However, there are successes we have yet to account for that might reduce the likelihood score. // We'll account for successes that are more recent than that last failure until we either // (a) run out of successes, or // (b) reduce the score below the threshold // (in which case we'll save any remaining successes to use if we again go over the threshold.) while (bruteLikelihoodScore > blockThreshold && successesWithCreditsIndex < copyOfRecentLoginSuccessesAtMostOnePerAccount.Count && copyOfRecentLoginSuccessesAtMostOnePerAccount[successesWithCreditsIndex].TimeOfAttempt > failure.TimeOfAttempt) { // Start with successes for which, on a prior calculation of ShouldBlock, we already removed // a credit from the account that logged in via a call to TryGetCredit. LoginAttempt success = copyOfRecentLoginSuccessesAtMostOnePerAccount[successesWithCreditsIndex]; if ( // We have not already used this account to reduce the BruteLikelihoooScore // earlier in this calculation (during this call to ShouldBlock) !accountsUsedForSuccessCredit.Contains(success.UsernameOrAccountId) && // We HAVE received the credit during a prior recalculation // (during a prior call to ShouldBlock) success.HasReceivedCreditForUseToReduceBlockingScore) { // Ensure that we don't count this success more than once accountsUsedForSuccessCredit.Add(success.UsernameOrAccountId); // Reduce the brute-force attack likelihood score to account for this past successful login bruteLikelihoodScore += _options.RewardForCorrectPasswordPerAccount; } successesWithCreditsIndex++; } while (bruteLikelihoodScore > blockThreshold && successesWithoutCreditsIndex < copyOfRecentLoginSuccessesAtMostOnePerAccount.Count && copyOfRecentLoginSuccessesAtMostOnePerAccount[successesWithoutCreditsIndex].TimeOfAttempt > failure.TimeOfAttempt) { // If we still are above the threshold, use successes for which we will need to remove a new credit // from the account responsible for the success via TryGetCredit. LoginAttempt success = copyOfRecentLoginSuccessesAtMostOnePerAccount[successesWithoutCreditsIndex]; if ( // We have not already used this account to reduce the BruteLikelihoodScore // earlier in this calculation (during this call to ShouldBlock) !accountsUsedForSuccessCredit.Contains(success.UsernameOrAccountId) && // We have NOT received the credit during a prior recalculation // (during a prior call to ShouldBlock) !success.HasReceivedCreditForUseToReduceBlockingScore) { // FUTURE -- We may wnat to parallelize to get rid of the latency. However, it may well not be worth // worth the added complexity, since requests for credits should rarely (if ever) occur more than // once per login // Reduce credit from the account for the login so that the account cannot be used to generate // an unlimited number of login successes. if (await _userAccountClient.TryGetCreditAsync(success.UsernameOrAccountId, serversResponsibleForCachingThisAccount: serversResponsibleForCachingTheAccount, cancellationToken: cancellationToken)) { // There exists enough credit left in the account for us to use this success. // Ensure that we don't count this success more than once accountsUsedForSuccessCredit.Add(success.UsernameOrAccountId); // Reduce the brute-force attack likelihood score to account for this past successful login bruteLikelihoodScore += _options.RewardForCorrectPasswordPerAccount; } } successesWithoutCreditsIndex++; } } // The brute-force attack likelihood score should never fall below 0, even after a success credit. if (bruteLikelihoodScore < 0d) bruteLikelihoodScore = 0d; if (bruteLikelihoodScore >= blockThreshold) { if (loginAttempt.Outcome == AuthenticationOutcome.CredentialsValid) { loginAttempt.Outcome = AuthenticationOutcome.CredentialsValidButBlocked; } break; } } }
/// <summary> /// This analysis will examine the client IP's previous failed attempts to login to this account /// to determine if any failed attempts were due to typos. /// </summary> /// <param name="clientsIpHistory">Records of this client's previous attempts to examine.</param> /// <param name="account">The account that the client is currently trying to login to.</param> /// <param name="correctPassword">The correct password for this account. (We can only know it because /// the client must have provided the correct one this loginAttempt.)</param> /// <param name="phase1HashOfCorrectPassword">The phase1 hash of that correct password (which we could /// recalculate from the information in the previous parameters, but doing so would be expensive.)</param> /// <returns></returns> protected void UpdateOutcomesUsingTypoAnalysis( IpHistory clientsIpHistory, UserAccount account, string correctPassword, byte[] phase1HashOfCorrectPassword) { if (clientsIpHistory == null) return; List<LoginAttempt> loginAttemptsWithOutcompesUpdatedDueToTypoAnalysis = account.UpdateLoginAttemptOutcomeUsingTypoAnalysis(correctPassword, phase1HashOfCorrectPassword, _options.MaxEditDistanceConsideredATypo, clientsIpHistory.RecentLoginFailures.MostRecentToOldest.Where( attempt => attempt.UsernameOrAccountId == account.UsernameOrAccountId && attempt.Outcome == AuthenticationOutcome.CredentialsInvalidIncorrectPassword && !string.IsNullOrEmpty(attempt.EncryptedIncorrectPassword)) ); foreach (LoginAttempt updatedLoginAttempt in loginAttemptsWithOutcompesUpdatedDueToTypoAnalysis) { WriteLoginAttemptInBackground(updatedLoginAttempt); } }