ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

返利平台接口安全实战:签名、防重放、加密与权限体系设计

返利平台接口安全实战:签名、防重放、加密与权限体系设计

做电商返利平台,技术上最核心的一句话是:返利是资金流动的凭证,接口就是闸门。我在带团队搭建第一个返利系统的初始阶段,对用户增长、返利比例计算、和上游电商联盟的对账这些问题关注很多,直到有一次线上预警把我们从业务兴奋中打醒——有人绕过App前端,直接向后端接口批量提交伪造的订单信息,试图把返利刷进自己的账户。从那天起,接口签名、防重放、数据加密、权限体系这四件事,被我们提到了和返利规则计算同等的优先级。

这篇文章把我们实际落地过的一套安全基线完整整理出来,不只讲方案,也讲为什么这样设计、踩过什么坑。适合正在搭建返利平台后端、需要完善开放接口安全方案的团队参考,也适合做资金凭证类系统(优惠券、积分、佣金、补贴)的开发同学阅读。安全没有银弹,但有可复用的套路。

1. 返利平台为什么必须把安全当成第一需求

1.1 资金凭证场景和其他接口的差异

普通的信息类接口被攻击,影响往往是数据泄露或服务不可用,属于名誉和可用性问题。返利接口不一样,它直接和钱挂钩:用户下单后平台要向上游电商联盟确认订单,然后计算返利金额并记录到用户账户,最后用户发起提现。任何一个环节的接口被伪造、被重放、被越权调用,最终结果都是资金从平台账户里流出。

这个差异决定了安全设计的目标。我们做返利平台时,第一优先级不是“让接口更安全”这种模糊概念,而是四件事:接口签名防止参数被篡改、防重放机制防止同一笔请求被反复生效、数据加密防止敏感信息泄露、权限体系防止有人调用不属于自己的接口和数据。四者缺一不可,少了任何一个,其他防线的效果都会打折扣。

1.2 一次被绕过的真实风险

上线早期,我们只做了HTTPS,接口没有签名,也没有幂等。某天看数据时发现有个账户在凌晨短时间内产生了大量“订单”,而且这些订单在电商联盟侧根本查询不到。追查之后发现,调用方直接抓包拿到了App的接口格式,然后写脚本模拟下单回调,让服务器以为订单真实存在,于是给账户累计了一笔不小的返利。

好在那次金额较小,人工对账发现了问题,没有造成实际打款。但这件事给了我们很直接的教训:返利平台的接口不能默认调用方是“自己人”,必须假设每一个接口都可能被脚本攻击,并按这个假设去设计安全体系。从零开始建设安全模块时,我们按下面这张表来规划分工,每一个模块解决一类问题。

安全模块核心防护目标对应威胁
接口签名参数不可篡改伪造请求、修改金额/用户ID
时间戳+nonce防重放同一请求只能生效一次重复领取、重复回调、刷单
数据加密与脱敏敏感信息不可见拖库泄露、日志泄露、中间人窃听
权限体系调用者只能操作自己的数据水平越权、垂直越权

1.3 四个模块的边界关系

签名解决的是“谁发的、有没有被改”,防重放解决的是“同一句话能不能重复说”,数据加密解决的是“内容被偷看了怎么办”,权限体系解决的是“这个人能不能做这件事”。它们不是同一件事,也不能互相替代。签名做得再好,如果同一个合法请求可以重复提交,签名没有作用;防重放做得再好,如果有人伪造了一个带有正确时间戳和nonce的请求,防重放也拦不住。返利平台的每一笔交易都要经过四层检查,才能进入业务逻辑。

2. 接口签名:让每个参数都不能被偷换

2.1 为什么选HMAC-SHA256而不是普通Hash拼接

很多团队刚开始做签名时会用MD5/SHA1把参数拼接起来加个固定盐再Hash,这样确实能防住“随手改一下参数”的低级攻击,但存在几个隐患:普通Hash没有密钥参与运算的标准化方式,盐很容易因为代码重构泄露;MD5/SHA1在抗碰撞能力上已经不适合用于安全场景;一旦拼接规则被逆向出来,攻击者完全可以自己算出合法签名。

我们最终选用HMAC-SHA256。HMAC是标准化的密钥散列函数,签名强度同时依赖密钥和算法;SHA256输出256位摘要,当前没有可行的碰撞攻击路径。调用方和服务端各自持有同一个secret,服务端按同样的规则重新计算签名,用恒定时间的比较函数比对,就能确认参数是否被篡改。之所以不选RSA数字签名,是因为我们的调用方主要是自有App、小程序和少量合作方,对称密钥模型足够满足需求;RSA的非对称管理更适合开放生态里的第三方开发者,对内部系统反而增加密钥分发成本。

2.2 参与签名的字段与排序规则

签名规则越严格,安全性越高。我们规定:除了sign本身,其他所有请求参数都要参与签名,包括appId、timestamp、nonce以及业务参数。参数按key的字典序升序排序,用等号连接键值对,再用&拼接,最后在串尾部追加key=secret,以整串作为HMAC-SHA256的输入。

这个规则看起来简单,实际落地有几个容易出问题的细节。第一个是空值:我们的做法是空字符串参与拼接,这样调用方漏传参数会导致签名不一致,反而容易暴露异常请求。第二个是类型一致性:所有值都要先转成字符串再拼接,数字1不能和字符串"1"混用,否则服务端和客户端算出来的签名不一样。第三个是嵌套对象:如果业务参数里有数组或字典,明确约定用标准JSON序列化后的字符串参与拼接,不允许自定义格式。

下面是Flask框架下签名校验的核心实现,我们线上也是类似的逻辑:

import hmac import hashlib from flask import request, jsonify def generate_sign(params: dict, secret: str) -> str: # 过滤掉sign字段自身,然后按key排序 items = [(str(k), str(v)) for k, v in params.items() if k != "sign" and v is not None] items.sort(key=lambda x: x[0]) raw = "&".join(f"{k}={v}" for k, v in items) message = f"{raw}&key={secret}" return hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest() def verify_sign(app_secret: str, params: dict) -> bool: sign = params.get("sign", "") expect = generate_sign(params, app_secret) # 恒定时间比较,避免时序攻击 return hmac.compare_digest(expect, sign) @app.route("/api/rebate/claim", methods=["POST"]) def claim_rebate(): params = request.get_json(force=True) app_id = params.get("appId") secret = get_secret_by_app_id(app_id) if not verify_sign(secret, params): return jsonify({"code": 403, "msg": "invalid signature"}), 403 # 后续业务逻辑...

2.3 密钥管理的基本姿势

签名算法可以公开,密钥绝不能。返利平台上游方多、接口载体多,密钥管理容易出各种问题。我们最初把secret放在Git仓库的配置文件里,某次代码仓库权限被误调宽,虽然很快收回了权限,但为了安全必须轮换所有密钥,耗时一整天。从那以后改为配置中心统一管理,按调用方维度隔离。

每个调用方分配一对appId和secret,App端、小程序端、运营后台、内部定时任务各自不同。App端可以预置属于自己的secret吗?坦白说,严格意义上客户端里的secret是可以被逆向的,所以真正高安全的场景会使用非对称签名或动态票据。我们评估过风险:返利平台App端的secret泄露后,攻击者最多替自己账户生成合法签名,但无法冒充服务端,也无法获取其他人的数据,真正关键的数据权限是靠另一个维度限制的。因此现阶段App端采用对称密钥可以接受,但要避免把全局统一secret放进去。

2.4 签名校验失败时的分级处理

校验失败不能只回一个笼统的错误,但也不能把细节全暴露给请求方。我们的做法是区分错误码:缺参数返回400,签名错误返回403,时间戳超过允许窗口返回401,nonce重复返回409。日志里记录完整请求参数和失败原因,给前端返回时只给“无效请求”“签名过期”“重复请求”三类提示,避免攻击者利用详细错误信息探测签名规则。

另外,对连续签名失败的来源IP要做自动告警。我们把签名失败率超过一定阈值的调用方视为潜在攻击,进入风控观察列表,必要的时候直接拉黑一分钟。签名逻辑最好放在独立的中间件里,对所有外部接口生效,而不是每个视图函数手动调用。

3. 防重放:时间戳窗口加nonce缓存的双保险

3.1 重放攻击在返利场景的杀伤力

签名能保证参数没被篡改,但不能保证同一份参数只被使用一次。假设一个正常用户领取了一张10元返利券,领券请求的签名和时间戳都是合法的。如果接口没有防重放逻辑,攻击者只需要在脚本里循环发送同一个请求,就能重复领取。更隐蔽的是订单回调场景:上游电商联盟回调通知“订单已确认”,平台给用户记账返利,如果这个回调被重放,用户账户会被重复入账。

我们的经验是,只要涉及资金写入的接口,都必须把幂等当成和签名同级别的硬要求。签名解决“能不能伪造”,防重放解决“能不能重复”,两者配合,攻击者既改不了参数,也没法用旧请求刷钱。

3.2 时间戳窗口的取舍与时钟偏移处理

常见做法是客户端在请求里带上timestamp,服务端检查当前时间和timestamp的差的绝对值。窗口设多大很讲究:窗口太小,用户手机时钟稍微不准就会被拒绝;窗口太大,重放攻击的有效时间变长。我们线上用的是±300秒,也就是五分钟窗口。

选这个值的原因有三层。一是返利平台的用户请求完全可能来自旧版本App,这些客户的设备时钟可能存在数分钟偏差。二是五分钟的窗口配合nonce缓存已经能有效拦截绝大多数脚本攻击,再小的窗口并不能带来质的提升。三是五分钟窗口内如果出现重放,nonce缓存会拦住,如果攻击者迟了五分钟还在重放,时间戳校验就会拒绝,两条防线各有分工。客户端还需要做一件事:首次启动时从服务端时间接口获取时间校准偏移量,后续请求都使用校准后的时间生成timestamp,大幅降低因设备手动改时间导致的误拒。

3.3 nonce去重的Redis实现

时间戳窗口只能保证请求在五分钟内不过期,不能保证五分钟内只被提交一次。所以每个请求都要带一个nonce(随机字符串),服务端把已经见过的nonce缓存起来,遇到重复的直接拒绝。实现上用Redis的SET NX EX命令最合适,单条命令原子完成“判重+写入+过期”三个动作。

import time def check_nonce(app_id: str, nonce: str, redis_cli) -> bool: nonce_key = f"nonce:{app_id}:{nonce}" # nx=True 表示只有key不存在时才写入 # ex=600 表示600秒后自动过期,对应前面5分钟窗口的两倍 ok = redis_cli.set(nonce_key, "1", ex=600, nx=True) return ok is True def request_handler(): params = request.get_json(force=True) # 建议先校验签名,再校验时间戳,最后check nonce if not verify_sign(...): return jsonify({"code": 403, "msg": "invalid signature"}), 403 timestamp = int(params.get("timestamp", 0)) if abs(time.time() - timestamp) > 300: return jsonify({"code": 401, "msg": "request expired"}), 401 if not check_nonce(params["appId"], params.get("nonce", ""), redis_cli): return jsonify({"code": 409, "msg": "duplicate request"}), 409 # 通过全部校验,进入业务逻辑

注意,nonce的过期时间要大于时间戳窗口的2倍,这样即使对方在过期前最后一刻重放,Redis里还能查到这个nonce。我们的做法是固定10分钟过期,和5分钟窗口错开,给时钟偏差异常的请求留出缓冲。

3.4 幂等键和数据库唯一索引兜底

Redis里的nonce判断存在一个极端情况:Redis服务短暂不可用,或nonce刚好在两次请求之间过期了。这时候如果业务逻辑直接执行,重复请求仍然可能生效,所以必须在数据库层做兜底。返利入账记录表要建立唯一索引,最简单可靠的方案是(user_id, order_id, merchant_id)三重唯一。并发情况下,两条相同请求同时插入,数据库唯一索引保证只有一条成功,捕获到冲突的请求返回“重复提交”。

ALTER TABLE rebate_record ADD UNIQUE KEY uk_user_order_merchant (user_id, order_id, merchant_id);

秒杀的瞬时并发比返利回调高得多,返利场景通常不需要自研分布式锁。唯一索引加事务的兜底已经足够。如果追求更稳妥,可以在业务代码里先查后插,但绝不要依赖“先查后插”作为唯一保护——并发时序下两个请求可能同时查到不存在,然后同时插入。唯一索引才是硬约束。

4. 数据加密:从传输层到存储层的纵深防护

4.1 HTTPS之外的最后一公里

很多团队觉得上了HTTPS就万事大吉,其实HTTPS保护的是客户端和服务器之间的传输链路,一旦请求到达服务器,经过日志、数据库、数据管道,任何一环都可能泄露明文手机号、身份证号、银行卡号。返利平台涉及用户手机号、实名信息、银行卡绑定,这些字段在传输层和存储层都必须加密。

我们采用的是传输层HTTPS加应用层敏感字段加密的双层方案。客户端在加密前先把手机号等敏感字段用AES-GCM加密成密文字段,服务端拿到后先解密再处理。这个做法的价值在于,即使中间人拿到了请求报文,或者后端日志意外打印了请求参数,敏感信息仍然不可读。当然,分层加密会增加调用方的开发复杂度,所以只对真正高敏的字段启用,普通用户名和昵称不做应用层加密。

4.2 AES-256-GCM落库方案

字段加密用AES-256-GCM比较合适。GCM是认证加密模式,除了加密还带完整性校验,解密时能发现密文被篡改,比ECB/CBC更安全。每个密文至少要存IV和认证标签,我们统一格式为IV(12字节) + TAG(16字节) + 密文。

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes import os def encrypt_sensitive(key: bytes, plaintext: bytes) -> bytes: iv = os.urandom(12) encryptor = Cipher(algorithms.AES(key), modes.GCM(iv)).encryptor() ciphertext = encryptor.update(plaintext) + encryptor.finalize() # 返回 iv + tag + ciphertext return iv + encryptor.tag + ciphertext def decrypt_sensitive(key: bytes, payload: bytes) -> bytes: iv = payload[:12] tag = payload[12:28] ciphertext = payload[28:] decryptor = Cipher(algorithms.AES(key), modes.GCM(iv, tag)).decryptor() return decryptor.update(ciphertext) + decryptor.finalize()

如果不想手写这些细节,直接用Fernet也行,Fernet本质上就是AES加密加HMAC认证封装好的格式。我更喜欢GCM是因为在统一密钥管理时,字段长度可以自己控制,密钥轮换策略也更灵活。

加密字段落库后,会立刻遇到一个实际问题:手机号没法模糊查询了。密文不可比较,直接LIKE '%138%'完全失效。我们的方案是冗余一个脱敏索引字段:比如单独存手机号后四位和掩码结果。精确查询场景用SHA-256加盐生成索引值,模糊查询场景用脱敏字段走索引,既兼顾功能又避免回退到明文存储。

4.3 密钥管理与定期轮换

数据库加密密钥不能和数据库备份放在同一个地方。如果加密密钥和密文一起泄露,加密就失去了意义。我们的密钥体系分两层:主密钥由配置中心保存,日常使用的数据密钥由主密钥加密后下发到服务内存。轮换数据密钥时,新旧密钥并存一段时间,新的写入用新密钥,旧的存量数据继续用旧密钥解密,等数据迁移完成后再彻底下线旧密钥。

有一条要提前想清楚:密钥轮换不是重启服务就能完成的事,如果存量数据全部用旧密钥加密,新密钥加载后,解密旧数据会报错。我们当时因为仓库泄露被迫轮换,解决方式是离线脚本全量解密再重新加密。所以设计阶段就要支持配置多个解密密钥,解密时先尝试当前密钥,失败再尝试历史密钥,这一点非常关键。

4.4 日志脱敏

安全建设里最容易被忽略的就是日志系统。某次排查问题时,我发现测试环境的日志把手机号完整打印出来了,追查发现请求日志中间件只处理了顶层参数,手机号藏在嵌套对象里,过滤器没覆盖到。这类问题比接口攻击更难防,因为日志会被复制到日志平台、离线数仓,泄漏面很大。

我们给日志中间件加了一层脱敏函数,所有字段名包含phone、mobile、idCard、bankCard的,不管在第几层,都统一做掩码处理。手机号保留前三位和后四位,中间用星号代替;身份证号保留前四后四;银行卡号同理。同时建立一条纪律——线上环境禁止打印请求参数全集,需要联调时用专门的脱敏开关,只有打开开关且记录请求ID才能查完整参数。

5. 权限体系:谁能在什么条件下动哪些数据

5.1 四种角色与最小权限原则

返利平台至少有四类角色:普通用户、运营人员、合作商户、内部系统任务。权限设计的第一步是定义角色能力矩阵,明确每个角色能调用哪些接口、能看哪些数据。我们的矩阵简表如下:

角色可访问接口范围数据范围
普通用户浏览返利商品、领取优惠券、提交/查询本人的返利订单仅本人数据
运营人员订单管理、用户申诉处理、报表查询按渠道/城市划分的数据域
合作商户上传商品、查询本商户订单与佣金仅本商户数据
内部系统任务订单同步、对账、结算按任务定义的数据集合

这个矩阵的意义是让权限设计有据可查,而不是在代码里随手加判断。角色增加时,先更新矩阵再开发,避免权限越界。

5.2 token设计:JWT用对才安全

用户登录后的身份凭证,我们用的是短时效JWT加服务端状态校验。JWT本身不是魔鬼,用错才是。常见误区有三个:payload里塞敏感信息、secret硬编码在代码里、只校验签名不校验过期时间。

我们的做法是JWT里只放userId、角色、会话版本号,有效期30分钟。为了支持退出登录和封禁,Redis里保存一个会话白名单,token的jti字段要和Redis里的记录一致才放行。这样即使JWT被偷,也能通过服务端主动失效。生成和校验逻辑如下:

import jwt import os import time SECRET = os.environ["JWT_SECRET"] def create_token(user_id: int, role: str, jti: str) -> str: payload = { "uid": user_id, "role": role, "jti": jti, "iat": int(time.time()), "exp": int(time.time()) + 1800, # 30分钟 } return jwt.encode(payload, SECRET, algorithm="HS256") def parse_token(token: str) -> dict: try: return jwt.decode(token, SECRET, algorithms=["HS256"]) except jwt.ExpiredSignatureError: raise PermissionError("token expired") except jwt.InvalidTokenError: raise PermissionError("invalid token")

30分钟的有效期对返利平台足够,用户每次切后台回来基本都会刷新token。如果一个操作执行时间超过30分钟导致token过期,前端可以捕获401后用refresh_token换新token,不打断操作。

5.3 接口级权限和数据级权限要分开看

接口级权限是“能不能调用这个接口”,数据级权限是“调用成功后只能看到哪些数据”。两者都要做,后者的漏洞更隐蔽。我们遇到过一类典型问题:用户A调用查询订单详情接口,把路径里的订单ID改成用户B的订单,接口本身并没有校验订单归属,返回了B的订单信息。这就是水平越权。

修复方式是在Service层强制校验数据归属。查询订单详情前,只允许拼接当前登录用户的ID条件,不允许直接用前端传来的订单ID查库。用一句直白的话说——查询条件里必须带上当前身份约束,而不是依赖前端自觉。接口权限用装饰器或中间件统一控制,示例代码如下:

from functools import wraps def require_roles(*roles): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): user = get_current_user() if not user or user.role not in roles: return {"code": 403, "msg": "forbidden"}, 403 return func(*args, **kwargs) return wrapper return decorator

内部系统任务之间的调用不依赖用户token,而是使用上一篇提到的appId和secret做服务级认证,每次调用同样带签名和时间戳。唯一区别是系统任务不需要nonce防重放,我们会额外要求任务请求必须带幂等键,由接收方做去重。

5.4 越权测试清单

权限体系上线后,我们整理了一份越权测试清单,每次发版前由测试团队逐条跑一遍。清单的框架如下:

测试项测试方式期望结果
水平越权用户A登录后,修改订单ID/用户ID为其他用户的值返回无权限或空数据
垂直越权普通用户token调用运营/管理员接口返回403
未登录访问不带token调用受保护接口返回401或跳转登录
数据域隔离运营A尝试访问其他运营负责的渠道返回无权限
商户数据隔离商户A查询商户B的商品和结算数据返回无权限

这份清单看起来简单,但实际能拦住大部分上线事故。权限体系不需要一开始就做到完美,可以在每次修复一个问题后把案例补充进清单,让回归测试覆盖到真实发生过的漏洞。

6. 这套架构在运营中踩过的坑与修坑记录

6.1 签名信息被打进日志的事件

上线签名中间件大约两周后,我们发现所有签名用的secret都进了应用日志。原因是我们日志中间件打印了完整的请求参数JSON,而参数里包含请求方上传的secret。虽然前端传的secret理论上只属于当前调用方,但日志平台按天归档,访问权限比应用服务器宽松得多,这等于把密钥送到了更多人手里。

修复分三步:日志中间件增加敏感字段过滤,secret字段无论出现在哪一层都替换成***;日志平台检索接口增加审计,查询含参数体的日志需要双人审批;同时要求前端停止上传明文secret,改为服务端根据appId查询对应secret。这件事给我们的教训是,安全模块本身也会创造新的攻击面,必须带着批判的眼光审视日志、监控、配置体系。

6.2 客户端时钟不准导致签名失效

上线时间戳校验后,陆续有用户反馈App无法使用,查看日志是签名校验通过但时间戳超窗。调查发现部分用户的手机时间手动调整或者网络自动校准机制异常,设备时间落后超过10分钟,所有请求都被秒拒。问题是真实的,方案也是对的,但需要兼容真实的设备环境。

修复方案是双管齐下。客户端每次启动时调用时间同步接口,拿到服务端时间和本机时间差,之后生成请求timestamp时先加上偏移量;服务端针对登录用户不做额外优待,但签名校验失败时返回当前服务端时间,帮客户端计算偏差。另外我们一度考虑把时间戳窗口从300秒扩大到600秒,但最终没有采用,因为nonce缓存时间固定10分钟,窗口扩大了重放风险也相应增大,靠缓存兜底不划算。

6.3 上游订单回调乱序引发的重放误判

防重放上线之后,最折腾的问题是订单状态回调乱序。上游电商联盟的回调请求本身是异步多发的,可能出现“已确认收货”回调先到,“已付款”回调后到。我们当时的防重放逻辑把后到的回调直接当重复请求拒绝,结果一部分订单状态永远停留在“已付款”,返利计算没有触发,用户来找客服,运营来找开发,一团乱。

排查链路走了一圈才发现,问题不在防重放本身,而在“把重放和乱序混为一谈”。同一个订单ID的回调确实会多次到达,但每次到达的业务状态不同,它们不是重放,而是合法的状态变更。最终方案分两点:防重放校验只针对同一事件ID的同一状态请求,不同状态的回调放行;订单状态更新采用状态机模型,允许外部事件按到达顺序更新,但内部始终维护最新的业务时间戳,旧状态回调不再影响返利金额。这个坑告诉我们,任何安全策略都必须结合业务语义判断,不能一刀切。

6.4 安全模块上线后如何量化效果

从只依赖HTTPS到四层安全模块全部落地,经历了大概三个月。这段期间我们开始每周统计安全指标:签名失败请求数、nonce重复拦截数、越权测试通过率、日志敏感字段命中数。第一周这些数字都不好看,恶意脚本尝试比较频繁;一个月后,签名失败和nonce重复数量稳定下降,大量脚本在第一步签名校验就被拦下,根本走不到业务逻辑。

还有一个额外的好处:上游电商联盟做年度安全审计时,我们的接口签名和权限体系文档直接通过,省掉了很多重复沟通成本。安全模块投入的ROI不太好精确计算,但有一个直观数据:恶意刷单事件从上线早期的每月数次降为基本为零。对我们这样的返利平台来说,这就是安全建设价值最直接的体现。

如果让我给正在起步的团队一个建议,就是在写第一行业务代码之前,先想清楚资金走向和接口暴露面。签名、防重放、加密、权限这些模块不必一次做到完美,但要在系统还小的时候把骨架搭好。返利平台最危险的阶段不是业务量大的时候,而是业务量增长而安全体系还没跟上来的那段窗口期。先立规矩再做业务,这在返利平台上不是一句口号。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进