资讯动态

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

发布时间:2026/10/19 2:59:47 来源:尧图企业网站定制
做电商返利平台技术上最核心的一句话是返利是资金流动的凭证接口就是闸门。我在带团队搭建第一个返利系统的初始阶段对用户增长、返利比例计算、和上游电商联盟的对账这些问题关注很多直到有一次线上预警把我们从业务兴奋中打醒——有人绕过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的字典序升序排序用等号连接键值对再用拼接最后在串尾部追加keysecret以整串作为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(keylambda 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(forceTrue) 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和secretApp端、小程序端、运营后台、内部定时任务各自不同。App端可以预置属于自己的secret吗坦白说严格意义上客户端里的secret是可以被逆向的所以真正高安全的场景会使用非对称签名或动态票据。我们评估过风险返利平台App端的secret泄露后攻击者最多替自己账户生成合法签名但无法冒充服务端也无法获取其他人的数据真正关键的数据权限是靠另一个维度限制的。因此现阶段App端采用对称密钥可以接受但要避免把全局统一secret放进去。2.4 签名校验失败时的分级处理校验失败不能只回一个笼统的错误但也不能把细节全暴露给请求方。我们的做法是区分错误码缺参数返回400签名错误返回403时间戳超过允许窗口返回401nonce重复返回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 fnonce:{app_id}:{nonce} # nxTrue 表示只有key不存在时才写入 # ex600 表示600秒后自动过期对应前面5分钟窗口的两倍 ok redis_cli.set(nonce_key, 1, ex600, nxTrue) return ok is True def request_handler(): params request.get_json(forceTrue) # 建议先校验签名再校验时间戳最后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, algorithmHS256) 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不太好精确计算但有一个直观数据恶意刷单事件从上线早期的每月数次降为基本为零。对我们这样的返利平台来说这就是安全建设价值最直接的体现。如果让我给正在起步的团队一个建议就是在写第一行业务代码之前先想清楚资金走向和接口暴露面。签名、防重放、加密、权限这些模块不必一次做到完美但要在系统还小的时候把骨架搭好。返利平台最危险的阶段不是业务量大的时候而是业务量增长而安全体系还没跟上来的那段窗口期。先立规矩再做业务这在返利平台上不是一句口号。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑