HumanVerify 人机校验
oak-general-business 现在已经把人机校验做成了一套独立能力。它和 captcha 不是一回事:
captcha是短信、邮箱验证码实体,负责保存和校验用户收到的验证码;humanVerify是请求前的人机校验,负责在登录、注册、发送验证码这类高风险入口前先挡一层机器人流量。
这套能力的核心源码在 oak-general-business/src/types/HumanVerify.ts、src/utils/humanVerify/*、src/features/humanVerify.ts、src/endpoints/humanVerify.ts 和 src/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默认是enforce。enforce会在 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.requirederror::humanVerify.unsupportedProvidererror::humanVerify.failederror::humanVerify.expirederror::humanVerify.serviceUnavailableerror::humanVerify.riskTooHigh
内置 Provider
oak-general-business 内置了三个 provider:
| provider | 作用 | 关键配置 |
|---|---|---|
debug | 调试用,通过约定 token 模拟通过或失败。 | token,默认 debug-pass |
turnstile | Cloudflare Turnstile。前端隐藏执行 challenge,后端调用 Cloudflare siteverify。 | siteKey、secretKey、expectedHostname、action、theme、language |
altcha | ALTCHA 自托管挑战。后端签发 challenge,前端显示 altcha-widget,后端验证 payload。 | hmacKey、challengeUrl、expiresIn、maxNumber、saltLength、hideFooter |
后端 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 导出。它会:
- 根据请求里的
applicationId切到对应应用; - 读取当前应用所属
system.config.humanVerify; - 只有
activeType === 'altcha'时才使用 ALTCHA 配置; - 用
hmacKey、expiresIn、maxNumber、saltLength创建 challenge; - 把
scene和action写入 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.json 的 oak.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/password | auth.login.account | features.token.loginByAccount(...) |
components/user/register | auth.register.loginName | features.token.registerByLoginName(...) |
components/user/login/sms、components/mobile/login、components/changePassword/byMobile | auth.captcha.sendMobile | features.token.sendCaptcha('mobile', ...) |
components/user/login/email、components/email/upsert | auth.captcha.sendEmail | features.token.sendCaptcha('email', ...) |
服务端对应的强制校验点在:
src/aspects/token.ts的loginByAccount(...)src/aspects/token.ts的sendCaptchaByMobile(...)src/aspects/token.ts的sendCaptchaByEmail(...)src/aspects/user.ts的registerUserByLoginName(...)
如果项目自己绕开这些公共组件、直接调用 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,最小需要两端:
- 后端实现
HumanVerifyProvider.verify(...),通过registerHumanVerifyProvider(...)注册。 - 前端实现
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。