Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

HumanVerify 人机校验

oak-general-business 现在已经把人机校验做成了一套独立能力。它和 captcha 不是一回事:

  • captcha 是短信、邮箱验证码实体,负责保存和校验用户收到的验证码;
  • humanVerify 是请求前的人机校验,负责在登录、注册、发送验证码这类高风险入口前先挡一层机器人流量。

这套能力的核心源码在 oak-general-business/src/types/HumanVerify.tssrc/utils/humanVerify/*src/features/humanVerify.tssrc/endpoints/humanVerify.tssrc/components/humanVerify/*

能力模型

人机校验的运行配置挂在 system.config.humanVerify 上,类型是 HumanVerifyConfig

type HumanVerifyConfig = {
  activeType?: string;
  providers?: Record<string, { config?: Record<string, unknown> }>;
  scenes?: Record<string, HumanVerifyScenePolicy>;
};

这里有三层含义:

  • activeType:当前启用哪个 provider。同一个系统可以保存多个 provider 配置,但运行时只用一个。
  • providers[type].config:provider 自己的配置,例如 Turnstile 的 siteKey/secretKey,ALTCHA 的 hmacKey
  • scenes[scene]:按业务场景决定是否启用、是观察还是强制拦截。

内置场景在 HUMAN_VERIFY_SCENES 里,目前有四个:

  • auth.login.account:账号密码登录,对应 loginByAccount
  • auth.register.loginName:用户名注册,对应 registerUserByLoginName
  • auth.captcha.sendMobile:发送手机验证码,对应 sendCaptchaByMobile
  • auth.captcha.sendEmail:发送邮箱验证码,对应 sendCaptchaByEmail

需要特别注意:短信、邮箱验证码登录本身仍然由 captcha 记录来校验;人机校验挡在“发送验证码”之前,而不是挡在“提交验证码登录”之前。

场景策略

每个 scene 都可以配置 HumanVerifyScenePolicy

type HumanVerifyScenePolicy = {
  enabled?: boolean;
  mode?: 'observe' | 'enforce';
  action?: string;
  minScore?: number;
  providerErrorPolicy?: 'allow' | 'deny';
};

真实行为由 src/utils/humanVerify/policy.ts 里的 verifyHumanVerifyScene(...) 决定:

  • enabled !== true 时,这个场景不做人机校验。
  • mode 默认是 enforceenforce 会在 proof 缺失、provider 不匹配、校验失败或分数不足时抛 OakUserException
  • observe 不拦截请求,适合灰度观察或临时兜底;当前实现不把观察结果落库,只是不抛错。
  • providerErrorPolicy 决定 provider 未注册、第三方服务异常等情况怎么处理;默认按 deny 理解,allow 只处理 provider 错误,不豁免 enforce 模式下的 proof 缺失。
  • minScore 只对会返回风险分数的 provider 有意义,低于阈值时 enforce 会拦截。当前内置 debug / altcha 成功时返回 score: 1,Turnstile 内置 provider 不返回分数。

root context 会直接跳过人机校验,所以内部初始化、后台 root 操作不会被这层能力卡住。

服务端失败时会抛 OakUserException,错误 key 包括:

  • error::humanVerify.required
  • error::humanVerify.unsupportedProvider
  • error::humanVerify.failed
  • error::humanVerify.expired
  • error::humanVerify.serviceUnavailable
  • error::humanVerify.riskTooHigh

内置 Provider

oak-general-business 内置了三个 provider:

provider作用关键配置
debug调试用,通过约定 token 模拟通过或失败。token,默认 debug-pass
turnstileCloudflare Turnstile。前端隐藏执行 challenge,后端调用 Cloudflare siteverifysiteKeysecretKeyexpectedHostnameactionthemelanguage
altchaALTCHA 自托管挑战。后端签发 challenge,前端显示 altcha-widget,后端验证 payload。hmacKeychallengeUrlexpiresInmaxNumbersaltLengthhideFooter

后端 provider 默认在 src/utils/humanVerify/index.backend.ts 中注册。应用如果要增加自定义 provider,通过 @oak-general-business/registry.backend 暴露的 registerHumanVerifyProvider(...) 注册即可。注册要放在应用启动早期、第一次人机校验发生之前;后端 provider registry 被使用后会锁定,不能再追加 provider。

前端 provider 不会自动全部打进应用包。应用侧需要通过 @oak-general-business/registry.frontend 手动注册 frontend bundle、配置组件和交互组件。

ALTCHA Endpoint

ALTCHA 需要服务端签发 challenge,公共包已经提供 endpoint:

humanVerify/altcha/challenge

这个 endpoint 在 src/endpoints/humanVerify.ts 中定义,并通过 src/endpoints/index.ts 导出。它会:

  1. 根据请求里的 applicationId 切到对应应用;
  2. 读取当前应用所属 system.config.humanVerify
  3. 只有 activeType === 'altcha' 时才使用 ALTCHA 配置;
  4. hmacKeyexpiresInmaxNumbersaltLength 创建 challenge;
  5. sceneaction 写入 challenge params。

ALTCHA 前端组件默认会用:

features.cache.makeEndpointUrl('humanVerify/altcha/challenge')

如果 providerConfig.challengeUrl 有值,则使用配置里的地址。

前端调用链

前端统一通过 features.humanVerify.acquireProofForScene(scene, payload) 获取 proof。它会先读取当前应用的 system.config.humanVerify,解析出当前 scene 是否启用、使用哪个 provider、provider 配置和策略。

如果 scene 没启用,方法直接返回 undefined。如果启用了,则按 provider 的前端能力走两种路径:

  • client 的 provider,直接调用 client.acquire(...) 获取 proof。Turnstile 就是这种路径。
  • 没有 client、但有 acquireComponent 的 provider,通过全局 host 组件弹出交互组件。debug 和 ALTCHA 走这条路径。

HumanVerifyProof 最终会传给对应 aspect:

type HumanVerifyProof = {
  provider?: string;
  scene?: string;
  token?: string;
  payload?: Record<string, unknown>;
};

服务端公共策略层会先要求 token 存在,并校验 proof.provider 是否和当前启用 provider 一致;随后再交给具体 provider 校验 token、action、hostname 或 challenge payload。

Host 组件

需要交互组件的 provider 依赖全局 host:

oak-humanVerifyHost

这个全局组件在 oak-general-business/package.jsonoak.frontend.globalComponents 中声明,指向:

@oak-general-business/components/humanVerify/host/index

web 端 host 会从 features.humanVerify 取 pending request,然后渲染对应 AcquireComponent。小程序端通过 componentGenerics.acquire 暴露一个泛型组件插槽,默认是空实现 emptyAcquire;如果小程序要做人机校验,需要项目侧提供对应 acquire 组件。

如果某个 scene 是 enforce,但前端没注册 provider bundle、没有 acquire component,或者 host 没挂载,acquireProofForScene(...) 通常会抛错。即使前端因为 providerErrorPolicy: 'allow' 返回了 undefined,服务端在 enforce 下仍会把它当作 proof 缺失并抛 error::humanVerify.required

已接入的人机校验入口

公共包的成品组件已经在关键入口调用 features.humanVerify.acquireProofForScene(...)

入口scene后续调用
components/user/login/passwordauth.login.accountfeatures.token.loginByAccount(...)
components/user/registerauth.register.loginNamefeatures.token.registerByLoginName(...)
components/user/login/smscomponents/mobile/logincomponents/changePassword/byMobileauth.captcha.sendMobilefeatures.token.sendCaptcha('mobile', ...)
components/user/login/emailcomponents/email/upsertauth.captcha.sendEmailfeatures.token.sendCaptcha('email', ...)

服务端对应的强制校验点在:

  • src/aspects/token.tsloginByAccount(...)
  • src/aspects/token.tssendCaptchaByMobile(...)
  • src/aspects/token.tssendCaptchaByEmail(...)
  • src/aspects/user.tsregisterUserByLoginName(...)

如果项目自己绕开这些公共组件、直接调用 features.token 或后端 aspect,也要自己先调用 features.humanVerify.acquireProofForScene(...) 并把 proof 传进去。否则在 scene 为 enforce 时,服务端会按缺失 proof 处理。

管理台配置入口

系统配置组件 components/config/upsert 已经把“人机校验”作为标准 tab 加进去了。运行时读取的是当前应用所属系统上的配置,配置写入路径是:

system.config.humanVerify

这个 tab 做三件事:

  • 选择 activeType
  • 编辑当前 provider 的配置;
  • 按四个内置 scene 配置 enabled/mode/action/minScore/providerErrorPolicy

不过 provider 专属配置表单也需要前端注册。公共包提供了三组内置配置组件:

  • @oak-general-business/components/config/upsert/humanVerify/providers/debug
  • @oak-general-business/components/config/upsert/humanVerify/providers/turnstile
  • @oak-general-business/components/config/upsert/humanVerify/providers/altcha

应用侧接入示例

web 应用如果要使用内置 provider,通常在前端 registry 或初始化入口里注册:

import {
  registerHumanVerifyAcquireComponent,
  registerHumanVerifyConfigComponent,
  registerHumanVerifyFrontendProviderBundle,
} from '@oak-general-business/registry.frontend';
import { HUMAN_VERIFY_PROVIDER_TYPES } from '@oak-general-business';
import {
  altchaHumanVerifyFrontendBundle,
  debugHumanVerifyFrontendBundle,
  turnstileHumanVerifyFrontendBundle,
} from '@oak-general-business/utils/humanVerify/frontendBundles';
import AltchaConfig from '@oak-general-business/components/config/upsert/humanVerify/providers/altcha';
import DebugConfig from '@oak-general-business/components/config/upsert/humanVerify/providers/debug';
import TurnstileConfig from '@oak-general-business/components/config/upsert/humanVerify/providers/turnstile';
import AltchaAcquire from '@oak-general-business/components/humanVerify/acquire/altcha/web';
import DebugAcquire from '@oak-general-business/components/humanVerify/acquire/debug/web';

registerHumanVerifyFrontendProviderBundle(altchaHumanVerifyFrontendBundle);
registerHumanVerifyFrontendProviderBundle(debugHumanVerifyFrontendBundle);
registerHumanVerifyFrontendProviderBundle(turnstileHumanVerifyFrontendBundle);

registerHumanVerifyConfigComponent(HUMAN_VERIFY_PROVIDER_TYPES.altcha, AltchaConfig);
registerHumanVerifyConfigComponent(HUMAN_VERIFY_PROVIDER_TYPES.debug, DebugConfig);
registerHumanVerifyConfigComponent(HUMAN_VERIFY_PROVIDER_TYPES.turnstile, TurnstileConfig);

registerHumanVerifyAcquireComponent(HUMAN_VERIFY_PROVIDER_TYPES.altcha, AltchaAcquire);
registerHumanVerifyAcquireComponent(HUMAN_VERIFY_PROVIDER_TYPES.debug, DebugAcquire);

Turnstile 有 frontend client,可以隐藏执行,不需要额外 acquire component。debug 和 ALTCHA 需要弹窗交互组件,所以必须注册 acquire component,并确保 oak-humanVerifyHost 已经出现在页面树里。

直接调用示例

如果项目自己写登录按钮,不走公共登录组件,调用方式应该类似这样:

const humanVerify = await this.features.humanVerify.acquireProofForScene(
  HUMAN_VERIFY_SCENES.loginByAccount,
  { account }
);

await this.features.token.loginByAccount(account, password, humanVerify);

发验证码也是同样的结构:

const humanVerify = await this.features.humanVerify.acquireProofForScene(
  HUMAN_VERIFY_SCENES.sendCaptchaByMobile,
  { mobile, type: 'login' }
);

await this.features.token.sendCaptcha('mobile', mobile, 'login', humanVerify);

这里的 payload 主要给前端 provider 或交互组件使用,服务端当前校验核心仍然是 proof.token 和当前 provider。

自定义 Provider

项目可以扩展自己的 provider,最小需要两端:

  1. 后端实现 HumanVerifyProvider.verify(...),通过 registerHumanVerifyProvider(...) 注册。
  2. 前端实现 HumanVerifyClient.acquire(...),或者实现一个 acquire component,再通过 frontend registry 注册。

如果要让系统配置页支持这个 provider,还要实现 HumanVerifyConfigComponentProps 对应的配置组件,并通过 registerHumanVerifyConfigComponent(...) 注册。

后端返回值统一是:

type HumanVerifyResult = {
  success: boolean;
  score?: number;
  reasonCode?: string;
  message?: string;
  raw?: unknown;
};

reasonCode 会进入 OakUserException 的 params,前端可通过错误国际化显示更明确的原因。

注意事项

  • 不要把 HumanVerify 和 captcha 混在一起。验证码发送前做人机校验,验证码登录时校验 captcha
  • activeType 为空时,人机校验不会触发,即使 scene 配了 enabled
  • mode: 'observe' 不适合当正式防刷策略,只适合灰度观察。
  • providerErrorPolicy: 'allow' 会在 provider 出错时放行,高风险场景要谨慎使用。
  • ALTCHA 的 hmacKey 和 Turnstile 的 secretKey 都是服务端密钥,不应出现在前端公开配置之外的地方。
  • 小程序端 host 只是预留了泛型 acquire 插槽;当前公共包内置的 debug/ALTCHA acquire 组件是 web 实现,小程序要按项目需要补。
  • 如果项目不用公共登录/注册/验证码组件,而是自己写 UI,必须显式获取 proof 并传给对应 feature/aspect。