在HarmonyOS混合应用里,原生与H5之间的凭据怎么下发、能力怎么放行、退出后怎么一起失效,其实是同一个信任边界问题的三个侧面。社区里反复冒出的提问——H5请求怎么带上App登录态、token能不能拼在URL后面、原生退出后H5为何还显示已登录、切换账号后预加载的H5为何串号——本质上都在问:原生和H5之间信任到哪里为止,状态由谁做主。本文基于原实现整理出一张风险矩阵、三条设计原则,并重点校准ArkWeb几个容易写错却“跑得通”的API用法,最后收成一份可直接用于评审的清单。
一、先把交互拆成三个命题
任何一次原生与H5的交互,基本可归入三类命题:身份贯通(App登录态如何传给H5)、能力开放(H5如何拉起原生能力)、失效传播(退出或踢下线后如何同步失效)。身份贯通与失效传播其实是同一件事的两端——一个凭据方案好不好,关键看回收能力,而不是传递能力。
二、四个信任域与四条通道
很多人把“原生到H5”想成一根管道,实际中间隔着两次界:
- 原生进程 ──①──> Web内核(Cookie库/网络栈) ──②──> 页面脚本 ──③──> 服务端
- 可信 半可信(系统管理) 不可信 可信
复制代码
两个常见误判都来自忽略中间那道界:其一,以为“Cookie注入到内核”就安全了,但脚本域能不能读到取决于有没有HttpOnly,与注入方式无关;其二,以为“桥只在我打开的页面里生效”,实际桥对象注册在整个WebView实例上,实例内加载的任何页面都能调到同一个桥对象,导航到站外也一样,除非用permission白名单收敛。
跨域传递凭据只有四条通道:URL、普通Cookie、HttpOnly Cookie、JSBridge。选型时最该看的是“能否撤销”这一列。URL和HttpOnly Cookie的差距不在“能不能被看见”,而在能不能收回;JSBridge常被当成更安全的替代,但它的可撤销性比Cookie更差——返回值一旦进了脚本域,原生就管不着那些副本了。特别提醒:URL拼凭据会把凭据交给所有能看日志、看历史、看Referer的系统,这与攻击者有没有心无关,是设计本身在往外送数据。
三、三条设计原则
第一,凭据默认不下发到脚本域。能只放在内核就不放脚本,能完全不传递最好。
第二,能力按意图授权,不按对象或路由授权。H5的任何入参都不作为可信输入。
第三,状态只有一个源,其余都是副本。副本是必然存在的:Cookie一份、H5的localStorage可能又存一份、内存缓存一份、服务端会话一份。所以“立刻失效”不是删一份就完事,而要三层联动——服务端作废让残留副本即使还在也无效,本地清理让后续请求不再携带,通知页面自清让界面立即对上。这是纵深防御,不是重复劳动。
四、凭据怎么给,更要怎么收
选型可按两个问题走:H5与App后端是否同域(或可控的父子域)?否的话,走请求代理(由原生代发请求并附加鉴权头)或一次性票据;是的话,再看凭据是否允许落到持久化介质,允许就用HttpOnly Cookie,且必须带齐HttpOnly + Secure + 正确的SameSite。
请求代理值得单独说:ArkTS侧用onInterceptRequest事件拦截H5数据请求,Native侧对应ArkWeb_WebSchemeHandler系列C API,原生用@kit.NetworkKit代发并附加Authorization。这样token全程只在原生侧,H5完全无感知,既没有可窃取的凭据,也不受同源策略约束,token刷新和过期重试还能由原生统一管理。代价是所有H5请求都要过原生一层,需要处理请求体解析、流式响应、文件下载等边界,不适合有大量媒体流或第三方SDK自由发请求的页面。
如果把凭据落到Web内核,还要保证失效联动,清理的位置和顺序都要对,见下一节。
五、能力开放与失效传播
“H5点个按钮调起原生页”这类需求,最省事的实现是开放一个goToAppPage(pageUrl)通用路由,问题在于它把“能跳哪些页”的决定权交给了H5。正确做法是暴露意图方法,由原生决定路由,并对入参做schema校验:
- // 反例:决定权在H5手里
- goToAppPage(pageUrl: string, params?: ESObject) {
- RouterManager.push({ builderName: pageUrl, param: params });
- }
- // 正例:决定权在原生手里
- openMyOrders(): void {
- RouterManager.push({
- routerName: RouterConstants.ENTRY_HAP,
- builderName: 'mall:StoreMyOrderPage', // 常量,不接受H5入参
- });
- }
- openProductDetail(skuId: string): void {
- if (!/^[A-Za-z0-9_-]{1,32}$/.test(skuId)) {
- return; // 参数schema校验,静默拒绝
- }
- RouterManager.push({
- routerName: RouterConstants.ENTRY_HAP,
- builderName: 'mall:MallStoreDetailsPage',
- param: { skuId: skuId },
- });
- }
复制代码
好处是H5不需要知道原生路由表长什么样,路由重构不会打断线上H5。
失效传播要三层联动,顺序不能反:①调服务端登出接口让会话立即作废(安全层,根本保障);②清WebView Cookie让后续请求不再携带(本地层);③runJavaScript通知H5立即跳未登录页(体验层)。先清本地再调服务端,中间会有一个窗口期凭据仍然有效;如果凭据已通过Referer等途径复制出去,只有服务端作废能真正断掉。
多数实现只覆盖“用户主动退出”,还有三处必须补齐。第一处是被动踢下线:token在别处登录被挤下线、服务端主动作废时用户根本没点退出,只监听退出按钮会让H5一直“看起来已登录”,工程里通常已有踢下线事件(如APP_LOGIN_KICK_OUT一类广播),缺的是把它接到WebView。第二处是H5侧本地缓存要一起清,只清Cookie不通知页面,会出现“Cookie没了但页面还显示已登录、点按钮报401”的割裂状态:
- window.onNativeLogout = function (reason) {
- localStorage.clear();
- sessionStorage.clear();
- if (window.caches) {
- caches.keys().then(keys => keys.forEach(k => caches.delete(k)));
- }
- location.replace('/mall/login-required?reason=' + reason);
- };
复制代码
第三处是幂等与竞态:主动退出、被动踢下线、网络层401三个入口可能在几秒内同时触发,扇出动作必须幂等,第①步不能省。
另外两个容易被当成边缘情况的主流程要特别注意。多WebView实例下,clearAllCookiesSync()是全局的,若活动页、客服页、支付中间页各自依赖不同域的Cookie,一刀清空会把它们一起登出且难以归因,更精准的是会话Cookie配clearSessionCookieSync()。但ArkTS侧没有按域、按名删除Cookie的接口,清理粒度只有“会话”和“全部”两档,所以域的规划要在设计期做:登录态统一用会话Cookie,确需强隔离的独立业务可用incognito隐私模式WebView(Cookie只存内存、组件销毁即清,但要接受不落盘、页面重开需重新注入的代价)。切换账号不是登出而是换身份,要求“清旧态+注新态”原子完成,可行做法是换号时销毁并重建WebView,或用一次性票据让H5在新会话建立前完全无凭据;预加载的WebView尤其危险,它注入的是上一个账号的Cookie,复用前必须校验账号一致性。
六、ArkWeb API校准:四类常见误用
①注入时机。网上流传的webview.once('webInited')写法只对“进程内第一个WebView”成立。once()是全局一次性事件,内核完成初始化后第二个及之后的WebView不再触发回调,注入会静默失效(接口说明有明确描述)。而WebCookieManager是所有Web组件共享的全局单例,configCookieSync调用本身就会触发内核初始化。真正需要保证的是“注入发生在该实例的首次请求之前”,更稳的做法是放进组件自己的onControllerAttached,并在loadUrl之前执行,对每个实例都成立:
- Web({ src: '', controller: this.controller })
- .onControllerAttached(() => {
- webview.WebCookieManager.configCookieSync(domain, cookieValue);
- this.controller.loadUrl(this.mallUrl);
- })
复制代码
setUrlTrustList也应放在这里——该接口要求controller已关联组件(否则报17100001)。
②刷盘。写入成功不等于已持久化,这是“注入成功但偶发失效”的成因。Cookie每30秒周期性保存到磁盘,configCookieSync只写进内核内存,进程在这30秒内被杀(用户划掉、系统回收)就丢了。要立刻持久化需显式刷盘:
- webview.WebCookieManager.configCookieSync(domain, cookieValue);
- await webview.WebCookieManager.saveCookieAsync(); // 自API 9起提供
复制代码
版本对应别记错:saveCookieAsync自API 9就有;同步版本saveCookieSync反而是API 15才新增,且会阻塞调用线程直到落盘完成。API 9→11之间废弃的是setCookie/getCookie/deleteEntireCookie/deleteSessionCookie这批旧接口,网上“saveCookieSync已更名”的说法是错的。
③退出清Cookie。选用哪个方法取决于Cookie类型:登录态写成会话Cookie(不带expires/Max-Age)用clearSessionCookieSync(),精准且不影响其他域;写成持久Cookie则会话清理方法清不掉,只能退到clearAllCookiesSync()全量清理,代价是带走其他业务登录态。
④permission白名单。methodList给空数组看似配置了,实际表示没有为任何方法单独配权限,方法级管控并未生效。permission分两层:urlPermissionList是对象级(匹配则授权该对象所有方法),methodList是方法级(针对单个方法收紧)。要给敏感方法单独配:
- const BRIDGE_PERMISSION: string = JSON.stringify({
- javascriptProxyPermission: {
- urlPermissionList: [
- { scheme: 'https', host: 'mall.example.com', port: '', path: '' }
- ],
- methodList: [
- {
- methodName: 'getToken',
- urlPermissionList: [
- { scheme: 'https', host: 'mall.example.com', port: '', path: '' }
- ]
- }
- ]
- }
- });
复制代码
匹配规则三条:scheme和host精确匹配、不能为空、不支持通配符(通配符是新版本setUrlTrustList才支持);port留空表示不检查端口;path是前缀匹配,留空表示不检查路径。还有一条容易漏的交集原则:方法级最终白名单是对象级与方法级的交集,两侧必须同时配,只配一侧该方法就调不通。
七、八条实现细节
第一,SameSite默认是Lax而不是None。跨站跳转还想携带Cookie要显式设None; Secure,否则Cookie不会发送,这一点要跟H5确认跳转来源后再定。
第二,Cookie每30秒自动落盘,崩溃会丢最近30秒;另外在PC/二合一/平板上,即使会话Cookie也不会被saveCookieAsync()写入磁盘,多设备形态要单独验证。
第三,configCookieSync的“多条Cookie”要分清:官方有示例把多个键值对塞进一次调用,但接口说明和FAQ都明确不支持一次性批量设置,应多次调用。原因是value必须遵循Set-Cookie响应头格式,逗号拼法会让整串被当成一个Cookie的值,实际只写入一个:
- // 反例:看着像写了三个,实际只写入一个
- configCookieSync(url, 'a=b;c=d;e=f');
- // 正例:多个Cookie就多次调用
- configCookieSync(url, 'a=b; Path=/; Secure');
- configCookieSync(url, 'c=d; Path=/; Secure');
复制代码
附带规则:相同host、path、名称的Cookie会被新值替换;已过期的Cookie不会被存储;带Secure属性时url必须是https://否则写入失败;要覆盖已有的HttpOnly Cookie,value里同样要带HttpOnly属性——这是“Cookie注入后H5还是未登录”最隐蔽的成因之一。
第四,methodList和asyncMethodList不能混。methodList放同步方法,返回值直接回传H5;asyncMethodList放返回Promise的方法,其resolve值才回传。把异步方法误登记到methodList,H5侧await bridge.xxx()的then/await永远不触发,表现为“回调不触发”,极易被误判成“桥没注入”。
第五,动态注册要记得refresh()。用registerJavaScriptProxy()注册后必须调refresh()才生效,否则前端报“对象未定义”。
第六,组件销毁时调deleteJavaScriptRegister(name)释放桥对象,防内存泄漏。
第七,测试用例必须覆盖“未登录态”。很多线上事故是“退出后H5还在用旧态”,而自测时从来都是登录状态——加一条:登录→打开H5→原生退出→返回H5→断言处于未登录。
第八,多端统一抽象一层。iOS WKWebView、Android WebView、HarmonyOS ArkWeb的Cookie同步机制各不相同,抽一个H5SessionBridge接口各端实现,业务只调接口,否则同一套逻辑要在三端各踩一遍坑。
八、可直接贴进评审单的清单
新需求先过三步:归类(这次交互属于哪几类命题)、选通道(涉及凭据查风险矩阵、涉及调用查四层校验)、过清单。14条清单如下。
身份贯通组:URL里没有token、cookie、sessionId、手机号等敏感项(非敏感业务参数可留);凭据若落到内核,带齐HttpOnly+Secure+明确SameSite且URL是https;凭据没通过JSBridge返回值下发到脚本域,确需下发时用一次性票据而非长期token;域关系明确,跨域场景已选定代理或票据方案。
能力开放组:桥对象注册时配置了permission且methodList对敏感方法单独收紧;加载域名有系统级白名单(setUrlTrustList或等价手段)覆盖location.href、302、iframe导航;暴露的是意图方法而非通用路由/builderName透传;所有H5入参做schema校验,能力按读/跳/写/敏感分级,敏感级不下放。
失效传播组:退出、被动踢下线、网络层401三个入口都接到同一套扇出逻辑且动作幂等;顺序是“服务端作废→清内核Cookie→通知页面”不能反向;通知H5时清了localStorage/sessionStorage/Cache API;会话Cookie用会话级清理方法,没有误用全局clearAllCookiesSync()波及其他WebView。
再加两条跨组项,最容易在迭代中被破坏:切换账号、WebView预加载复用前校验了账号一致性;“登录→打开H5→退出→返回H5”有用例覆盖。
九、这套框架什么时候不适用
纯原生页面没有WebView,四条通道不存在,直接走原生鉴权即可。系统浏览器、小程序等不受自己控制的容器,域模型不同,无法注入Cookie也无法注册桥,只能走标准OAuth/深链回跳。请求形态高度不可控的页面(WebRTC、大范围流式下载、三方SDK自由发请求),请求代理方案的维护成本会超过收益,应转向票据方案。完全自有、同域、同发布链路且已有严格XSS防护的H5,框架仍适用,但能力分级可适当放宽,放宽要写进评审记录而不是默认省略。
总而言之,凭据不进脚本域、能力按意图授权、失效扇出到每个副本,这三条原则加上ArkWeb的几处API校准,基本能覆盖混合应用里绝大多数“token放哪儿”“退出后为何没失效”的坑。 |