Skip to content
bitzorcas
中EN

Guide

RiskControl captcha providers and one-time tickets

Understand ImageCode, Slider, PointSelect, and Behavior providers, resolver fallback, session binding, replay protection, and current wiring gaps.

Last updated

A captcha is a two-stage protocol. The server issues a challenge with a one-time ticket; the client renders it and submits an answer; the provider atomically consumes the ticket and verifies the answer. Replay protection does not automatically prevent a stolen ticket from another session.

1. Contract and trust boundary

CaptchaContext carries TenantId, SessionId, optional UserId, and IP during generation. CaptchaChallenge returns ChallengeId, type, render data, and expiry. Verification receives only ChallengeId and UserAnswer.

One-time storeProviderLogin or WebsiteClientOne-time storeProviderLogin or WebsiteClientrequest challengeGenerate(context)SET captcha:tenant:session:nonce, TTL 5mid and render dataid and answervalidate tenant/session/scenarioVerify(id, answer)GETDEL idResult and Passed

Because Verify has no current context, the provider cannot prove ticket ownership. The consumer must check a server-derived prefix, or the contract must be extended to carry binding facts.

2. Provider resolver

CaptchaProviderResolver builds a dictionary once:

  • the first registration for a type wins;
  • an ImageCode provider is mandatory;
  • an unregistered type silently falls back to ImageCode;
  • ImageCode, Slider, and PointSelect are registered in-house;
  • Behavior is registered only when Provider configuration is non-empty.

Silent fallback supports progressive enhancement but can hide a broken Behavior configuration. A strict endpoint should require an exact provider or verify the returned challenge type.

3. ImageCode

SvgImageCaptchaProvider creates a five-character code excluding ambiguous characters and renders noisy SVG. The ticket stores salted SHA-256 of the uppercase answer. Verification uses a fixed-time comparison.

Generate and verify a bound image challenge
var context = new CaptchaContext(
TenantId: currentTenant.Tenant.EffectiveTenantId,
SessionId: session.Id,
UserId: currentUser.UserId?.ToString(),
IpAddress: requestIp);
var challenge = await resolver.Resolve(CaptchaType.ImageCode)
.GenerateAsync(context, cancellationToken);
// Validate binding before GETDEL so a foreign request cannot consume the legitimate ticket.
var expectedPrefix = $"captcha:{context.TenantId}:{context.SessionId}:";
if (!request.ChallengeId.StartsWith(expectedPrefix, StringComparison.Ordinal))
return Result.Failure(CaptchaErrors.ContextMismatch);
var verification = await resolver.Resolve(CaptchaType.ImageCode).VerifyAsync(
new CaptchaVerifyRequest(request.ChallengeId, request.Answer),
cancellationToken);
// A wrong answer is Success(Passed=false); both result layers matter.
if (verification.IsFailure || !verification.GetValueOrThrow().Passed)
return Result.Failure(CaptchaErrors.Rejected);

4. Slider

Slider returns a 300×150 background and a 44-pixel slider, with gap X between 50 and 250 and tolerance of ±5 pixels. The ticket stores only salted hash of X.

This is a position puzzle, not behavior analysis. It does not evaluate trajectory, duration, speed, or acceleration. Automated image analysis can potentially solve it, so high-risk use needs stronger signals or a vendor behavior provider.

5. PointSelect

PointSelect generates three to five target points with fifteen-pixel tolerance. Each expected coordinate is stored as a separate hash. Verification requires the same number of points and consumes each expected hash once.

6. Behavior provider

Behavior is registered when RiskControl:Captcha:Behavior:Provider is non-empty. The recognized names are Aliyun and Tencent, but any other non-empty value follows the Aliyun branch. Generation stores a pending ticket and returns provider, scene, and SDK URL.

Behavior captcha configuration boundary
# Reject unknown providers during startup instead of falling through to Aliyun.
# Resolve credentials from a secret provider; keep only references in configuration.
RiskControl:
Captcha:
Behavior:
Provider: "tencent" # Validate against an explicit allow-list.
AppKey: "secret-ref:risk-captcha-app-key" # Resolve through a secret provider.
AppSecret: "secret-ref:risk-captcha-secret" # Never commit to appsettings.
SceneId: "login"
SdkUrl: "https://trusted.example/sdk.js" # Validate HTTPS and CSP allow-list.

Current GA risks:

  • secret and verification token are placed in GET query strings;
  • Tencent Randstr is the fixed value bitzorcas and UserIp is empty;
  • no dedicated timeout, resilience policy, or response-size limit was found;
  • provider, credentials, scene, and SDK URL have no startup validation;
  • external exception messages are included in error descriptions;
  • no vendor contract tests were found.

7. Ticket-store availability

Redis uses atomic StringGetDeleteAsync. Without Redis, DI installs NoOpOneTimeTicketStore:

  • Set does nothing while Generate still returns success;
  • every Verify sees a missing ticket and fails safely;
  • users receive unsolvable challenges instead of a startup or readiness failure.

Production should require Redis or another truly atomic store and probe a Set/GETDEL cycle in readiness.

8. Actual consumers

PathGenerationBinding checkPassed check
Identity Logindefault ImageCode on risk challengegenerated with tenant plus username hash; no submit prefix checkyes
Website Contactdedicated GET generates ImageCodechecks captcha:public-website:{visitorId}:yes
RequireCaptcha Filterno general issue protocolnoneno, and attached nowhere

Login pseudo-session is the first sixteen hex characters of SHA-256 over TenantId and UserName, plus a random nonce in the final ChallengeId.

9. Reusable application boundary

Centralize context-bound verification
public async Task<Result> VerifyBoundAsync(
CaptchaBinding binding,
CaptchaVerifyRequest request,
CancellationToken cancellationToken)
{
var prefix = $"captcha:{binding.TenantId}:{binding.SessionId}:";
// The prefix is built from trusted server context, never from another request field.
if (!request.ChallengeId.StartsWith(prefix, StringComparison.Ordinal))
return Result.Failure(CaptchaErrors.ContextMismatch);
var provider = resolver.Resolve(binding.RequiredType);
var verification = await provider.VerifyAsync(request, cancellationToken);
// Infrastructure failure and business rejection both deny, with separate internal metrics.
return verification.IsSuccess && verification.Value!.Passed
? Result.Success()
: Result.Failure(CaptchaErrors.Rejected);
}

Include scenario such as login, contact, or password-reset in the ticket key or stored value to prevent cross-purpose use.

10. Test matrix

  • generation, correct answer, wrong answer, blank answer, expiry, and second use for each provider;
  • tenant, session, and scenario mismatch without consuming a legitimate ticket;
  • resolver duplicates, unknown type, missing default, and conditional Behavior registration;
  • Slider tolerance boundaries and PointSelect point-count/order contract;
  • Redis exception, production rejection of NoOp, and readiness;
  • vendor timeout, non-2xx, malformed JSON, rejection, and secret redaction;
  • filter denies Success(Passed=false) and is attached to real endpoints.

Back: Rule engine · Next: Policy and persistence

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%