商城接口被改价是上线后最容易出、也最难查的一类安全问题。客户端提交订单时把price从 199 改成 0.01服务端直接信任这个值入库或者抓一次下单请求原样重发几百次把优惠券、积分、库存刷穿。很多团队的第一反应是我们走了 HTTPS也校验了登录态怎么还能被改但这两件事解决的都不是这个问题。HTTPS 解决的是传输链路不被窃听和篡改登录态解决的是这个请求是谁发的。它不解决三个问题这个请求在发出之后有没有被改过参数、这个请求是不是被重复使用、这个资源是不是真的属于当前用户。我们在随商的商城项目开发和实施过程中这三类问题的暴露顺序基本是固定的先是被业务方发现有人改了价格下单接着是活动接口被重放刷券最后才在安全评审里翻出越权查询。本文按这三道关的顺序讲落地方式签名验签、防重放、越权校验每部分给出数据结构、Redis 设计和可以直接用的代码最后说明哪些场景这套方案会失效、应该换成什么。一、先列清楚商城接口的真实攻击面讨论方案之前把常见手法列出来后面的设计才有参照参数篡改改金额、改数量、改折扣、改收货地址归属。请求重放第一次下单/领券的合法请求被抓包后重复提交尤其是活动、秒杀、抽奖类接口。水平越权改orderId1001为1002遍历出别人的订单详情、物流、对账单。垂直越权普通用户账号直接调用运营侧接口比如改商品状态、改订单金额。伪造回调伪造第三方支付/物流的回调报文直接把订单改成已支付。接口刷量不看参数是否合法只用脚本高频打某一个接口把数据库或下游拖垮。这里面对应三种不同性质的控制篡改和重放属于报文完整性 时效性越权属于授权刷量属于限流。用一套机制去做全部三件事通常做不干净分开做反而更简单。二、签名验签只签 body 是最常见的错误签名的作用是让服务端能判断收到的这份报文和我发给你的那份是不是同一份。做法是基于共享密钥appSecret对一份规范化字符串做 HMAC-SHA256客户调用方和服务端各算一次比对结果。关键在于规范化字符串里必须包含什么。只签 body 是不够的因为同一个 body 可以被改路径打到另一个接口上。实践中的最小集合是METHOD \n PATH \n 排序后的query \n body的SHA256 \n timestamp \n nonce两个容易被忽略的点query要按 key 的 ASCII 升序排列并对 key 和 value 做 URL 编码双方编码方式必须完全一致body建议签哈希而不是原文既避免超长字符串拼接也避免解析成对象再重新序列化导致键顺序和空格变化。publicfinalclassOpenApiSign{privatestaticfinalStringALGOHmacSHA256;/** 允许的客户端与服务端时间差超过直接拒绝 */publicstaticfinallongWINDOW_MILLIS5*60*1000L;publicstaticStringbuildStringToSign(HttpServletRequestreq,StringbodySha256){Stringmethodreq.getMethod().toUpperCase(Locale.ROOT);Stringpathreq.getRequestURI();// 不含 query stringreturnString.join(\n,method,path,canonicalQuery(req.getParameterMap()),bodySha256null?:bodySha256,// 无 body 时用空串不要用 nullreq.getHeader(X-Timestamp),req.getHeader(X-Nonce));}/** 按 key 升序 URL 编码空值参数直接丢弃 */privatestaticStringcanonicalQuery(MapString,String[]params){returnparams.entrySet().stream().filter(e-e.getValue()!nulle.getValue().length0).sorted(Map.Entry.comparingByKey()).map(e-enc(e.getKey())enc(e.getValue()[0])).collect(Collectors.joining());}privatestaticStringenc(Stringv){returnURLEncoder.encode(v,StandardCharsets.UTF_8);}publicstaticStringsign(StringappSecret,StringstringToSign){try{MacmacMac.getInstance(ALGO);mac.init(newSecretKeySpec(appSecret.getBytes(StandardCharsets.UTF_8),ALGO));returnHexFormat.of().formatHex(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)));}catch(GeneralSecurityExceptione){thrownewIllegalStateException(签名计算失败,e);}}/** 比较用常量时间方法避免通过响应时间差逐字节猜签名 */publicstaticbooleanverify(StringappSecret,StringstringToSign,StringclientSign){byte[]expectedsign(appSecret,stringToSign).getBytes(StandardCharsets.UTF_8);byte[]actualclientSignnull?newbyte[0]:clientSign.getBytes(StandardCharsets.UTF_8);returnMessageDigest.isEqual(expected,actual);}}签名环节踩坑集中在双方算法细节不一致一方用 Hex 输出、另一方用 Base64一方对参数值编码、另一方不编码一方把 JSON 解析成对象再序列化。这类问题的定损方式很简单——把双方各自的待签名字符串原样打进日志对比不要只打印签名结果。三、防重放timestamp 加窗口nonce 加一次性签名解决没被改但它对原样重发完全无效——重发的那份报文签名是正确的。所以还要加两个字段。X-Timestamp是请求发出的毫秒时间戳服务端校验与当前时间差不超过窗口通常 5 分钟。它把重放的可用时间压缩到了一个短期区间内。X-Nonce是每次请求都不同的随机串UUID 即可服务端把它记下来同一个 appId 下重复出现就拒绝。这一步才能真正把窗口内的重放堵死。两个字段都必须参与签名。如果 nonce 不参与签名攻击者可以在原报文中替换一个全新的 nonce一次性校验就形同虚设。nonce 落 Rediskey 里带 appId否则不同调用方会互相碰撞privatestaticfinalStringNONCE_KEYopen:nonce:%s:%s;// appId, noncepublicvoidcheckNonce(StringappId,Stringnonce,longtimestamp){longnowSystem.currentTimeMillis();if(Math.abs(now-timestamp)OpenApiSign.WINDOW_MILLIS){thrownewApiSecurityException(请求已过期请检查客户端时间);}StringkeyString.format(NONCE_KEY,appId,nonce);// SET NX TTL 是一条原子命令不要先 get 再 setBooleanfirstredis.opsForValue().setIfAbsent(key,1,Duration.ofMillis(OpenApiSign.WINDOW_MILLIS60_000));if(!Boolean.TRUE.equals(first)){thrownewApiSecurityException(重复请求);}}TTL 比窗口略长即可不要设成永久。常见的两个错误一是 nonce 不设 TTL几个月下来 Redis 里堆了几千万个键二是把时间戳超窗和参数错误合并返回同一个提示客户端时钟偏差的问题会拖很久才被发现——时间戳超窗的响应里应该带上服务端当前时间方便调用方校准。时钟偏移是这套机制里最现实的运维问题。服务端统一配 NTP同时把时间戳超窗拒绝数和nonce 重复拒绝数做成指标并设置告警阈值因为这两个数字的突增通常意味着出现了攻击或者某个调用方发了新版本。四、越权校验把归属条件写进查询而不是写进判断签名和防重放都过了请求依然可能是越权的——它确实是由合法调用方发出的只是访问了不属于自己的资源。水平越权的根因通常是两点。第一是资源 ID 可枚举订单表主键自增接口直接暴露id1001、1002挨个试。对外接口不应该返回数据库主键应该用单独的对外单号业务单号 随机后缀让 ID 不可预测。第二是归属校验写错位置// 反例只判断资源存在不判断它属于谁OrderorderorderMapper.selectById(orderId);if(ordernull)thrownewBizException(订单不存在);returnconvert(order);// 正确把当前主体作为查询条件的一部分ShopOrderorderorderMapper.selectOne(Wrappers.ShopOrderlambdaQuery().eq(ShopOrder::getOrderNo,orderNo).eq(ShopOrder::getMerchantId,LoginContext.currentMerchantId()));if(ordernull)thrownewBizException(订单不存在);第二个写法多做了一件事把不属于我和不存在返回同一个结果。这不是为了好看而是避免攻击者通过响应差异来判断某个订单号是否存在。垂直越权靠接口级权限解决也就是在网关或拦截器上做路径 scope 的匹配不要依赖前端不展示按钮。同时要明确一点服务端必须对金额、折扣、库存这类字段保持权威。客户端提交的price只能当作预期的价格快照服务端要用商品当前的计价规则重算不一致就直接拒绝并把差异记入风控日志。签名保证了客户端提交的内容没被第三方改过但它保证不了客户端提交的内容本身是对的——用户自己的客户端就在攻击者手里。五、限流与开放接口的凭证模型签名 nonce 挡不住合法调用方高速刷接口请求每次都是新的、签名都合法。所以第三道补充是限流维度按 appId 和接口分别统计而不是只按 IP——第三方系统对接往往从一个出口 IP 发起按 IP 限会把正常业务限死。令牌桶用 Lua 在 Redis 里做保证取令牌和扣减是原子的-- KEYS[1] 限流 key如 open:rate:{appId}:{api}-- ARGV: 1桶容量 2每秒补充速率 3当前毫秒时间戳 4本次消耗令牌数localcapacitytonumber(ARGV[1])localratetonumber(ARGV[2])localnowtonumber(ARGV[3])localneedtonumber(ARGV[4])localbucketredis.call(HMGET,KEYS[1],token,ts)localtokentonumber(bucket[1])localtstonumber(bucket[2])iftokennilthentokencapacity;tsnowendtokenmath.min(capacity,token(now-ts)/1000*rate)iftokenneedthenredis.call(HSET,KEYS[1],token,token,ts,now)redis.call(EXPIRE,KEYS[1],120)return0endredis.call(HSET,KEYS[1],token,token-need,ts,now)redis.call(EXPIRE,KEYS[1],120)return1调用方凭证单独建表管理密钥的轮换能力要在表结构里预留出来否则换一次密钥就要所有对接方同步停机CREATETABLEopen_app(idBIGINTNOTNULLAUTO_INCREMENT,app_idVARCHAR(32)NOTNULLCOMMENT调用方标识,app_secretVARCHAR(256)NOTNULLCOMMENT加密存储禁止明文与硬编码,prev_secretVARCHAR(256)DEFAULTNULLCOMMENT上一把密钥轮换过渡期使用,prev_expire_atDATETIMEDEFAULTNULLCOMMENT旧密钥失效时间,scopesVARCHAR(512)NOTNULLDEFAULTCOMMENT接口权限范围逗号分隔,allow_ipsVARCHAR(512)DEFAULTNULLCOMMENT可选来源 IP 白名单,statusTINYINTNOTNULLDEFAULT1COMMENT1 启用 0 停用,PRIMARYKEY(id),UNIQUEKEYuk_app_id(app_id))ENGINEInnoDBDEFAULTCHARSETutf8mb4COMMENT开放接口调用方;验签时先试app_secret失败再试prev_secret两者都不通过才拒绝。对接方改配置需要时间双密钥过渡是一个很划算的设计。六、Redis 不可用时的降级策略nonce 和限流都依赖 Redis。Redis 挂了怎么办需要在设计阶段就给出结论而不是等故障时临时决定。按接口风险分级处理涉及资金、库存、券码核销的写接口Redis 不可用时拒绝对外服务fail-close宁可短暂不可用也不能让重放和刷单进来纯查询类接口可以降级为只校验时间戳窗口配合本地 Caffeine 做单机计数兜底。把这个判断做成配置项而不是散落在代码里的 if。随商的实现取舍与适用范围在随商的项目里三道关是分层放的验签、防重放、限流放在统一的 API 过滤器里因为这三件事只依赖请求报文和 appId不依赖业务越权校验放在 Service 层因为它需要业务的归属字段——多商户模式下是merchant_idB2B 企业采购场景下是企业客户 ID 和采购组织这些信息在过滤器阶段拿不到硬塞进去只会让过滤器依赖业务模型。第二个取舍是密钥的生命周期。我们目前的商城产品在open_app层面就预留了prev_secret和失效时间密钥轮换走双密钥过渡对接方不需要停机改配置。这一步看着不起眼但没有它很多项目到后期是不敢换密钥的。第三个取舍是关于覆盖范围。随商在 ModulithShop 这类模块化单体路线里把接口安全做成一个独立模块过滤器 凭证管理 限流业务模块不重复实现对外接口和内网接口共用同一套入口避免出现新加的模块忘了加防护。这套方案适合的场景是有明确调用方的对接类接口第三方 ERP、WMS、物流、支付机构的服务端对接企业客户对接自家采购商城ISV 接入多商户平台的开放 API。这些场景双方都是服务端密钥可以安全保存。它不适合的场景也要说清楚。面向 C 端 H5 和小程序的接口做不了 HMAC 签名因为前端代码里的任何密钥都等于公开。这类接口的正确做法是短时效 token 设备指纹 服务端权威计价 业务风控而不是把 appSecret 塞进前端。另外当接入方是不特定的公开开发者时让平台维护并分发大量共享密钥会成为负担这时应该转向 OAuth 2.0 授权码模式配合非对称签名——调用方私钥签名、平台公钥验签平台不再持有调用方密钥。至于 nonce 存 Redis 的成本在请求量很大时可以按接口风险分级只对写接口和资金类接口做完整的一次性校验查询类接口保留时间戳窗口和限流即可。几个值得记下来的点签名只签 body等于允许同一份报文打到别的路径上。nonce 不进签名等于没做防重放。nonce 不设 TTL等于给 Redis 造了一个只增不减的表。对外接口直接用自增主键做资源标识越权只需要改一个数字。归属校验写成查到再判断响应差异会侧漏资源存在性。客户端时间没校准第一个报警的不是攻击者是正常对接方。总结商城接口安全里登录态只回答了你是谁。签名回答报文有没有被改时间戳和 nonce 回答请求是不是第一次来归属校验回答这条数据是不是你的限流回答你能来多快。四件事各管一维混在一层里做通常是一维做得很细、另外几维其实是空的。随商技术团队长期从事企业商城软件产品研发及项目实施涉及 Java、Golang、B2B、B2B2C、多商户、企业采购、工业品集采及跨境商城等技术场景。产品交付源码支持独立二次开发如需了解产品与技术方案可在搜索引擎搜索「随商商城」。