Lockout & Captcha

Two building blocks against password guessing and sign-up bots: IFailureLockout locks a key after repeated failures, ICaptchaVerifier checks a captcha token on the server. Both are registered by default and do nothing until you call them (the captcha is off until it has a secret). Pair them with HTTP rate limits on the same endpoints.

Failure Lockout

public interface IFailureLockout
{
    ValueTask<TimeSpan> LockedForAsync(string key, CancellationToken ct = default);
    ValueTask<bool> RecordFailureAsync(string key, int maxFailures, TimeSpan window, TimeSpan lockFor, CancellationToken ct = default);
    ValueTask ClearAsync(string key, CancellationToken ct = default);
}

RecordFailureAsync returns true when this failure reached maxFailures within window and locked the key for lockFor. You choose the keys and the numbers. A sign-in, for example:

public sealed class LoginService(IFailureLockout lockout, IPasswordHasher hasher, Accounts accounts)
{
    static readonly TimeSpan Window = TimeSpan.FromMinutes(15), LockFor = TimeSpan.FromMinutes(15);

    public async Task<Account> LoginAsync(string name, string password, string clientIp)
    {
        var userKey = $"login:{name.ToLowerInvariant()}|{clientIp}";   // this name from this address
        var ipKey = $"login-ip:{clientIp}";                              // every name from this address

        var locked = Max(await lockout.LockedForAsync(userKey), await lockout.LockedForAsync(ipKey));
        if (locked > TimeSpan.Zero)
            throw new HttpApiException(429, "locked", "Too many failed attempts. Try again later.",
                retryAfterSeconds: (int)Math.Ceiling(locked.TotalSeconds));

        var account = await accounts.FindAsync(name);
        if (account is null || !hasher.Verify(password, account.PasswordHash))
        {
            await lockout.RecordFailureAsync(userKey, 5, Window, LockFor);
            await lockout.RecordFailureAsync(ipKey, 20, Window, LockFor);   // credential spraying
            throw new HttpApiException(401, "invalid_credentials", "Invalid username or password.");
        }
        await lockout.ClearAsync(userKey);   // not the address budget: one valid account must not reset it
        return account;
    }
}

Note:

Never lock by user name alone: anyone could keep the owner locked out by failing on purpose. Combine the name with the client address, and add a budget per address.

Shared by the fleet

altruist:
  security:
    lockout:
      store: backplane     # memory (default) | backplane

memory keeps exact sliding failure windows per server. backplane keeps the failure count and the lock in the fleet backplane (Redis), so a lock set on one server holds on every other; the failure window starts at the first failure. Keys are stored hashed. While the backplane is process-local or unreachable, the server counts in memory.

Captcha

altruist:
  security:
    captcha:
      provider: turnstile            # turnstile (default) | hcaptcha | recaptcha
      site-key: "0x4AAAA..."         # public: your client renders the widget with it
      secret-key: ""                 # secret: set it from the environment
[HttpPost("register")]
public async Task<IActionResult> Register(RegisterRequest body, [FromServices] ICaptchaVerifier captcha)
{
    // cheap validation first: the check is a network round trip
    if (captcha.Enabled && !await captcha.VerifyAsync(body.CaptchaToken ?? "", HttpContext.ClientIp()))
        throw new HttpApiException(400, "captcha_failed", "The security check failed. Please try again.", "captcha");
    ...
}

[HttpGet("config"), AllowAnonymous]       // tell the client whether to show the widget
public object Config([FromServices] ICaptchaVerifier captcha) => new { captchaSiteKey = captcha.SiteKey };
  • Off without a secret: Enabled is false, SiteKey is null and VerifyAsync returns true — handy in Development. Refuse to boot in Production yourself if a captcha is required there.
  • Siteverify: posts secret, response and remoteip (when known) and accepts "success": true. Turnstile, hCaptcha and reCAPTCHA share this form; verify-url points at any other compatible endpoint.
  • Fails closed: an unreachable or failing endpoint rejects the token (logged). Empty tokens and tokens over max-token-length (2048) are rejected without a call; timeout-seconds (10) bounds the call.

Keep the secret out of config.yml: ALTRUIST__SECURITY__CAPTCHA__SECRET_KEY, or reference the variable your deploys already set with a placeholder: secret-key: "${MYGAME__TURNSTILE__SECRET_KEY}".