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.
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.
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.
# 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
bitzorcasand 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
| Path | Generation | Binding check | Passed check |
|---|---|---|---|
| Identity Login | default ImageCode on risk challenge | generated with tenant plus username hash; no submit prefix check | yes |
| Website Contact | dedicated GET generates ImageCode | checks captcha:public-website:{visitorId}: | yes |
| RequireCaptcha Filter | no general issue protocol | none | no, 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
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.