Update 2024-06-26: The original code was vulnerable to a replay attack for solved challenges (thanks to Marlin for notifying me about this). The code below (and in GitHub) mitigates this by using the newly introduced expires header in Altcha V0.4 to invalidate challenges every 60 seconds and switching the challenge generation from JSON payload to a request Url. This way replays are only possible in this short time frame. For a complete and corrected implementation example refer to: https://github.com/aduggleby/MinimalIdentityUI.
Sooner or later your public facing ASP.NET Identity form will be targeted by illicit actors and you will start getting sign-ups from [email protected] but they never click the email verification link or use the service. My best guess this is either to warm up spam sending email addresses with legitimate email traffic or to disqualify honeypot email addresses.
Either way adding a CAPTCHA (an automated bot detection mechanism) is often the easiest way to fix the issue as these tools are often used in an automated fashion and so adding an additional hurdle will cause the attacker to spend more time or money to use your service.
Recaptcha is the original solution in the space, but now belongs to the Data Kraken and honestly I’ve spent way too much time in the endless loop of identifying fire trucks, stairs and traffic lights despite being a real human. There are a couple of privacy focussed Captcha services (e.g. hcaptcha) with a generous free plan but I wanted to add a simple self hosted method to MinimalIdentityUI - my styled drop-in replacement for the ASP.NET Identity UI.
With recent advances in image detection I’m not sure the image based Captchas have much of a future unless you have the backing of Google to implement counter measures. There are self hosted .NET Captcha solutions (e.g. BotDetect) but they seem to use a very old approach and I don’t think they’re future proof.
Another approach is the proof-of-work method. Similar to the idea of having to spend a cent for every email you send would stop a lot of spammers, this method make the computer (the client) using your form spend just a moment of time to do some tedious calculation before they submit the form. For the individual it will only cost them a few seconds at most, but for someone trying to use your service in an automated fashion it will take seconds for each attempt.
An additional bonus for proof-of-work bot protection is your user is never in a loop trying to figure out the image puzzle you’re throwing at them. It’s just one click and the computer thinks a bit and produces the answer. Very similar to recaptcha’s current version.
An open source project that implements bot protect this way is Altcha. You just need their provided web component and implement some small calculations on the server. Let’s look at implementing this into ASP.NET Identity.
Adding the Altcha Web Component to your ASP.NET Registration Page
First you want to add the Altcha Javascript script to your page. Either add it globally to your Layout or in the Scripts section of the specific page you’re working on.
https://cdn.jsdelivr.net/gh/altcha-org/altcha@main/dist/altcha.min.js
You can of course download the script from the CDN (or GitHub) and host it yourself.
Next add the Altcha web component inside your registration Form.
<altcha-widget challengeurl="@Url.Page(null, "Altcha")"
hidelogo="true"
hidefooter="true"
></altcha-widget>
Next, we will create the Altcha class that handles the challenge generation.
For a full implementation of the Register page see here.
Add an Altcha Challenge Generator for C#
The Altcha challenge protocol consists of the the server generating a SHA256 salted hash for a specific random number and the client then going through all the numbers trying to generate the hash until it finds the number which is returned to the server. The protocol is completely stateless as (no cookies & no database required) the payload returned from the client contains all necessary information for the server to calculate the hash again and check the client has solved it correctly.
This logic is encapsulated in this simple class (you can also download the file here):
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
public class Altcha
{
// These values need to match to make sure the widget and server are calculating the same hash
private static readonly string ALTCHA_JS_ALG = "SHA-256";
private static readonly string ALTCHA_ALG = "SHA256";
private static readonly string ALTCHA_HMAC_ALG = "HMACSHA256";
private static readonly int[] ALTCHA_NUM_RANGE = { 1000, 100000 };
private static readonly string ALTCHA_HMAC_KEY = "$ecret.key-change-me-2024";
private static (string Challenge, string Signature)
CreateAltcha(string salt, int number)
{
if (string.IsNullOrWhiteSpace(salt) || salt.Length < 10)
{
throw new Exception("Invalid salt value");
}
var challenge = GetHashString(salt + number);
var signature = GetHmacSignature(ALTCHA_HMAC_KEY, challenge);
return (challenge, signature);
}
public static IActionResult CreateChallengeResult(
TimeSpan? expiresIn = null)
{
return new ContentResult()
{
Content = CreateChallengeJson(expiresIn),
ContentType = "application/json"
};
}
public static string CreateChallengeJson()
{
var salt = GenerateRandomHexString(12) +
"?expires=" +
DateTimeOffset.Now.Add(
expiresIn ??
TimeSpan.FromSeconds(60)
).ToUnixTimeSeconds();
var number = GenerateRandomNumber(
ALTCHA_NUM_RANGE[0],
ALTCHA_NUM_RANGE[1]);
var altcha = CreateAltcha(salt, number);
return JsonSerializer.Serialize(new {
algorithm = ALTCHA_JS_ALG,
challenge = altcha.Challenge,
salt,
signature = altcha.Signature }); ;
}
public static bool VerifyChallengeJson(string payload)
{
try
{
var challengeResponse =
JsonSerializer.Deserialize<AltchaPayload>
(Base64Decode(payload));
if (challengeResponse != null)
{
var expires HttpUtility.ParseQueryString(
challengeResponse.Salt.Split('?').Last()
)["expires"];
if (!string.IsNullOrWhiteSpace(expires) &&
long.TryParse(expires, out var ts) &&
DateTimeOffset.FromUnixTimeSeconds(ts)
>= DateTimeOffset.UtcNow)
{
var altcha = CreateAltcha(
challengeResponse.Salt,
challengeResponse.Number);
return
challengeResponse.Algorithm == ALTCHA_JS_ALG &&
challengeResponse.Challenge == altcha.Challenge &&
challengeResponse.Signature == altcha.Signature;
}
else
{
return false;
}
}
}
catch
{
// invalid payload
}
return false;
}
private static string GetHashString(string input)
{
using (var hashAlgorithm = CryptoConfig
.CreateFromName(ALTCHA_ALG) as HashAlgorithm)
{
if (hashAlgorithm == null)
{
throw new Exception("Invalid hash algorithm.");
}
var bytes = Encoding.UTF8.GetBytes(input);
var hashBytes = hashAlgorithm.ComputeHash(bytes);
return BitConverter.ToString(hashBytes)
.Replace("-", "").ToLower();
}
}
private static string GetHmacSignature(string key, string input)
{
using (var hmac = CryptoConfig
.CreateFromName(ALTCHA_HMAC_ALG) as HMAC)
{
if (hmac == null)
{
throw new Exception("Invalid hash algorithm.");
}
hmac.Key = Encoding.UTF8.GetBytes(key);
var bytes = Encoding.UTF8.GetBytes(input);
var hashBytes = hmac.ComputeHash(bytes);
return BitConverter.ToString(hashBytes)
.Replace("-", "").ToLower();
}
}
private static string GenerateRandomHexString(int length)
{
var random = new Random();
var bytes = new byte[length / 2];
random.NextBytes(bytes);
return BitConverter.ToString(bytes).Replace("-", "").ToLower();
}
private static int GenerateRandomNumber(int min, int max)
{
var random = new Random();
return random.Next(min, max + 1);
}
private static string Base64Decode(string input)
{
var bytes = Convert.FromBase64String(input);
return Encoding.UTF8.GetString(bytes);
}
private class AltchaPayload
{
[JsonPropertyName("algorithm")]
public string Algorithm { get; set; }
[JsonPropertyName("challenge")]
public string Challenge { get; set; }
[JsonPropertyName("salt")]
public string Salt { get; set; }
[JsonPropertyName("signature")]
public string Signature { get; set; }
[JsonPropertyName("number")]
public int Number { get; set; }
}
}
The only recommended change is the HMAC secret to a unique value for your service. The other constant values at the top can be change if you’d like to use a different algorithm or make the proof-of-work harder (takes longer).
Next, you just add a property to the code-behind of the Register page and check the payload.
Verifying an Altcha payload
First you want to add a property to the Register.cshtml.cs file:
/// <summary>
/// This captures the solved Altcha challenge
/// </summary>
[BindProperty]
[Required]
[DataType(DataType.Text)]
[Display(Name = "Protection")]
public string Altcha { get; set; }
Add the page handler that creates the challenge JSON to the page the widget is on.
public async Task<IActionResult> OnGetAltcha() =>
global::Altcha.CreateChallengeResult();
Then in your OnPost method right at the beginning you can implement the check.
if (!global::Altcha.VerifyChallengeJson(Altcha))
{
ModelState.AddModelError("Altcha", "Required");
}
As you can see implementing Altcha is very simple and it’s a no-brainer to add to add it to your Register and ForgotPassword forms to disuade abuse from bots.