Create a custom captcha provider with ASP.NET Core

Create a custom captcha provider with ASP.NET Core

Create a custom captcha provider with ASP.NET Core

Nowadays, you can't actually run a website on the Internet without having a protection mechanism against content bots. One possibility is the use of CAPTCHAs. CAPTCHAs are small tasks that a human can easily solve, but a bot (almost) cannot.

For years I have used reCAPTCHA from Google; both in my own pages and in the pages of customer portals - it is simply by far the best tool for this purpose today. But Google has decided to significantly inflate reCAPTCHA, add unnecessary features and integrate it into the Google Cloud Platform - plus a pricing model that is not really sustainable for many small sites.

reCAPTCHA alternatives

In principle, alternatives are a dime a dozen - but there are no really good alternatives. So I tried switching to Cloudflare's Turnstile for 4 weeks and was "rewarded" with myCSharp.de being flooded with over 4000 new bot users in a few days. At least in this case, I have not had a good experience.

Looking at other solutions, especially those from Europe, I often thought to myself: and they all charge money for that? Wow!

My own solution

First and foremost, I need a solution that is simple but also effectively excludes bots. I decided to develop my own solution based on .NET and ASP.NET Core. The solution is simple but effective: a simple math problem that a human can solve in a few seconds, but a bot cannot without a custom implementation.

The basic idea of every captcha from all providers is:

  • The user visits a page
  • A captcha session is created in the background, optionally with certain parameters
  • When a form is submitted, the captcha session is checked (e.g. the origin, the task)

A positive side effect: no more external sites such as Google or Cloudflare.

Limitations

I am aware that this is a very simple solution and that if someone attacks this captcha principle, it can certainly be circumvented. However, as I said, it is also intended more for small sites with individual visitors; specific attacks will always be difficult to block.

I have already enriched my productive solution with additional features, but I don't want to reveal them. However, the basic principle of this idea remains the same.

The implementation

In order to be independent of external providers as quickly as possible, I decided to create a solution that is perfectly tailored to my needs - the code is correspondingly simple.

First of all, I need a captcha session that is created when the page is visited. This session contains the two components of a calculation task, the calculation operation as well as a session ID and the IP of the user. In addition, I have added an 'Action Name' to use the captcha session for different purposes and to know which of my page functions the captcha should check.

public sealed record class UserCaptcha(Guid Id, int Number1, int Number2, 
    bool IsPlus, string? IPAddress, string ActionName);

I store the sessions themselves in a MemoryCache; for my first purpose I do not need persistence or external implementation for multi-instance pages. After 2 minutes each entry is automatically deleted.

public class InMemoryCaptchaProvider : ICaptchaProvider
{
    private readonly MemoryCache _cache = new(new MemoryCacheOptions{SizeLimit = 1000});
    private static readonly TimeSpan s_cacheTime = TimeSpan.FromMinutes(2);

A Captcha instance is created by simply calling the CreateCaptcha method. This creates the Captcha ID and the calculation task and saves them in the cache together with the user information from the HttpContext.

public UserCaptcha CreateCaptcha(HttpContext httpContext, string actionName)
{
    // context
    Guid id = Guid.NewGuid();
    string? ipAddress = httpContext.Connection.RemoteIpAddress?.ToString();

    // create captcha
    int number1 = Random.Shared.Next(35, 65);
    int number2 = Random.Shared.Next(5, 30);
    bool isPlus = Random.Shared.Next(0, 2) == 0;

    UserCaptcha captcha = new(id, number1, number2, isPlus, ipAddress, actionName);

    // store in cache - id as string for easier access
    using (ICacheEntry cacheEntry = _cache.CreateEntry(id.ToString()))
    {
        cacheEntry.SetValue(captcha);
        cacheEntry.SetSize(1);
        cacheEntry.SetAbsoluteExpiration(s_cacheTime);
    }

    // return
    return captcha;
}

Validation is carried out using the ValidateCaptcha method. This checks whether the captcha ID is available in the cache and whether the math problem has been solved correctly.

public bool ValidateCaptcha(HttpContext httpContext, string id, int answer, string actionName)
{
    // context
    string? ipAddress = httpContext.Connection.RemoteIpAddress?.ToString();

    // get captcha by id
    if (_cache.TryGetValue(id, out UserCaptcha? captcha) is false)
    {
        return false;
    }

    // remove entry
    _cache.Remove(id);

    // verify
    if (captcha is null)
    {
        return false;
    }

    // pre check
    if (captcha.IPAddress != ipAddress || captcha.ActionName != actionName)
    {
        return false;
    }

    // answer check
    if (captcha.IsPlus)
    {
        return captcha.Number1 + captcha.Number2 == answer;
    }
    else
    {
        return captcha.Number1 - captcha.Number2 == answer;
    }
}

To create the image I use - since I fulfill the license conditions in this case - SixLabors.ImageSharp. For other purposes I switch to SkiaSharp.

private static FontFamily CreateFontFamily()
{
    FontCollection collection = new();
    FontFamily family = collection.Add(PortalResources.Fonts.InterVariable);

    return family;
}

private static readonly Color s_textColor = Color.FromRgb(57, 62, 66);
private static readonly FontFamily s_fontFamily = CreateFontFamily();
private static readonly Font s_font = s_fontFamily.CreateFont(28, FontStyle.Bold);

public async Task<string> CreateBase64WebPImage(UserCaptcha userCaptcha, CancellationToken ct)
{
    string text;
    if (userCaptcha.IsPlus)
    {
        text = $"{userCaptcha.Number1} + {userCaptcha.Number2} = ?";
    }
    else
    {
        text = $"{userCaptcha.Number1} - {userCaptcha.Number2} = ?";
    }

    const int width = 200;
    const int height = 60;

    using Image<Rgba32> image = new(width, height);

    // set brackground as white
    image.Mutate(ctx => ctx.Fill(Color.White));

    // set options for RTO
    RichTextOptions textOptions = new(s_font)
    {
        HorizontalAlignment = HorizontalAlignment.Center,
        VerticalAlignment = VerticalAlignment.Center,
        Origin = new PointF(width / 2, height / 2)
    };

    // draw text
    image.Mutate(ctx => ctx.DrawText(textOptions, text, s_textColor));

    // write to stream  
    await using MemoryStream memoryStream = new();
    await image.SaveAsWebpAsync(memoryStream, ct).ConfigureAwait(false);

    // return
    byte[] bytes = memoryStream.ToArray();
    return Convert.ToBase64String(bytes);
}

Boom: my captcha provider is ready.

Integration in ASP.NET Core

Every form on my site is generated by a GET request; the captcha session is also created in this process.

@model LoginViewModel
@inject ICaptchaProvider CaptchaProvider
@{
    UserCaptcha captcha = CaptchaProvider.CreateCaptcha(this.Context, ActionName: "Login");
    string captchaImage = await CaptchaProvider.CreateBase64WebPImage(captcha, Context.RequestAborted);
}

@* Bot Check *@
<div class="fv-row mb-10 fv-plugins-icon-container">
    <div class="d-flex flex-stack mb-2">
        <label class="form-label fw-bold text-dark fs-6 mb-0">Bot-Check</label>
        <img src="data:image/webp;base64,@(captchaImage)" />
    </div>
    <input type="hidden" name="@(nameof(LoginSubmitModel.VerifyToken))" value="@(captcha.Id)" />
    <input class="form-control form-control-lg form-control-solid"
            type="text" name="@(nameof(LoginSubmitModel.BotCheck))" required />
</div>

The captcha session and the WebP image are generated within the Razor view and integrated into the HTML code. Upon submission, the Captcha ID (VerifyToken) and the solution to the math problem entered by the user automatically end up in a request model:

public class LoginSubmitModel : BaseSubmitModel
{
    public UserName Username { get; set; } = null!;

    // more properties here

    public string VerifyToken { get; set; } = null!;
    public int BotCheck { get; set; }
}

In ASP.NET Core, validation can be done in two different places: you can create an action filter that automatically validates the Captcha session or you can integrate a few lines of code into your action to have a little more control over potential error messages; which is why I opted for the second solution.

[HttpPost, Route(RouteTemplates.Login)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(
    [FromServices] ICaptchaProvider recaptchaProvider,
    LoginSubmitModel sm)
{
    LoginViewModel vm = new(sm);
    if (ModelState.IsNotValid(out string[]? errors))
    {
        return View(LoginViews.Login, vm.WithErrors(errors));
    }

    // bot check
    bool botCheck = recaptchaProvider.ValidateCaptcha(HttpContext, sm.VerifyToken, sm.BotCheck, "Login");
    if (botCheck is false)
    {
        return View(LoginViews.Login, vm.WithErrors("Bot-Validation failed.""));
    }

When the submitted form is accepted, the provider is first injected via dependency injection. This is followed by the standard validation of all necessary parameters of the request model via the model state. Finally, the captcha is validated.

Your own captcha implementation is ready.

Fazit

Implementing your own captcha provider is not difficult and can be done within 1-2 hours if you know what to do. It is certainly not the perfect enterprise solution with any scoring models like Google does with its now overloaded reCAPTCHA solution, but it is an effective solution for small to medium-sized websites.

In the end, this solution does not have to be a math problem; for a restaurant, for example, you can also come up with funny or individual captcha texts, which is not possible with standard implementations.