跳到正文
Konlue的博客
返回

跨系统回调的安全设计:鉴权链、防重放与幂等

场景

审批状态机管的是“内部怎么走”,这篇管“外面怎么进来”:审批结果在上游系统产生,要推给我们。接收方是我负责的申报模块,一共五个回调接口,覆盖受理结果、查验结果、报送反馈、文件下载这几类事件,上游每推进一步,就有一个回调落到我们系统上。

这类接口的麻烦在于调用方是外部系统,两条内网假设全部失效:不能信网络(包可以被截获、重放),也不能信请求本身(任何人都能朝这个地址发请求)。所以回调接口要先回答三个问题:你是谁、这个请求是不是刚发生的、内容有没有被改过。鉴权链、防重放、签名分别回答这三问,幂等则处理“哪怕前三问都过了,请求还是会重复”的现实。

鉴权链:appId / appSecret → code → token

最省事的做法是发一个长期 token,请求头带上就完事。我们没用长期 token,原因很实际:token 会泄露。它放在对方系统里,日志可能打印、代码可能硬编码、运维可能贴到群里;而两个系统之间换密钥要走流程、要约时间窗口,泄露之后的吊销成本比想象中大。

所以拆成三段。接入时发一对 appId / appSecret,appSecret 只存在于双方服务端配置里,永远不上网络;调用前对方先拿 appId 加 appSecret 换一个短时效 code;再用 code 换 token,token 有效期只有分钟级,过期就重新走一遍。

这样每次实际调用的凭证只有几分钟寿命,泄露窗口被压到最小;真正需要保密的 appSecret 不参与日常请求。代价是对方每次会话多一次换码往返,放在服务端对服务端的调用里,这点开销可以忽略。

sequenceDiagram
    autonumber
    participant U as 上游系统
    participant P as 申报审批平台
    Note over U,P: 接入阶段:线下发放 appId / appSecret,只存双方服务端
    U->>P: appId + appSecret
    P-->>U: 短时效 code
    U->>P: code
    P-->>U: token(分钟级有效期)
    Note over U,P: 每次回调:token + timestamp + nonce + sign
    U->>P: 回调请求(四件套请求头 + 业务数据)
    P-->>U: 验签、幂等判定后签收

防重放与防篡改:四个请求头

token 只证明“调用方是我授权过的”,不证明“这个请求是刚发出的”。抓包的人拿一个合法请求原样重发,token 校验照样通过。所以每个回调请求还要带四个头:timestamp、nonce、sign,外加 token 本身。

四件套缺一不可:只看 timestamp,攻击者改个时间戳就行;只靠 nonce,内容可以被改;只有签名没有时间约束,一个旧请求可以永远重放。合在一起,攻击者要么完整伪造(需要 appSecret,拿不到),要么原样重放(被 timestamp 或 nonce 拦住)。

验签收敛在拦截器里做,业务代码不感知:

// 回调签名校验拦截器(简化)
public boolean preHandle(HttpServletRequest req) {
    if (expired(req.getHeader("timestamp"), 5, ChronoUnit.MINUTES)) {
        return false;                                   // 老请求
    }
    if (!redis.tryAcquireNonce(req.getHeader("nonce"), 5, ChronoUnit.MINUTES)) {
        return false;                                   // 窗口内重放
    }
    String expected = hmacSha256(secret, canonicalString(req));
    return constantTimeEquals(expected, req.getHeader("sign"));
}

两个实现细节值得写下来。一是拼接顺序要写成文档交给对方,参与签名的字段列表也要固定:我们约定“参数名按字典序排序、值不做 URL encode、空值不参与”,这条规则在双方各自实现时最容易走样。二是比对签名用常量时间比较,至少别用会短路的 ==——这个场景被计时攻击打穿的实际门槛很高,但写对也不费事。

幂等:假设回调一定会重复

前三关都过了,还有一个现实:上游的重试策略不归我们管。网络抖动它重发,超时判断保守它重发,上游升级后的补推更是成批地来。回调必须默认会重复、会乱序、会迟到。

幂等键是申报单 id 加状态版本。上游推送的结果里带着它针对的状态版本号,我们本地每次状态迁移也维护自己的版本(状态迁移走带版本号的乐观锁更新,版本不匹配直接失败)。判断放在状态迁移的同一个事务里:

返回成功很重要。上游收到成功就不再重试;返回失败只会让它加倍重推,制造更多需要幂等掉的流量。而“同事务”是底线:版本判断和状态更新必须在同一个数据库事务里,拆开就重新有了并发窗口,等于白做。

乱序的处理也是这一个判断顺带完成的:后到的旧版本自然落进“小于等于”分支,我不需要为乱序单独设计什么。

踩坑

验签“随机”失败。 联调初期验签时好时坏,最后发现是 URL encode:对方框架对参数值做了 encode,我们按原始值计算签名。错得“随机”,是因为只有参数里恰好出现需要编码的字符(中文、空格、加号)时才会失败。排查半天,结论现在写在接入文档第一行:参与签名的值一律用原始值。

nonce 的存储成本。 每个请求存一个 nonce,TTL 5 分钟,量大之后就是纯粹的内存税。按我们的回调量级算过,5 分钟窗口内的占用可以忽略,直接 SET NX PX 最简单。如果量大到这笔税肉疼,更合理的方向是按时间分桶、整桶过期,而不是缩短 TTL——缩短窗口等于放宽重放。这个优化我没有做,量没到。

效果

这套设计随二期上线,五个回调接口没有出过安全事件。上游某次升级后的批量补推(同一批结果重发了几遍)被幂等层安静地吃掉了,业务方没有感知。安全设计做对的标准之一,就是它工作时没人注意到它。



下一篇
列表接口 50ms → 12ms:一次完整的查询优化复盘