把 WebSocket 长期密钥移出前端:短期票据改造与上线闭环

关闭 Source Map 不能让浏览器中的长期密钥变得安全。本文复盘一次 STOMP 鉴权改造:登录后签发短期票据、服务端兼容旧框架,再通过负向测试和密钥轮换完成上线闭环。

🎧 文章导读

前端环境变量,不是密钥保险箱

这次排查的入口,是生产前端环境文件里的 WebSocket 固定凭证。它随着构建进入 JavaScript,再作为 STOMP CONNECT 头发送到服务端。只要浏览器需要使用它,用户就能够从资源或运行过程里取得它。

关闭 Source Map 可以减少源码暴露,混淆可以增加阅读成本;两者都不能让发送给浏览器的长期密钥保密。修复方向应是:长期密钥留在服务端,浏览器只拿短期、限定用途的连接票据。

短期票据连接链路
图 1:票据由已登录用户申请;长期凭证不再随前端构建分发。

这篇复盘记录的是一次兼容既有框架的增量改造,示例中的凭证、用户和服务名称均做了替换,不包含原环境的真实密钥。

先划清 HTTP 握手与 STOMP CONNECT

WebSocket 建连成功,不等于 STOMP 认证通过,更不等于用户有权订阅任意主题。排查时至少要区分三个层次:HTTP/SockJS 传输、STOMP 连接认证、后续消息与目的地授权。

浏览器原生 WebSocket 和 SockJS 对自定义 HTTP 请求头存在限制。Spring 官方给出的一个接入方式是,在 STOMP 连接帧中传认证信息,再通过 ChannelInterceptor 处理。这里应当理解为消息协议层的头,不要与握手 HTTP 头混用。参见 Spring Token Authentication。

脱敏后的前端示意如下。getWsTicket 必须使用已有登录态,生产传输应使用 HTTPS/WSS:

1
2
3
4
5
6
async function connectAlarmChannel(stompClient) {
const { ticket } = await getWsTicket({ audience: 'alarm' });
stompClient.connect({ wsTicket: ticket }, onConnected, onConnectError);
}

// 每次重连重新申请,避免重复使用已经过期的票据。

握手路径的放行也要和协议层认证配合。本次曾遇到一个服务的 SockJS 路径没有进入预期白名单,导致请求还没走到票据拦截器就被挡住。修复路径规则时,应逐项确认传输与消息认证仍然存在,不能用“一律放开 WebSocket”替代鉴权。

票据校验,要检查的不只是签名

本次票据由用户标识、服务用途、过期时间和 HMAC-SHA256 签名组成,默认有效期 5 分钟。它是项目自定义格式,不应直接称为标准 JWT。

1
2
3
payload = userId + "." + audience + "." + expiresAt
signature = HMAC_SHA256(serverSecret, payload)
ticket = payload + "." + signature

这只是字段结构示意,实际实现还需约定编码、分隔符和时间单位。服务端校验应包含格式、签名、过期时间和用途;用户身份来自受保护的签发接口,不能由浏览器随意指定。

检查项 解决的问题
签发接口要求登录 匿名用户不能直接申请票据
签名验证 字段不能被随意修改
expiresAt 缩短被窃取票据的可用时间
audience 一个服务的票据不能直接用于另一个服务
常量时间比较 降低签名比较引入的时序差异

短 TTL 只缩短重放窗口,不意味着一次性消费。本次没有实现 nonce 去重或主动吊销,也没有在这次记录中证明所有订阅主题都已按用户隔离。连接认证和业务授权仍须分别检查。

没有框架源码,怎样渐进接入

原认证逻辑封装在既有框架 jar 内,按配置中的静态客户端凭证校验。直接替换框架不在此次范围内,所以业务层注册优先执行的拦截器:先校验票据,再在服务端内部补齐旧框架需要的认证头,让已有后续流程继续工作。

这里最重要的约束是“服务端内部”。长期密钥不能出现在取票接口响应,也不能回写给前端。适配层的执行顺序则必须用实际配置和测试证明,不能只看到一个 @Order 注解就认为一定正确。

1
2
3
4
5
6
7
// 适配思路示意:省略具体框架 API 和异常处理。
if (isConnectFrame(message)) {
TicketClaims claims = ticketCodec.verify(ticket, expectedAudience);
attachAuthenticatedIdentity(message, claims.getUserId());
supplyLegacyHeadersInsideServer(message, serverCredentials);
}
return message;

上面是便于解释的伪代码,不能直接当成可复制的完整实现。项目里的真实改动还涉及可变消息头和原框架拦截器兼容;迁移到其他 Spring 版本时,应重新验证注册顺序与消息头生命周期。

兼容期与密钥轮换边界
图 2:新前端上线只是中间节点,关闭旧认证和轮换泄露密钥才形成闭环。

上线最容易漏掉的是密钥轮换

本次为了渐进发布,提供了旧凭证兼容开关。先上线支持票据的后端,再升级前端,观察旧认证回落日志是否仍出现,最后关闭旧认证。

但还有一步不能漏:轮换已经暴露过的长期密钥。 本次历史设计复用该密钥进行票据 HMAC 签名;如果只从前端删掉它,知道旧密钥的人仍可能签出合法外观的票据。

1
2
3
4
5
6
# 示例配置,不包含真实凭证。
websocket:
ticket:
ttl-seconds: 300
legacy-enabled: false
# 签名密钥通过服务端安全配置注入,不写进前端环境文件。

稳妥的发布顺序是:后端兼容上线、前端取票上线、确认连接和业务消息、观察旧客户端、关闭兼容并轮换密钥、再次验证。若准备回滚,也应明确恢复旧认证会重新引入什么风险,不能把兼容开关永远留在打开状态。

原笔记列出的 E2E 检查包含匿名取票拒绝、两服务取票和连接、旧凭证兼容、假票据、空凭证、跨服务票据与篡改票据等,记录为 10/10 通过;另外验证了关闭兼容后的行为。本篇未重新运行这些业务测试。

连接正常,为什么还是没有告警弹窗

最后一个排障转折很有代表性:浏览器取票成功、连接成功,界面却没有弹窗。继续查看发现,规则中的弹窗接收人名单并不包含测试用户,前端因此正确过滤了消息。

所以验收不能只看“连上了”,也不能只看“没弹窗”。应逐层确认:取票结果、协议连接、业务事件生成、通知接收人、消息到达与页面展示。WebSocket 连接修复,不会自动修好一条错误的通知名单。

这次改造记录了编码、测试与合并,但上线关闭兼容、轮换密钥仍列为待办。写博客时保留这一状态,比一句“安全整改完成”更有价值:它提醒我们,凭证整改需要走完分发、校验、迁移和撤销的完整生命周期。

延伸阅读


本文根据 2026 年 9 月 22 日至 10 月 22 日的工程笔记整理。账号、凭证、地址和业务样本已脱敏;通用示例已在正文标明,历史验证结果以原记录为准。