Skip to content
bitzorcas
中EN

Guide

RiskControl Captcha Provider 与一次性票据

讲透 ImageCode、Slider、PointSelect、Behavior Provider、Resolver、会话绑定、防重放和当前接线缺口。

Last updated

Captcha 由两步组成:服务端生成带一次性 Ticket 的挑战,客户端渲染并提交作答,Provider 原子消费 Ticket 后验证。任何一步没有绑定 tenant、session 和 scenario,都可能把“防重放”误当成“防盗用”。

1. 统一契约

CaptchaContext 在生成时携带 TenantId、SessionId、可空 UserId 和 IP;CaptchaChallenge 返回 ChallengeId、类型、RenderData 与到期时间;校验输入却只有 ChallengeId 和 UserAnswer。

IOneTimeTicketStoreProviderLogin / WebsiteClientIOneTimeTicketStoreProviderLogin / WebsiteClient请求挑战Generate(context)SET captcha:tenant:session:nonce, TTL 5mChallengeId + RenderDataChallengeId + Answer校验 tenant/session/scenario 前缀Verify(id, answer)GETDEL idResult<CaptchaVerification>同时检查 IsFailure 与 Passed

Ticket 一次性消费能阻止同一 ChallengeId 重复验证,但 Provider 的 Verify 没有当前 CaptchaContext,无法自己证明该 Ticket 属于当前请求。这个检查必须由调用方完成,或修改契约让 Verify 接收绑定上下文。

2. Provider Resolver

CaptchaProviderResolver 构造时按 CaptchaType 建 O(1) 字典:

  • 同类型多个实现保留最先注册者;
  • ImageCode 是必须存在的默认 Provider,缺失则启动构造失败;
  • 未注册类型静默回退 ImageCode;
  • ImageCode、Slider、PointSelect 由组合注册;Behavior 条件注册。

静默回退适合渐进增强,却可能掩盖配置错误。若 Endpoint 明确要求 Behavior,应验证返回的 CaptchaChallenge.Type,或使用“缺 Provider 即失败”的严格解析端口。

3. ImageCode

SvgImageCaptchaProvider 生成 5 位、排除易混字符的随机码和带干扰的 SVG。Ticket 保存固定盐 + 大写答案的 SHA-256,而不是明文;验证用固定时间比较。

生成并验证图形验证码
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);
// 二次提交先检查前缀,防止盗用另一个租户或会话的 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);
// Provider 的错误答案是 Success(Passed=false),两层状态都必须判断。
if (verification.IsFailure || !verification.GetValueOrThrow().Passed)
return Result.Failure(CaptchaErrors.Rejected);

4. Slider

Slider 返回 300×150 背景与 44 像素滑块 SVG,随机缺口 X 在 50–250,允许 ±5 像素。Ticket 只保存缺口 X 的盐渍哈希。

这是位置挑战,不是行为分析:它不检查轨迹、耗时、速度或加速度。自动化脚本若能从 SVG 识别缺口,就可能通过;高风险场景应使用行为 Provider 或叠加限流和设备信号。

5. PointSelect

PointSelect 随机生成 3–5 个点,允许每点 15 像素误差,Ticket 保存每个坐标的独立哈希。验证要求点数一致,并让每个期望点最多消费一次。

6. Behavior Provider

只有 RiskControl:Captcha:Behavior:Provider 非空时注册。支持 aliyun 和 tencent 名称;其他非空值会走 Aliyun 分支。生成阶段只存 pending Ticket,并把 Provider、SceneId、SdkUrl 返回前端;验证阶段先 GETDEL,再调厂商 API。

行为验证码配置边界
# 启动时拒绝未知 Provider,不能让拼写错误静默走 Aliyun 分支。
# 配置文件只保存 Secret 引用,运行时再从密钥服务解析。
RiskControl:
Captcha:
Behavior:
Provider: "tencent" # 只允许经过校验的 aliyun 或 tencent。
AppKey: "secret-ref:risk-captcha-app-key" # 从 Secret Provider 注入。
AppSecret: "secret-ref:risk-captcha-secret" # 禁止提交到 appsettings。
SceneId: "login"
SdkUrl: "https://trusted.example/sdk.js" # 需要启动校验与 CSP 白名单。

当前 GA 风险:

  • AppSecret 和验证 Token 被放入 GET Query,可能进入代理、APM 与访问日志;
  • Tencent Randstr 固定为 bitzorcas,UserIp 为空,没有接收前端 Randstr;
  • HttpClient 未见专用 Timeout、Resilience 或响应大小限制;
  • Provider、AppKey、Secret、SceneId、SdkUrl 没有启动校验;
  • Error Description 拼入外部异常消息,可能暴露不必要诊断;
  • 没有第三方厂商合同测试。

7. Ticket Store 的可用性

Redis 实现使用 StringGetDeleteAsync 原子消费。没有 Redis 时 DI 注册 NoOpOneTimeTicketStore:

  • Generate 的 Set 什么也不做,却仍返回成功 Challenge;
  • Verify 永远拿到 null,安全地返回未通过;
  • 用户看到的效果是所有挑战都无法完成,而不是启动失败或健康检查红灯。

生产组合应要求 Redis,或提供真正的单实例原子 Store,并用 Readiness 检查一次 Set/GETDEL 能力。

8. 三条实际消费路径

路径生成上下文绑定检查 Passed
Identity Login风险触发时生成默认 ImageCode生成使用 tenant + 用户名哈希;二次提交未校验前缀是
Website Contact独立 GET Endpoint 生成默认 ImageCode提交时校验 captcha:public-website:{visitorId}:是
RequireCaptcha Filter没有配套通用生成协议未校验否,且未挂载

Login 的 pseudo-session 是 TenantId + UserName 的 SHA-256 前 16 hex,再加随机 nonce。它能避免同用户名生成覆盖,却不能在二次提交自动验证归属。

9. 生产级调用骨架

把挑战绑定校验封装成应用服务
public async Task<Result> VerifyBoundAsync(
CaptchaBinding binding,
CaptchaVerifyRequest request,
CancellationToken cancellationToken)
{
var prefix = $"captcha:{binding.TenantId}:{binding.SessionId}:";
// 固定前缀由服务端上下文生成,不从请求体接收。
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);
// 基础设施失败与业务未通过都拒绝,但保留不同指标和内部错误码。
return verification.IsSuccess && verification.Value!.Passed
? Result.Success()
: Result.Failure(CaptchaErrors.Rejected);
}

Scenario 最好也进入 Ticket Key 或存储值,例如 login、contact、password-reset,防止同租户同 Session 的挑战跨业务入口复用。

10. 测试矩阵

  • 每个 Provider 的生成、正确答案、错误答案、空答案、过期与二次消费;
  • tenant/session/scenario 不匹配拒绝且不误消费合法 Ticket;
  • Resolver 重复类型、未知类型、缺默认和 Behavior 条件注册;
  • Slider ±5 边界、PointSelect 点数/容差/顺序合同;
  • Redis 异常 fail-closed,NoOp 配置被生产 Readiness 拒绝;
  • Behavior 超时、非 2xx、损坏 JSON、厂商拒绝和 Secret 不入日志;
  • Filter 对 Success(Passed=false) 返回 400,并验证真实 Endpoint 已挂载。

上一篇:规则引擎与风险因子 · 下一篇:策略与持久化

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%