Captcha 由两步组成:服务端生成带一次性 Ticket 的挑战,客户端渲染并提交作答,Provider 原子消费 Ticket 后验证。任何一步没有绑定 tenant、session 和 scenario,都可能把“防重放”误当成“防盗用”。
1. 统一契约
CaptchaContext 在生成时携带 TenantId、SessionId、可空 UserId 和 IP;CaptchaChallenge 返回 ChallengeId、类型、RenderData 与到期时间;校验输入却只有 ChallengeId 和 UserAnswer。
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 已挂载。