Relintio/relintio-expo-agent

GitHub: Relintio/relintio-expo-agent

Relintio 官方的 Expo/React Native 客户端 Agent,在原生移动环境中发起验证裁定并通过 WebView 呈现 challenge。

Stars: 0 | Forks: 0

Relintio

@relintio/expo-agent

npm quickstart license

用于 Expo 和 React Native 的 Relintio agent。

`createRelintio()` 返回一个 `RelintioNativeClient`:这是一个小型对象,它向 Relintio 请求裁定,在需要时通过 `WebView` 呈现 challenge,并在用户通过后 resolve 你的代码正在等待的 promise。该协议存在于 `@relintio/browser-core` 中 —— 包括拒绝 publishable-key、对 challenge URL 的 `http`/`https` 检查、十秒的超时下限、并发 challenge 的去重,以及在所有错误路径上的 fail open —— 与 React、Vue、Svelte 和 Angular SDK 逐字节共享。此 package 只是在其之上的 React Native 形式的封装。它改变的不是协议,而是环境:没有 `location`,没有 `document`,没有 iframe,并且设备信号集更小,且坦诚地承认这种局限性。 ``` // relintio.ts import { Platform } from 'react-native'; import { createRelintio } from '@relintio/expo-agent'; export const relintio = createRelintio({ publishableKey: 'pk_live_...', // Required. There is no location.hostname on native, so the agent has to be // told which origin this app talks to. domain: 'api.example.com', environment: { platform: Platform.OS, platformVersion: Platform.Version, }, }); ``` ## 安装说明 ``` npx expo install @relintio/expo-agent react-native-webview ``` 使用 `expo install` 而不是 `npm install`,以便将 WebView 固定为你的 Expo SDK 附带的版本。`react-native-webview` 包含原生代码:将其添加到已经预构建的项目中意味着需要一次新的原生构建,并且它在 Expo Go 中无法超越其捆绑的版本运行。 | 对等依赖 | 版本范围 | 是否声明 | | --- | --- | --- | | `react` | `>=17.0.0` | 是 | | `react-native` | `>=0.70.0` | 是 | | `react-native-webview` | — | **否** | 最后一行需要仔细阅读。`react-native-webview` 既没有被声明为 peer dependency,也不是 runtime dependency,因此 npm 不会发出警告,安装也不会失败 —— 但它不是可选的。`webViewProps()` 返回的 props 如果没有组件可以应用就毫无意义,而且在原生环境中没有其他方法可以呈现 challenge。请在同一条命令中,与此 package 一起显式安装它。 `@relintio/browser-core` `^1.0.0` 是唯一声明的 runtime dependency。 ## 注册 在靠近应用根节点的 module 作用域内 **只创建一次** client,并在各处导入该实例。该 agent 是有状态的:它持有待处理的 challenge、订阅者集合和已 resolve 的计数,两个 client 意味着对于该用户是否通过了任何验证会有两个独立的视图。 ``` // App.tsx import { relintio } from './relintio'; import { RelintioChallenge } from './RelintioChallenge'; export default function App() { useEffect(() => () => relintio.destroy(), []); return ( <> ); } ``` `destroy()` 会销毁底层的 agent:它会以 `Relintio agent unmounted` 为由 reject 任何处于打开状态的 challenge,清除监听器集合,并拒绝再次启用另一个。在根节点 unmount 时调用它对于生命周期与进程一样长的应用来说只是为了整洁,而非必须;但在测试中,或者在任何会拆除并重建组件树的情况下,它就很重要。 如果设置了 `verifyOnMount`,verdict 请求会在 `createRelintio()` 内部触发 —— 在 module 作用域下,这发生在导入时,即第一个屏幕渲染之前。 ## 配置 `RelintioExpoConfig` 继承了共享的 `RelintioConfig`,并额外增加了两个字段。 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `publishableKey` | `string` | — | 必填。必须以 `pk_` 开头。其他任何内容都会被拒绝;见下文。 | | `domain` | `string` | — | 必填,且为该 package 特有。此应用通信的 origin。将作为 `domain` 上报,并用于构建 `return_url`。 | | `environment` | `RelintioNativeEnvironment` | `{}` | 你传入的设备元数据。没有它就不会收集任何内容。 | | `apiUrl` | `string` | `https://api.relintio.com/v1` | 控制面 (Control-plane) 基础地址。末尾斜杠会在构造时被截断一次。 | | `challengeTimeoutMs` | `number` | `120000` | 一个 challenge 在其 promise 被 reject 之前可以保持打开状态的时间。下限为 `10000`;低于此值的设置会被上调,而不会按原样执行。 | | `verifyOnMount` | `boolean` | `false` | 在 client 创建后立即请求 verdict。 | | `fallbackUrl` | `string` | — | 在 `RelintioConfig` 上声明,但核心代码中没有任何地方会读取它。设置它没有效果。 | `domain` 没有默认值,因为没有合理的默认值可供设置。浏览器 agent 会读取 `location.hostname` 并知道它正在保护哪个站点;而应用二进制文件只知道你告诉它的内容。如果没有 `domain`,平台就无法将请求与受保护的域进行匹配,导致返回的每个 verdict 都是不匹配的。 ## 哪种密钥用在这里 这是一个 **浏览器家族** 的 package,凭证规则是相同的,而且原因更明确:应用二进制文件是一个任何人都可以下载并解包的文件,编译到其中的字符串就是一个已公开的字符串。它需要一个 **publishable key**(`pk_live_…`),其他任何内容都不行。Publishable key 在设计上就是公开的,且只做一件事 —— 请求 verdict 并读取结果。 你的 **licence key 绝对不能出现在这里**。它是用于 challenge 护照和请求签名的 HMAC 密钥,因此任何持有它的人都可以伪造一个通过你 WAF 的通行证。如果被赋予了一个 licence key,agent 会匹配 `pk_` 前缀,匹配失败后,会写入一条 `console.error` 说明情况,并且 `createRelintio()` 会返回一个 `usable` 为 false 的 client。该 client 不会传输任何内容:`verify()` 会在不发起请求的情况下 resolve 为 `null`,因此密钥永远不会离开设备。它也不会抛出异常 —— 有关不可用的 client 对代码其余部分的影响,请参阅 [边缘情况](#edge-cases)。 ## 原生 runtime 实际可以收集的内容 浏览器收集器通过 DOM 对设备进行指纹识别:渲染并进行哈希处理的 canvas、WebGL renderer 字符串、通过文本宽度探测的二十八种字体、`navigator.plugins`、离线音频渲染,以及被动的行为计数器。React Native 没有这些。没有 canvas,没有 WebGL 上下文,没有字体枚举,没有 `navigator.plugins`,也没有可以附加行为监视器的 `document`。 因此,Expo agent 重写了 `collectSignals()` 并发送一个较小的集合。它不会通过 shim 模拟浏览器:一个伪造值的指纹比一个短小的指纹更糟糕,因为平台会根据该设备并不属于的浏览器基准对其进行评分。 | 你传入的值 | 发送为 | 典型来源 | | --- | --- | --- | | `platform` | `env.platform` | `Platform.OS` | | `platformVersion` | `env.platform_version` | `Platform.Version` | | `appVersion` | `env.app_version` | `expo-application` | | `buildVersion` | `env.build_version` | `expo-application` | | `deviceModel` | `env.device_model` | `expo-device` | | `deviceBrand` | `env.device_brand` | `expo-device` | | `isPhysicalDevice` | `env.is_physical_device` | `Device.isDevice` — 在模拟器上为 `false` | | `timezone` | `env.timezone` | IANA 时区 | | `timezoneOffset` | `env.timezone_offset` | 分钟 | | `locale` | `env.locale` | BCP-47 | | `extra` | 展开到 `telemetry` 中 | 你希望参与评分的任何其他内容 | 每个字段都是可选的,并且每个字段都是应用已经拥有的内容。`telemetry.surface` 始终为 `'native'`。不会收集任何其他内容 —— 此平台上没有环境收集,你不传入的字段就不会被发送。`expo-device` 和 `expo-application` 是通常的来源,但它们都不是此 package 的依赖项;来自它们的字段距离可用只差一个 `?? undefined`。 其他的重写是环境性的。`path` 始终为 `/`,`referrer` 始终为空,`return_url` 始终为 `https:///`,而 `up_token` 始终为 `null` —— 原生启动没有 query string 来携带这些内容。 ## 呈现 challenge challenge 是模态框中的一个 `WebView`,而不是 iframe。`webViewProps()` 返回要展开的 props,或者当屏幕上没有 challenge 时返回 `null`。 ``` import { useEffect, useState } from 'react'; import { Modal } from 'react-native'; import { WebView } from 'react-native-webview'; import { relintio } from './relintio'; export function RelintioChallenge() { const [, setState] = useState(relintio.getState()); useEffect(() => relintio.subscribe(setState), []); const props = relintio.webViewProps(); if (!props) return null; return ( relintio.handleWebViewMessage(event.nativeEvent.data)} /> ); } ``` `incognito: true` 是故意的:challenge 是一次身份验证,而重用上一次验证的 cookie 只是在验证 cookie 本身。`injectedJavaScript` 安装了一个四行的 bridge,它监听 challenge 页面上的 `message` 事件,并将 string payload 转发给 `window.ReactNativeWebView.postMessage`,这就是 `onMessage` 所接收到的内容。它以 `true;` 结尾,因为如果注入脚本的最后表达式是一个对象,`react-native-webview` 在 iOS 上会发出警告。 **三部分的 `postMessage` 检查在此处不适用,并且在代码中也没有任何内容替代它。** 在浏览器中,agent 会验证事件 origin、source frame 和确切的消息体,因为任何页面都可以向任何窗口发送消息。在 WebView 中,没有跨域 `window` 可以被冒充,也没有 `event.source` 可以比较,因此 `handleWebViewMessage` 只做一件事:将字符串与 `relintio_challenge_success` 进行精确比较。取代浏览器检查的是结构性约束 —— WebView 由你的应用创建,它只加载平台发布的 URL,并且它是唯一连接到此 handler 的对象。只要这三点保持不变,这就成立;有关导航的边缘情况,请参见下文。 ## 请求 verdict 原生环境上没有拦截器。React Native 确实提供了一个全局 `fetch`,但它是平台网络栈之上的一个 polyfill,而不是位于 origin 和 CORS 模型背后的浏览器 `fetch`,并且此 package 从不对它进行 patch —— 在别人的应用内部 patch 全局变量不是依赖项应该做的事。`RelintioNativeClient` 暴露了 `subscribe`、`getState`、`verify`、`challenge`、`handleWebViewMessage`、`webViewProps` 和 `destroy`,而核心库的 `interceptFetch` 并不在其中。 因此,决策点由你来放置: ``` const verdict = await relintio.verify(); if (verdict?.action === 'challenge' && verdict.challenge_url) { await relintio.challenge(verdict.challenge_url); // rejects if unsolved } ``` 当 verdict 包含 challenge 时,`verify()` 已经会自行呈现 challenge,因此上面的显式调用是用于另一条路径:你自己的 API 响应 `403` 并附带 `X-Relintio-Challenge` header,在原生环境上这需要你自己读取并转交。 ## 边缘情况 **没有什么能阻止 challenge 的 WebView 导航离开。** `webViewProps()` 设置了 `source`、`javaScriptEnabled`、`incognito` 和 `injectedJavaScript`,除此之外什么也没有。没有 `originWhitelist`,也没有 `onShouldStartLoadWithRequest`。如果 challenge 页面发生重定向,目标地址会在附加了相同 bridge 的同一个 WebView 中加载,并且从该页面发送的 `postMessage` 如果与确切的成功字符串匹配,就会使 challenge resolve。如果你的威胁模型包含这一点,请添加你自己的导航防护。 **被拒绝的密钥会直接 resolve challenge 而不是呈现它们。** 如果使用了不可用的 client,`challenge()` 会返回 `Promise.resolve()`,因此在释放请求之前 await 它的代码会像用户已经通过了一样继续执行。`subscribe()` 会使用当前状态调用你的监听器一次,并返回一个空操作 (no-op) 的取消订阅函数,因此 UI 永远不会更新,`webViewProps()` 也会永远保持为 `null`。构造时的 `console.error` 是应用未受保护的唯一信号。 **护照保留在 WebView 中。** 通过 challenge 会 resolve 你应用中的 promise,这就是此 package 跨越边界传输的全部内容。Challenge 页面设置的任何 cookie 都存在于 WebView 的隐身存储中,该存储会被丢弃,并且此处不会将任何内容复制到你应用的 HTTP client 中。如果你的 origin 期望在后续调用中获得护照 cookie,那么这些连接逻辑由你自己负责。 **`domain` 被按原样使用且从不进行验证。** 它会被原样上报,并内插到 `` `https://${domain}/` `` 中作为 `return_url`。如果传入 `https://api.example.com` 而不是 `api.example.com`,将会生成 `https://https://api.example.com/`,这在本地会被接受,但在平台上毫无用处。 **`environment` 对象是在请求时读取的,而不是在捕获时。** `collectSignals()` 会在每次 verdict 调用时从你交给 `createRelintio()` 的 config 对象中读取它,因此稍后修改该对象会被获取到 —— 但替换它则不会。异步到达的值可以填充到同一个对象中;而新对象将被忽略。任何保持为 `undefined` 的字段都会被 `JSON.stringify` 丢弃,并且根本不会出现在网络传输中。 **并发失败只会产生一个 challenge。** 同时失败的多个请求会加入同一个待处理的 promise,因此只会弹出一个模态框,而不是多个模框去竞相验证同一个用户。显示的是第一个 challenge URL;后面的都会被丢弃。 **未解决的 challenge 会被 reject。** 超过 `challengeTimeoutMs` 超时,或者在 challenge 打开时调用了 `destroy()`,都会 reject 该 promise,而 rejection 意味着不要释放之前被持有的任何内容。已销毁的 client 根本拒绝启用新的 challenge,因此创建它的组件树消失后,任何两分钟的计时器都不会继续存在。 **成功字符串在此处作为字面量被复制了一份。** 核心库将其导出为 `CHALLENGE_SUCCESS_MESSAGE`;`handleWebViewMessage` 比较的是该字符串的副本,而不是导入的常量。它目前是正确的,并且在此绑定中,这是唯一一个如果共享协议发生变化就不会自动传播的地方。 **每一个失败路径都会 fail open。** 无法访问控制面 (Control plane)、非 2xx 响应、在五秒时中止、无法解析的响应体:所有这些情况都不会返回 verdict,应用会继续执行。如果安全 agent 因为自己无法连接到其控制面而导致屏幕变白,那就等于把我们自己的故障变成了客户的故障。 ## 链接 - [文档](https://relintio.com/docs) - [快速入门](https://relintio.com/docs/quickstart/expo) - [API 参考](https://relintio.com/docs/api-reference) - [许可证](https://relintio.com/licenses) 安全报告请发送至 **support@relintio.com**,请勿提交至公开 issue。 ## License MIT。详见 [`LICENSE`](./LICENSE)。
标签:Expo, React Native, WebView, 人机验证, 移动开发, 自动化攻击, 防机器人