Any application that requires user authentication must take adequate steps to protect the user accounts for which it is responsible. This includes correctly handling workflows such as proper password hashing and storage, providing feedback that doesn't disclose information useful to an attacker, providing means for password reset, etc. The ASP.NET Core Identity membership system provides much of this functionality out-of-the-box, using tried and tested implementations that avoid common mistakes and pitfalls. It is an excellent platform on which to build when developing your application's authentication system.
ASP.NET Core Identity provides a means of mitigating brute force login attempts through user lockout. After a configurable number of failed login attempts, a user's account is locked for a period of time. Both the maximum number of attempts, and the lockout period, are configurable.
While this is certainly a valid strategy, it does have some weaknesses:
- The system can be trivially abused by a malicious party to lock a user out of their account. That is, where the goal of the attacker is not necessarily to gain access to the account, but simply to prevent the user from accessing the account themselves. The classic example is an online auction site, where it could be beneficial to lock a competing bidder out of their account towards the end of an auction, so the attacker has less competition.
- An attacker can cause an effective denial-of-service by locking out multiple accounts.
- It is ineffective against automated attacks where a common password is attempted against multiple accounts.
The strategy below mitigates the risk as follows:
- Login failures against the same account will have exponentially increasing wait times.
- After a specific threshold of failed attempts has been reached against a single user, a CAPTCHA must be solved along with providing the credentials for that user.
- After a specific threshold of failed attempts against all accounts has been exceeded, a CAPTCHA must be solved along with providing the credentials for all users.
Note that this assumes you are already enforcing strong password rules: minimum number of characters, allowing all characters, requiring certain types of characters, checking against known weak or compromised passwords, etc. Note also that code below is certainly not production-ready; it is utilizing poor practices such as hard-coded values (instead of reading from configuration), code duplication, more than one responsibility for a class, etc. The point is to demonstrate the concepts.
Here are the workflows:
GET Request
POST Request
For the demonstration below, I am using a very simple login page, based on the default provided by ASP.NET Core Identity, but the technique used could be adapted to most authentication logic.
Here is the page we are starting with:
Login.cshtml
@page
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@using LoginDemo.Areas.Identity.Pages.Account
@model LoginDemo.Areas.Identity.Pages.Account.LoginModel
@{
ViewData["Title"] = "Log in";
}
<h1>@ViewData["Title"]</h1>
<form id="account" method="post">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
<label asp-for="Input.Email"></label>
<input asp-for="Input.Email" class="form-control" />
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Input.Password"></label>
<input asp-for="Input.Password" class="form-control" />
<span asp-validation-for="Input.Password" class="text-danger"></span>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Log in</button>
</div>
</form>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
Login.cshtml.cs
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
namespace LoginDemo.Areas.Identity.Pages.Account
{
[AllowAnonymous]
public class LoginModel : PageModel
{
private readonly SignInManager<IdentityUser> _signInManager;
private readonly ILogger<LoginModel> _logger;
public LoginModel(SignInManager<IdentityUser> signInManager, ILogger<LoginModel> logger)
{
_signInManager = signInManager;
_logger = logger;
}
[BindProperty]
public InputModel Input { get; set; }
public class InputModel
{
[Required]
[EmailAddress]
public string Email { get; set; }
[Required]
[DataType(DataType.Password)]
public string Password { get; set; }
}
public void OnGet()
{
}
public async Task<IActionResult> OnPostAsync()
{
if (ModelState.IsValid)
{
var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, isPersistent: false, lockoutOnFailure: false);
if (result.Succeeded)
{
_logger.LogInformation("User logged in.");
return RedirectToPage("/Index");
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
}
}
return Page();
}
}
}
One more bit of setup: we need access to a Redis cache instance. (If you do not have a cache available, you can use the Windows Subsystem for Linux to install an instance on Ubuntu.) We then add a reference to the StackExchange.Redis package that provides a clean API for Redis:
dotnet add package StackExchange.Redis
OK, with that out of the way, we're ready to introduce our changes. The first thing to do is to increment our failure counts in the event of a failed login. We increment the counts both for the specific user, as well as for all users. First we add a reference to a shared connection to the cache at the top of Login.cshtml.cs, as well as two new methods to increment the failure counts. For the user failure count, we'll return the current failure count, as we're going to use this as part of our mitigation strategy.
private static Lazy<ConnectionMultiplexer> _cache = new Lazy<ConnectionMultiplexer>(() => ConnectionMultiplexer.Connect("localhost,ssl=false,abortConnect=False"));
private static async Task<long> IncrementUserFailureCount(string userId)
{
string key = $"login-failures:{userId}".ToLowerInvariant();
IDatabase db = _cache.Value.GetDatabase();
return await db.StringIncrementAsync(key);
}
private static async Task IncrementAllFailureCount()
{
string key = "login-failures:all";
IDatabase db = _cache.Value.GetDatabase();
await db.StringIncrementAsync(key);
}
On repeated failed login attempts for the same user, we will exponentially increase the time it takes for our server to respond. In the case of a legitimate user who accidentally fat-fingers a password, the delay will barely be noticeable. But by the time multiple failures have occurred, the delay will slow down malicious users attempting to compromise an account.
In the event of login failures:
public async Task<IActionResult> OnPostAsync()
{
if (ModelState.IsValid)
{
var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, isPersistent: false, lockoutOnFailure: false);
if (result.Succeeded)
{
_logger.LogInformation("User logged in.");
return RedirectToPage("/Index");
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
long userFailures = await IncrementUserFailureCount(Input.Email);
await IncrementAllFailureCount();
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, Math.Min(userFailures, 7) - 1)));
}
}
return Page();
}
This introduces an exponential delay in the following pattern:
Attempt | Delay |
---|---|
1 | 1 seconds |
2 | 3 seconds |
3 | 7 seconds |
4 | 15 seconds |
5 | 31 seconds |
6 | 1 minute 3 seconds |
7 or more | 2 minutes 8 seconds |
After a few failed attempts, this is what we see in Redis:
This is a start, but it doesn't do much for automated attacks, which could simply throw a bunch of simultaneous attempts, or start a new one when any delay is detected. We will attempt to frustrate automated attempts by forcing a CAPTCHA to be solved whenever we detect suspicious activity. There's no reason we couldn't always make the CAPTCHA required—that would certainly be more secure. But for some users, a CAPTCHA can be a real source of frustration, and might detract from the usability of your site. This is something that needs to be determined on a site-by-site basis.
First we introduce a method that detects whether or not we require a CAPTCHA to be solved:
Login.cshtml.cs
private const long UserLoginFailureCaptchaThreshold = 3;
private async Task<bool> IsCaptchaRequiredForUserAsync(string userId)
{
string key = $"login-failures:{userId}".ToLowerInvariant();
IDatabase db = _cache.Value.GetDatabase();
long failures = (long)(await db.StringGetAsync(key));
return failures >= UserLoginFailureCaptchaThreshold;
}
And then before we attempt to validate credentials, verify the CAPTCHA if required:
Login.cshtml.cs
public bool CaptchaRequired { get; set; }
public async Task<IActionResult> OnPostAsync()
{
this.CaptchaRequired = await this.IsCaptchaRequiredForUserAsync(Input.Email);
if (this.CaptchaRequired)
{
await this.ValidateGoogleRecaptchaAsync();
}
if (ModelState.IsValid)
{
var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, isPersistent: false, lockoutOnFailure: false);
if (result.Succeeded)
{
_logger.LogInformation("User logged in.");
return RedirectToPage("/Index");
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
long userFailures = await IncrementUserFailureCount(Input.Email);
await IncrementAllFailureCount();
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, Math.Min(userFailures, 7) - 1)));
if (userFailures >= UserLoginFailureCaptchaThreshold)
{
this.CaptchaRequired = true;
}
}
}
return Page();
}
The ValidateGoogleRecaptchaAsync
method is not explained in detail here, as that is outside our scope. At a high level, it will add errors to the model if a valid Google reCAPTCHA response is not included with the request. For more details, see Integrating Google reCAPTCHA v2 with an ASP.NET Core Razor Pages form. Here is a sample implementation:
private async Task ValidateGoogleRecaptchaAsync()
{
if (!this.Request.Form.ContainsKey("g-recaptcha-response"))
{
this.ModelState.AddModelError(string.Empty, "We noticed some suspicious login activity. Please re-enter your username and password, and complete the \"I'm not a robot\" checkbox.");
return;
}
string recaptchaResponse = this.Request.Form["g-recaptcha-response"];
if (string.IsNullOrEmpty(recaptchaResponse))
{
this.ModelState.AddModelError(string.Empty, "Please complete the \"I'm not a robot\" checkbox.");
return;
}
var parameters = new Dictionary<string, string>
{
{"secret", _configuration["reCAPTCHA:SecretKey"]},
{"response", recaptchaResponse},
{"remoteip", this.HttpContext.Connection.RemoteIpAddress.ToString()}
};
var apiRequest = new HttpRequestMessage(HttpMethod.Post, "https://www.google.com/recaptcha/api/siteverify")
{
Content = new FormUrlEncodedContent(parameters)
};
var client = _httpClientFactory.CreateClient();
try
{
HttpResponseMessage response = await client.SendAsync(apiRequest);
response.EnsureSuccessStatusCode();
string apiResponse = await response.Content.ReadAsStringAsync();
dynamic apiJson = JObject.Parse(apiResponse);
if (apiJson.success != true)
{
this.ModelState.AddModelError(string.Empty, "There was an unexpected problem processing this request. Please try again.");
}
}
catch (HttpRequestException ex)
{
// Something went wrong with the API. Let the request through.
_logger.LogError(ex, "Unexpected error calling reCAPTCHA api.");
}
}
Login.cshtml
@page
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@using LoginDemo.Areas.Identity.Pages.Account
@model LoginDemo.Areas.Identity.Pages.Account.LoginModel
@inject Microsoft.Extensions.Configuration.IConfiguration Configuration
@{
ViewData["Title"] = "Log in";
}
<h1>@ViewData["Title"]</h1>
<form id="account" method="post">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
<label asp-for="Input.Email"></label>
<input asp-for="Input.Email" class="form-control" />
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Input.Password"></label>
<input asp-for="Input.Password" class="form-control" />
<span asp-validation-for="Input.Password" class="text-danger"></span>
</div>
@if (Model.CaptchaRequired)
{
<div class="g-recaptcha" data-sitekey="@Configuration["reCAPTCHA:SiteKey"]"></div>
}
<div class="form-group">
<button type="submit" class="btn btn-primary">Log in</button>
</div>
</form>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
@if (Model.CaptchaRequired)
{
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
}
}
appsettings.json (or secrets file)
We have introduced a bit of a gap, in that the first POST attempt from a legitimate user whose account has already exceeded the threshold is always going to fail (requiring a CAPTCHA), even if the credentials are correct. (The GET request, before knowing the user id, will not display the CAPTCHA). Since this is not a typical workflow, I think that is acceptable; a warning that something suspicious is happening with the account is returned.
To prevent account enumeration, we return the same messages, and introduce the same delays, even if the user id is completely unknown to our system.
So far we have handled an attack against a single user, but what about the situation where an attacker is attempting to compromise multiple accounts. For example, trying the same password against various user ids? Here is a starting implementation; a number of deficiencies will be addressed as we move along:
Login.cshtml.cs
private const long SharedLoginFailureCaptchaThreshold = 10;
private async Task<bool> IsCaptchaRequiredForAllUsers()
{
string key = "login-failures:all";
IDatabase db = _cache.Value.GetDatabase();
long failures = (long)(await db.StringGetAsync(key));
return failures >= SharedLoginFailureCaptchaThreshold;
}
public async Task OnGetAsync()
{
this.CaptchaRequired = await IsCaptchaRequiredForAllUsers();
}
public async Task<IActionResult> OnPostAsync()
{
this.CaptchaRequired = await this.IsCaptchaRequiredForUserAsync(Input.Email) ||
await this.IsCaptchaRequiredForAllUsers();
...
}
We are mitigating the attack, but we are also introducing more friction to legitimate users than we would like. The biggest problem is that there is no "reset" on the failure counters. Once a user reaches the failure threshold, they will always be stuck completing the CAPTCHA. Same for all users; once a certain number of login failures have occurred, every user will have to complete a CAPTCHA, indefinitely.
Let's address the user threshold first. One way to handle this would be to reset the user failure counter upon successful login. In many cases, this might be a perfectly acceptable solution. One risk that it introduces is when a legitimate user is "fighting" with a malicious attacker/bot to gain access to the account at the same time. In this case, the legitimate user, upon successful login, is essentially opening the door for the attack to continue, at least for a few more attempts. A different strategy, but still very simple, is to just have the cache entry be invalidated after a certain amount of time:
private static async Task<long> IncrementUserFailureCount(string userId)
{
string key = $"login-failures:{userId}".ToLowerInvariant();
IDatabase db = _cache.Value.GetDatabase();
ITransaction tx = db.CreateTransaction();
Task<long> increment = tx.StringIncrementAsync(key);
_ = tx.KeyExpireAsync(key, TimeSpan.FromDays(1));
await tx.ExecuteAsync();
return increment.Result;
}
We could handle the situation with login failures across all accounts similarly, but it would be difficult to come up with a threshold and expiration that would make sense. A single user can be expected to eventually remember their password, but most sites will be experiencing login failures across all users with some relative frequency. We want to discern the difference between the frequency pattern of our legitimate users making password errors, versus an automated attack attempting to brute force credentials. This will very much depend on the traffic to your site, but one strategy, used below, is to establish various time-based thresholds. For example, mitigation protection would be activated whenever any of the following are true:
- More than 10 failed attempts in the last 1 minute.
- More than 20 failed attempts in the last 5 minutes.
- More than 60 failed attempts in the last hour.
To accomplish this, we need to complicate our cache structure a bit. Instead of a simple number, we are going to use a Sorted set, storing each login failure as a separate record "scored" by its timestamp. When we look to determine if our thresholds have been reached, we will use ZCOUNT
to quickly count the number of failures in each range. The only thing we then need to do is take care of cleanup--truncating the set with values that are completely outside of our ranges. We do this on every read in the example below. If that is inefficient for your usage, you could trigger the cleanup on a batch process, for example. Finally, we add an expiration to the key as well—in the case of no login failure activity (unlikely, but still...), the key will eventually clear itself out.
Here are the changes in Login.cshtml.cs:
private static readonly List<FailureThreshold> AllUserFailureThresholds = new List<FailureThreshold>
{
new FailureThreshold { FailureCount = 10, Period = TimeSpan.FromMinutes(1) },
new FailureThreshold { FailureCount = 20, Period = TimeSpan.FromMinutes(5) },
new FailureThreshold { FailureCount = 60, Period = TimeSpan.FromMinutes(60) }
};
private class FailureThreshold
{
public int FailureCount { get; set; }
public TimeSpan Period { get; set; }
}
private static async Task IncrementAllFailureCount()
{
TimeSpan cutoff = AllUserFailureThresholds.Last().Period;
string key = "login-failures:all";
IDatabase db = _cache.Value.GetDatabase();
ITransaction tx = db.CreateTransaction();
_ = tx.SortedSetAddAsync(key, Guid.NewGuid().ToString(), Score(DateTime.UtcNow));
_ = tx.KeyExpireAsync(key, cutoff);
_ = tx.SortedSetRemoveRangeByScoreAsync(key, Double.NegativeInfinity, Score(DateTime.UtcNow.Subtract(cutoff)));
await tx.ExecuteAsync();
}
private async Task<bool> IsCaptchaRequiredForAllUsers()
{
string key = "login-failures:all";
IDatabase db = _cache.Value.GetDatabase();
foreach (FailureThreshold threshold in AllUserFailureThresholds)
{
long failures = await db.SortedSetLengthAsync(key, Score(DateTime.UtcNow.Subtract(threshold.Period)));
if (failures >= threshold.FailureCount)
{
return true;
}
}
return false;
}
This provides at least a baseline strategy for detecting and frustrating brute-force login attacks against your users, without overly frustrating legitimate login attempts.
Comments
Post a Comment